From c68c33d4fea5b34bf2ca8529b4f54fdb58d07701 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:49:32 -0500 Subject: [PATCH 001/174] docs: remove deprecated modes.mdx pages (#25227) --- packages/web/src/content/docs/ar/modes.mdx | 329 ------------------ packages/web/src/content/docs/bs/modes.mdx | 314 ----------------- packages/web/src/content/docs/da/modes.mdx | 329 ------------------ packages/web/src/content/docs/de/modes.mdx | 329 ------------------ packages/web/src/content/docs/es/modes.mdx | 329 ------------------ packages/web/src/content/docs/fr/modes.mdx | 327 ----------------- packages/web/src/content/docs/it/modes.mdx | 328 ----------------- packages/web/src/content/docs/ja/modes.mdx | 327 ----------------- packages/web/src/content/docs/ko/modes.mdx | 327 ----------------- packages/web/src/content/docs/modes.mdx | 329 ------------------ packages/web/src/content/docs/nb/modes.mdx | 328 ----------------- packages/web/src/content/docs/pl/modes.mdx | 329 ------------------ packages/web/src/content/docs/pt-br/modes.mdx | 326 ----------------- packages/web/src/content/docs/ru/modes.mdx | 329 ------------------ packages/web/src/content/docs/th/modes.mdx | 329 ------------------ packages/web/src/content/docs/tr/modes.mdx | 329 ------------------ packages/web/src/content/docs/zh-cn/modes.mdx | 326 ----------------- packages/web/src/content/docs/zh-tw/modes.mdx | 326 ----------------- 18 files changed, 5890 deletions(-) delete mode 100644 packages/web/src/content/docs/ar/modes.mdx delete mode 100644 packages/web/src/content/docs/bs/modes.mdx delete mode 100644 packages/web/src/content/docs/da/modes.mdx delete mode 100644 packages/web/src/content/docs/de/modes.mdx delete mode 100644 packages/web/src/content/docs/es/modes.mdx delete mode 100644 packages/web/src/content/docs/fr/modes.mdx delete mode 100644 packages/web/src/content/docs/it/modes.mdx delete mode 100644 packages/web/src/content/docs/ja/modes.mdx delete mode 100644 packages/web/src/content/docs/ko/modes.mdx delete mode 100644 packages/web/src/content/docs/modes.mdx delete mode 100644 packages/web/src/content/docs/nb/modes.mdx delete mode 100644 packages/web/src/content/docs/pl/modes.mdx delete mode 100644 packages/web/src/content/docs/pt-br/modes.mdx delete mode 100644 packages/web/src/content/docs/ru/modes.mdx delete mode 100644 packages/web/src/content/docs/th/modes.mdx delete mode 100644 packages/web/src/content/docs/tr/modes.mdx delete mode 100644 packages/web/src/content/docs/zh-cn/modes.mdx delete mode 100644 packages/web/src/content/docs/zh-tw/modes.mdx diff --git a/packages/web/src/content/docs/ar/modes.mdx b/packages/web/src/content/docs/ar/modes.mdx deleted file mode 100644 index ed17670a5..000000000 --- a/packages/web/src/content/docs/ar/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: الأوضاع -description: أوضاع مختلفة لحالات استخدام مختلفة. ---- - -:::caution -يتم الآن ضبط الأوضاع عبر خيار `agent` في إعدادات opencode. أصبح خيار -`mode` مُهمَلًا الآن. [اعرف المزيد](/docs/agents). -::: - -تتيح لك الأوضاع في opencode تخصيص السلوك والأدوات والمطالبات لحالات استخدام مختلفة. - -يأتي مع وضعين مدمجين: **build** و **plan**. يمكنك تخصيصهما أو إعداد -أوضاعك الخاصة عبر إعدادات opencode. - -يمكنك التبديل بين الأوضاع أثناء الجلسة أو إعدادها في ملف الإعدادات لديك. - ---- - -## الأوضاع المدمجة - -يأتي opencode مع وضعين مدمجين. - ---- - -### Build - -وضع Build هو الوضع **الافتراضي** مع تفعيل جميع الأدوات. هذا هو الوضع القياسي لأعمال التطوير عندما تحتاج إلى وصول كامل لعمليات الملفات وأوامر النظام. - ---- - -### Plan - -وضع مقيَّد مُصمَّم للتخطيط والتحليل. في وضع plan، تكون الأدوات التالية مُعطَّلة افتراضيًا: - -- `write` - لا يمكن إنشاء ملفات جديدة -- `edit` - لا يمكن تعديل الملفات الموجودة، باستثناء الملفات الموجودة في `.opencode/plans/*.md` لتفصيل الخطة نفسها -- `patch` - لا يمكن تطبيق التصحيحات -- `bash` - لا يمكن تنفيذ أوامر shell - -يكون هذا الوضع مفيدًا عندما تريد من الذكاء الاصطناعي تحليل الشيفرة، أو اقتراح تغييرات، أو إنشاء خطط دون إجراء أي تعديلات فعلية على قاعدة الشيفرة لديك. - ---- - -## التبديل - -يمكنك التبديل بين الأوضاع أثناء الجلسة باستخدام مفتاح _Tab_، أو اختصار `switch_mode` الذي قمت بإعداده. - -انظر أيضًا: [Formatters](/docs/formatters) لمعلومات حول إعدادات تنسيق الشيفرة. - ---- - -## الإعداد - -يمكنك تخصيص الأوضاع المدمجة أو إنشاء أوضاعك الخاصة عبر الإعدادات. يمكن إعداد الأوضاع بطريقتين: - -### إعدادات JSON - -قم بإعداد الأوضاع في ملف الإعدادات `opencode.json` لديك: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### إعدادات Markdown - -يمكنك أيضًا تعريف الأوضاع باستخدام ملفات markdown. ضعها في: - -- عالمي: `~/.config/opencode/modes/` -- للمشروع: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -يصبح اسم ملف markdown هو اسم الوضع (على سبيل المثال، `review.md` ينشئ وضعًا باسم `review`). - -لنلقِ نظرة على خيارات الإعداد هذه بمزيد من التفصيل. - ---- - -### النموذج - -استخدم إعداد `model` لتجاوز النموذج الافتراضي لهذا الوضع. يفيد ذلك عند استخدام نماذج مختلفة مُحسَّنة لمهام مختلفة؛ مثل نموذج أسرع للتخطيط ونموذج أكثر قدرة للتنفيذ. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### درجة الحرارة - -تحكَّم في العشوائية والإبداع في ردود الذكاء الاصطناعي عبر إعداد `temperature`. القيم الأقل تجعل الردود أكثر تركيزًا وحتمية، بينما تزيد القيم الأعلى الإبداع والتنوّع. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -تتراوح قيم درجة الحرارة عادةً بين 0.0 و 1.0: - -- **0.0-0.2**: ردود شديدة التركيز وحتمية، مثالية لتحليل الشيفرة والتخطيط -- **0.3-0.5**: ردود متوازنة مع قدر من الإبداع، مناسبة لمهام التطوير العامة -- **0.6-1.0**: ردود أكثر إبداعًا وتنوّعًا، مفيدة للعصف الذهني والاستكشاف - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -إذا لم يتم تحديد درجة الحرارة، يستخدم opencode القيم الافتراضية الخاصة بكل نموذج (عادةً 0 لمعظم النماذج و 0.55 لنماذج Qwen). - ---- - -### الموجّه - -حدِّد ملف موجّه نظام (system prompt) مخصص لهذا الوضع عبر إعداد `prompt`. ينبغي أن يحتوي ملف الموجّه على تعليمات مرتبطة بهدف هذا الوضع. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -هذا المسار نسبي بالنسبة لموقع ملف الإعدادات. لذلك يعمل مع -إعدادات opencode العالمية وكذلك إعدادات المشروع. - ---- - -### الأدوات - -تحكَّم في الأدوات المتاحة في هذا الوضع عبر إعداد `tools`. يمكنك تفعيل أدوات محددة أو تعطيلها بضبطها على `true` أو `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -إذا لم يتم تحديد أدوات، فستكون جميع الأدوات مفعّلة افتراضيًا. - ---- - -#### الأدوات المتاحة - -فيما يلي جميع الأدوات التي يمكن التحكم بها عبر إعدادات الوضع. - -| الأداة | الوصف | -| ----------- | -------------------------- | -| `bash` | تنفيذ أوامر shell | -| `edit` | تعديل الملفات الموجودة | -| `write` | إنشاء ملفات جديدة | -| `read` | قراءة محتويات الملفات | -| `grep` | البحث في محتويات الملفات | -| `glob` | العثور على الملفات حسب نمط | -| `patch` | تطبيق تصحيحات على الملفات | -| `todowrite` | إدارة قوائم المهام | -| `webfetch` | جلب محتوى الويب | - ---- - -## أوضاع مخصصة - -يمكنك إنشاء أوضاعك المخصصة بإضافتها إلى الإعدادات. فيما يلي أمثلة باستخدام كلا الأسلوبين: - -### باستخدام إعدادات JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### باستخدام ملفات markdown - -أنشئ ملفات الأوضاع في `.opencode/modes/` لأوضاع خاصة بالمشروع أو في `~/.config/opencode/modes/` لأوضاع عالمية: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### حالات الاستخدام - -فيما يلي بعض حالات الاستخدام الشائعة لأوضاع مختلفة. - -- **Build mode**: أعمال تطوير كاملة مع تفعيل جميع الأدوات -- **Plan mode**: التحليل والتخطيط دون إجراء تغييرات -- **Review mode**: مراجعة الشيفرة مع وصول للقراءة فقط بالإضافة إلى أدوات التوثيق -- **Debug mode**: تركيز على الاستقصاء مع تفعيل أدوات `bash` و `read` -- **Docs mode**: كتابة التوثيق مع عمليات الملفات لكن دون أوامر النظام - -قد تجد أيضًا أن نماذج مختلفة تكون أنسب لحالات استخدام مختلفة. diff --git a/packages/web/src/content/docs/bs/modes.mdx b/packages/web/src/content/docs/bs/modes.mdx deleted file mode 100644 index d5f92a9f6..000000000 --- a/packages/web/src/content/docs/bs/modes.mdx +++ /dev/null @@ -1,314 +0,0 @@ ---- -title: Načini rada -description: Različiti načini za različite slučajeve upotrebe. ---- - -:::caution -Načini se sada konfiguriraju preko opcije `agent` u konfiguraciji otvorenog koda. The -`mode` opcija je sada zastarjela. [Saznajte više](/docs/agents). -::: - -Režimi u otvorenom kodu omogućavaju vam da prilagodite ponašanje, alate i upite za različite slučajeve upotrebe. -Dolazi sa dva ugrađena načina rada: **build** i **plan**. Možete prilagoditi -ove ili konfigurirajte svoje putem opencode config. -Možete se prebacivati ​​između režima tokom sesije ili ih konfigurisati u svom konfiguracionom fajlu. - ---- - -## Ugrađeni - -opencode dolazi sa dva ugrađena načina rada. - -### Build - -Build je **podrazumijevani** režim sa svim omogućenim alatima. Ovo je standardni način rada za razvoj kada vam treba pun pristup fajlovima i sistemskim komandama. - -### Plan - -Ograničeni način rada dizajniran za planiranje i analizu. U načinu plana, sljedeći alati su onemogućeni prema zadanim postavkama: - -- `write` - Ne mogu kreirati nove fajlove -- `edit` - Ne mogu modificirati postojeće fajlove, osim fajlova koji se nalaze na `.opencode/plans/*.md` radi detaljiziranja samog plana -- `patch` - Ne mogu primijeniti zakrpe -- `bash` - Ne mogu izvršiti naredbe ljuske - Ovaj način rada je koristan kada želite da AI analizira kod, predlaže promjene ili kreira planove bez ikakvih stvarnih modifikacija u vašoj bazi kodova. - ---- - -## Prebacivanje - -Možete se prebacivati ​​između načina rada tokom sesije pomoću tipke _Tab_. Ili vaše konfigurirano `switch_mode` spajanje tipki. -Vidi također: [Formatters](/docs/formatters) za informacije o konfiguraciji formatiranja koda. - ---- - -## Konfiguracija - -Možete prilagoditi ugrađene načine rada ili kreirati vlastite kroz konfiguraciju. Modovi se mogu konfigurirati na dva načina: - -### JSON konfiguracija - -Konfigurirajte načine rada u svom `opencode.json` konfiguracijskom fajlu: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown konfiguracija - -Također možete definirati načine rada koristeći markdown datoteke. Postavite ih u: - -- Globalno: `~/.config/opencode/modes/` -- Projekat: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Naziv datoteke s uštedom postaje naziv načina (npr. `review.md` kreira `review` način rada). -Pogledajmo ove opcije konfiguracije detaljno. - ---- - -### Model - -Koristite `model` konfiguraciju da nadjačate zadani model za ovaj način rada. Korisno za korištenje različitih modela optimiziranih za različite zadatke. Na primjer, brži model za planiranje, sposobniji model za implementaciju. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Kontrolišite slučajnost i kreativnost odgovora AI pomoću `temperature` konfiguracije. Niže vrijednosti čine odgovore fokusiranijim i determinističkim, dok veće vrijednosti povećavaju kreativnost i varijabilnost. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Vrijednosti temperature obično se kreću od 0,0 do 1,0: - -- **0,0-0,2**: Vrlo fokusirani i deterministički odgovori, idealni za analizu i planiranje koda -- **0,3-0,5**: Uravnoteženi odgovori sa malo kreativnosti, dobro za opšte razvojne zadatke -- **0,6-1,0**: kreativniji i raznovrsniji odgovori, korisni za razmišljanje i istraživanje - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Ako temperatura nije navedena, opencode koristi podrazumijevane postavke specifične za model (obično 0 za većinu modela i 0.55 za Qwen modele). - -### Upit - -Navedite prilagođenu sistemsku datoteku prompta za ovaj način rada s konfiguracijom `prompt`. Datoteka s promptom treba da sadrži upute specifične za svrhu načina rada. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ova putanja je relativna u odnosu na mjesto gdje se nalazi konfiguracijski fajl. Dakle, ovo radi za -i globalnu konfiguraciju otvorenog koda i konfiguraciju specifične za projekat. - ---- - -### Alati - -Kontrolirajte koji su alati dostupni u ovom načinu rada pomoću `tools` konfiguracije. Možete omogućiti ili onemogućiti određene alate tako što ćete ih postaviti na `true` ili `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Ako nijedan alat nije specificiran, svi alati su omogućeni po defaultu. - -#### Dostupni alati - -Ovdje su svi alati koji se mogu kontrolirati kroz konfiguraciju načina rada. -| Alat | Opis -|----------- | ----------------------- | -| `bash` | Izvrši naredbe ljuske | -| `edit` | Izmijenite postojeće datoteke | -| `write` | Kreirajte nove fajlove | -| `read` | Pročitajte sadržaj datoteke | -| `grep` | Pretraži sadržaj datoteke | -| `glob` | Pronađite datoteke po uzorku | -| `patch` | Primijenite zakrpe na datoteke | -| `todowrite` | Upravljanje listama zadataka | -| `webfetch` | Dohvati web sadržaj | - ---- - -## Prilagođeni načini rada - -Možete kreirati vlastite prilagođene modove tako što ćete ih dodati u konfiguraciju. Evo primjera koji koriste oba pristupa: - -### Korištenje JSON konfiguracije - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Korištenje markdown fajlova - -Kreirajte fajlove načina u `.opencode/modes/` za specifične načine rada ili `~/.config/opencode/modes/` za globalne načine: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Slučajevi upotrebe - -Evo nekoliko uobičajenih slučajeva upotrebe za različite načine rada. - -- **Build mode**: Potpuni razvojni rad sa svim omogućenim alatima -- **Plan mode**: Analiza i planiranje bez izmjena -- **Review mode**: Pregled koda sa pristupom samo za čitanje plus alati za dokumentaciju -- **Debug mode**: Fokusiran na istragu sa omogućenim bash i alatima za čitanje -- **Docs mode**: Pisanje dokumentacije sa operacijama datoteka, ali bez sistemskih naredbi - Možda ćete također otkriti da su različiti modeli dobri za različite slučajeve upotrebe. diff --git a/packages/web/src/content/docs/da/modes.mdx b/packages/web/src/content/docs/da/modes.mdx deleted file mode 100644 index a0fb87a86..000000000 --- a/packages/web/src/content/docs/da/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Tilstande -description: Forskellige tilstande til forskellige anvendelsestilfælde. ---- - -:::caution -Tilstande er nu konfigureret gennem indstillingen `agent` i opencode-konfigurationen. De -`mode` mulighed er nu forældet. [Learn more](/docs/agents). -::: - -Tilstande i opencode giver dig mulighed for at tilpasse adfærd, værktøjer og prompter til forskellige brugstilfælde. - -Den kommer med to indbyggede tilstande: **build** og **plan**. Du kan tilpasse -disse eller konfigurer dine egne gennem opencode-konfigurationen. - -Du kan skifte mellem tilstande under en session eller konfigurere dem i din konfigurationsfil. - ---- - -## Indbyggede - -opencode leveres med to indbyggede tilstande. - ---- - -### Byg - -Byg er **standard**-tilstanden med alle værktøjer aktiveret. Dette er standardtilstanden for udviklingsarbejde, hvor du har brug for fuld adgang til filhandlinger og systemkommandoer. - ---- - -### Plan - -En begrænset tilstand designet til planlægning og analyse. I plantilstand er følgende værktøjer deaktiveret som standard: - -- `write` - Kan ikke oprette nye filer -- `edit` - Kan ikke ændre eksisterende filer, undtagen filer placeret på `.opencode/plans/*.md` for at detaljere selve planen -- `patch` - Kan ikke anvende patches -- `bash` - Kan ikke udføre shell-kommandoer - -Denne tilstand er nyttig, når du vil have AI til at analysere kode, foreslå ændringer eller oprette planer uden at foretage egentlige ændringer af din kodebase. - ---- - -## Skift - -Du kan skifte mellem tilstande under en session ved at bruge _Tab_-tasten. Eller din konfigurerede `switch_mode` nøglebinding. - -Se også: [Formatters](/docs/formatters) for information om konfiguration af kodeformatering. - ---- - -## Konfiguration - -Du kan tilpasse de indbyggede tilstande eller oprette dine egne gennem konfiguration. Tilstande kan konfigureres på to måder: - -### JSON-konfiguration - -Konfigurer tilstande i din `opencode.json`-konfigurationsfil: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-konfiguration - -Du kan også definere tilstande ved hjælp af markdown-filer. Placer dem i: - -- Globalt: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown-filnavnet bliver tilstandsnavnet (f.eks. opretter `review.md` en `review`-tilstand). - -Lad os se nærmere på disse konfigurationsmuligheder. - ---- - -### Model - -Brug `model`-konfigurationen til at tilsidesætte standardmodellen for denne tilstand. Nyttigt til brug af forskellige modeller optimeret til forskellige opgaver. For eksempel en hurtigere model til planlægning, en mere dygtig model til implementering. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Styr tilfældigheden og kreativiteten af ​​AI's svar med `temperature`-konfigurationen. Lavere værdier gør svar mere fokuserede og deterministiske, mens højere værdier øger kreativitet og variabilitet. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturværdier varierer typisk fra 0,0 til 1,0: - -- **0.0-0.2**: Meget fokuserede og deterministiske svar, ideel til kodeanalyse og planlægning -- **0,3-0,5**: Afbalancerede svar med en vis kreativitet, god til generelle udviklingsopgaver -- **0.6-1.0**: Mere kreative og varierede svar, nyttige til brainstorming og udforskning - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Hvis der ikke er angivet nogen temperatur, bruger opencode modelspecifikke standarder (typisk 0 for de fleste modeller, 0,55 for Qwen-modeller). - ---- - -### Prompt - -Angiv en brugerdefineret systempromptfil for denne tilstand med `prompt`-konfigurationen. Promptfilen skal indeholde instruktioner, der er specifikke for tilstandens formål. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Denne sti er i forhold til, hvor konfigurationsfilen er placeret. Så dette virker for -både den globale opencode-konfiguration og den projektspecifikke konfiguration. - ---- - -### Værktøjer - -Kontroller, hvilke værktøjer der er tilgængelige i denne tilstand med `tools`-konfigurationen. Du kan aktivere eller deaktivere specifikke værktøjer ved at indstille dem til `true` eller `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hvis der ikke er angivet nogen værktøjer, er alle værktøjer aktiveret som standard. - ---- - -#### Tilgængelige værktøjer - -Her er alle de værktøjer, der kan styres gennem tilstandskonfigurationen. - -| Værktøj | Beskrivelse | -| ----------- | -------------------------- | -| `bash` | Udfør shell-kommandoer | -| `edit` | Rediger eksisterende filer | -| `write` | Opret nye filer | -| `read` | Læs filindhold | -| `grep` | Søg filindhold | -| `glob` | Find filer efter mønster | -| `patch` | Anvend patches til filer | -| `todowrite` | Administrer todo-lister | -| `webfetch` | Hent webindhold | - ---- - -## Brugerdefinerede tilstande - -Du kan oprette dine egne brugerdefinerede tilstande ved at tilføje dem til konfigurationen. Her er eksempler, der bruger begge tilgange: - -### Brug af JSON-konfiguration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Brug af markdown-filer - -Opret tilstandsfiler i `.opencode/modes/` for projektspecifikke tilstande eller `~/.config/opencode/modes/` for globale tilstande: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Brugsscenarier - -Her er nogle almindelige brugstilfælde for forskellige tilstande. - -- **Build mode**: Fuldt udviklingsarbejde med alle værktøjer aktiveret -- **Plantilstand**: Analyse og planlægning uden at foretage ændringer -- **Anmeldelsestilstand**: Kodegennemgang med skrivebeskyttet adgang plus dokumentationsværktøjer -- **Fejlretningstilstand**: Fokuseret på undersøgelse med bash og læseværktøjer aktiveret -- **Docs-tilstand**: Dokumentationsskrivning med filhandlinger, men ingen systemkommandoer - -Du kan også finde ud af, at forskellige modeller er gode til forskellige brugssituationer. diff --git a/packages/web/src/content/docs/de/modes.mdx b/packages/web/src/content/docs/de/modes.mdx deleted file mode 100644 index 11a010d6b..000000000 --- a/packages/web/src/content/docs/de/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modi -description: Verschiedene Modi für unterschiedliche Anwendungsfälle. ---- - -:::caution -Modi werden jetzt über die Option `agent` in der OpenCode-Konfiguration konfiguriert. Der -Die Option `mode` ist jetzt veraltet. [Learn more](/docs/agents). -::: - -Mit den Modi in OpenCode können Sie das Verhalten, die Tools und die Eingabeaufforderungen für verschiedene Anwendungsfälle anpassen. - -Es verfügt über zwei integrierte Modi: **Build** und **Plan**. Sie können anpassen -diese oder konfigurieren Sie Ihre eigenen über die OpenCode-Konfiguration. - -Sie können während einer Sitzung zwischen den Modi wechseln oder diese in Ihrer Konfigurationsdatei konfigurieren. - ---- - -## Integriert - -OpenCode verfügt über zwei integrierte Modi. - ---- - -### Build - -Build ist der **Standardmodus**, bei dem alle Tools aktiviert sind. Dies ist der Standardmodus für Entwicklungsarbeiten, bei dem Sie vollen Zugriff auf Dateioperationen und Systembefehle benötigen. - ---- - -### Plan - -Ein eingeschränkter Modus für Planung und Analyse. Im Planmodus sind die folgenden Tools standardmäßig deaktiviert: - -- `write` – Es können keine neuen Dateien erstellt werden -- `edit` – Vorhandene Dateien können nicht geändert werden, mit Ausnahme der Dateien unter `.opencode/plans/*.md`, um den Plan selbst detailliert darzustellen -- `patch` – Patches können nicht angewendet werden -- `bash` – Shell-Befehle können nicht ausgeführt werden - -Dieser Modus ist nützlich, wenn Sie möchten, dass AI Code analysiert, Änderungen vorschlägt oder Pläne erstellt, ohne tatsächliche Änderungen an Ihrer Codebasis vorzunehmen. - ---- - -## Wechseln - -Sie können während einer Sitzung mit der _Tab_-Taste zwischen den Modi wechseln. Oder Ihre konfigurierte `switch_mode`-Tastenkombination. - -Siehe auch: [Formatters](/docs/formatters) für Informationen zur Codeformatierungskonfiguration. - ---- - -## Konfiguration - -Sie können die integrierten Modi anpassen oder über die Konfiguration eigene erstellen. Modi können auf zwei Arten konfiguriert werden: - -### JSON-Konfiguration - -Konfigurieren Sie Modi in Ihrer `opencode.json`-Konfigurationsdatei: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-Konfiguration - -Sie können Modi auch mithilfe von Markdown-Dateien definieren. Platzieren Sie sie in: - -- Global: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Der Name der Markdown-Datei wird zum Modusnamen (e.g., `review.md` erstellt einen `review`-Modus). - -Schauen wir uns diese Konfigurationsmöglichkeiten im Detail an. - ---- - -### Model - -Verwenden Sie die `model`-Konfiguration, um das Standardmodell für diesen Modus zu überschreiben. Nützlich für die Verwendung verschiedener Modelle, die für verschiedene Aufgaben optimiert sind. Zum Beispiel ein schnelleres Modell für die Planung, ein leistungsfähigeres Modell für die Umsetzung. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Steuern Sie die Zufälligkeit und Kreativität der Antworten von AI mit der `temperature`-Konfiguration. Niedrigere Werte machen die Antworten fokussierter und deterministischer, während höhere Werte die Kreativität und Variabilität steigern. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturwerte liegen typischerweise zwischen 0.0 und 1.0: - -- **0.0-0.2**: Sehr fokussierte und deterministische Antworten, ideal für Code-Analyse und Planung -- **0.3-0.5**: Ausgewogene Antworten mit etwas Kreativität, gut für allgemeine Entwicklungsaufgaben -- **0.6-1.0**: Kreativere und vielfältigere Antworten, nützlich für Brainstorming und Erkundung - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Wenn keine Temperatur angegeben ist, verwendet OpenCode modellspezifische Standardwerte (normalerweise 0 für die meisten Modelle, 0.55 für Qwen-Modelle). - ---- - -### Prompt - -Geben Sie mit der `prompt`-Konfiguration eine benutzerdefinierte Systemaufforderungsdatei für diesen Modus an. Die Eingabeaufforderungsdatei sollte spezifische Anweisungen für den Zweck des Modus enthalten. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Dieser Pfad ist relativ zum Speicherort der Konfigurationsdatei. Das funktioniert also -sowohl die globale OpenCode-Konfiguration als auch die projektspezifische Konfiguration. - ---- - -### Werkzeuge - -Steuern Sie mit der `tools`-Konfiguration, welche Tools in diesem Modus verfügbar sind. Sie können bestimmte Tools aktivieren oder deaktivieren, indem Sie sie auf `true` oder `false` setzen. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Wenn keine Tools angegeben sind, sind alle Tools standardmäßig aktiviert. - ---- - -#### Verfügbare Tools - -Hier sind alle Tools aufgeführt, die über den Konfigurationsmodus gesteuert werden können. - -| Werkzeug | Beschreibung | -| ----------- | ---------------------------- | -| `bash` | Shell-Befehle ausführen | -| `edit` | Vorhandene Dateien ändern | -| `write` | Neue Dateien erstellen | -| `read` | Dateiinhalt lesen | -| `grep` | Dateiinhalte durchsuchen | -| `glob` | Dateien nach Muster suchen | -| `patch` | Patches auf Dateien anwenden | -| `todowrite` | Aufgabenlisten verwalten | -| `webfetch` | Webinhalte abrufen | - ---- - -## Benutzerdefinierte Modi - -Sie können Ihre eigenen benutzerdefinierten Modi erstellen, indem Sie diese zur Konfiguration hinzufügen. Hier sind Beispiele für beide Ansätze: - -### Verwenden der JSON-Konfiguration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Verwendung von Markdown-Dateien - -Erstellen Sie Modusdateien in `.opencode/modes/` für projektspezifische Modi oder `~/.config/opencode/modes/` für globale Modi: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Anwendungsfälle - -Hier sind einige häufige Anwendungsfälle für verschiedene Modi. - -- **Build-Modus**: Vollständige Entwicklungsarbeit mit allen aktivierten Tools -- **Planmodus**: Analyse und Planung ohne Änderungen -- **Überprüfungsmodus**: Codeüberprüfung mit schreibgeschütztem Zugriff plus Dokumentationstools -- **Debug-Modus**: Konzentriert sich auf die Untersuchung mit aktivierten Bash- und Lesetools -- **Docs-Modus**: Dokumentationsschreiben mit Dateioperationen, aber ohne Systembefehle - -Möglicherweise stellen Sie auch fest, dass unterschiedliche Modelle für unterschiedliche Anwendungsfälle geeignet sind. diff --git a/packages/web/src/content/docs/es/modes.mdx b/packages/web/src/content/docs/es/modes.mdx deleted file mode 100644 index dca900dff..000000000 --- a/packages/web/src/content/docs/es/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modos -description: Diferentes modos para diferentes casos de uso. ---- - -:::caution -Los modos ahora se configuran a través de la opción `agent` en la configuración opencode. El -La opción `mode` ahora está en desuso. [Más información](/docs/agents). -::: - -Los modos en opencode le permiten personalizar el comportamiento, las herramientas y las indicaciones para diferentes casos de uso. - -Viene con dos modos integrados: **construir** y **planificar**. Puedes personalizar -estos o configure el suyo propio a través de la configuración opencode. - -Puede cambiar entre modos durante una sesión o configurarlos en su archivo de configuración. - ---- - -## Integrados - -opencode viene con dos modos integrados. - ---- - -### Modo Build - -Build es el modo **predeterminado** con todas las herramientas habilitadas. Este es el modo estándar para el trabajo de desarrollo en el que necesita acceso completo a las operaciones de archivos y a los comandos del sistema. - ---- - -### Modo Plan - -Un modo restringido diseñado para la planificación y el análisis. En el modo de plan, las siguientes herramientas están deshabilitadas de forma predeterminada: - -- `write` - No se pueden crear archivos nuevos -- `edit` - No se pueden modificar archivos existentes, excepto los archivos ubicados en `.opencode/plans/*.md` para detallar el plan en sí. -- `patch` - No se pueden aplicar parches -- `bash` - No se pueden ejecutar comandos de shell - -Este modo es útil cuando desea que la IA analice código, sugiera cambios o cree planes sin realizar modificaciones reales en su base de código. - ---- - -## Cambiar de modo - -Puede cambiar entre modos durante una sesión usando la tecla _Tab_. O su combinación de teclas `switch_mode` configurada. - -Consulte también: [Formateadores](/docs/formatters) para obtener información sobre la configuración de formato de código. - ---- - -## Configuración - -Puede personalizar los modos integrados o crear los suyos propios mediante la configuración. Los modos se pueden configurar de dos maneras: - -### Configuración JSON - -Configure los modos en su archivo de configuración `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuración de Markdown - -También puede definir modos utilizando archivos de Markdown. Colócalos en: - -- Global: `~/.config/opencode/modes/` -- Proyecto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -El nombre del archivo de Markdown se convierte en el nombre del modo (por ejemplo, `review.md` crea un modo `review`). - -Veamos estas opciones de configuración en detalle. - ---- - -### Modelo - -Utilice la configuración `model` para anular el modelo predeterminado para este modo. Útil para utilizar diferentes modelos optimizados para diferentes tareas. Por ejemplo, un modelo más rápido de planificación, un modelo más capaz de implementación. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controle la aleatoriedad y la creatividad de las respuestas de la IA con la configuración `temperature`. Los valores más bajos hacen que las respuestas sean más centradas y deterministas, mientras que los valores más altos aumentan la creatividad y la variabilidad. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Los valores de temperatura suelen oscilar entre 0,0 y 1,0: - -- **0.0-0.2**: Respuestas muy enfocadas y deterministas, ideales para análisis y planificación de código. -- **0,3-0,5**: respuestas equilibradas con algo de creatividad, buenas para tareas de desarrollo general. -- **0.6-1.0**: respuestas más creativas y variadas, útiles para la lluvia de ideas y la exploración. - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Si no se especifica ninguna temperatura, opencode utiliza valores predeterminados específicos del modelo (normalmente 0 para la mayoría de los modelos, 0,55 para los modelos Qwen). - ---- - -### Indicación - -Especifique un archivo de aviso del sistema personalizado para este modo con la configuración `prompt`. El archivo de aviso debe contener instrucciones específicas para el propósito del modo. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Esta ruta es relativa a donde se encuentra el archivo de configuración. Entonces esto funciona para -tanto la configuración global opencode como la configuración específica del proyecto. - ---- - -### Herramientas - -Controle qué herramientas están disponibles en este modo con la configuración `tools`. Puede habilitar o deshabilitar herramientas específicas configurándolas en `true` o `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Si no se especifica ninguna herramienta, todas las herramientas están habilitadas de forma predeterminada. - ---- - -#### Herramientas disponibles - -Aquí están todas las herramientas que se pueden controlar a través del modo de configuración. - -| Herramienta | Descripción | -| ----------- | --------------------------------------- | -| `bash` | Ejecutar comandos de shell | -| `edit` | Modificar archivos existentes | -| `write` | Crear nuevos archivos | -| `read` | Leer el contenido del archivo | -| `grep` | Buscar contenido del archivo | -| `glob` | Buscar archivos por patrón | -| `patch` | Aplicar parches a archivos | -| `todowrite` | Administrar listas de tareas pendientes | -| `webfetch` | Obtener contenido web | - ---- - -## Modos personalizados - -Puede crear sus propios modos personalizados agregándolos a la configuración. A continuación se muestran ejemplos que utilizan ambos enfoques: - -### Usando la configuración JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando archivos de Markdown - -Cree archivos de modo en `.opencode/modes/` para modos específicos del proyecto o `~/.config/opencode/modes/` para modos globales: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casos de uso - -A continuación se muestran algunos casos de uso comunes para diferentes modos. - -- **Modo Build**: trabajo de desarrollo completo con todas las herramientas habilitadas -- **Modo Plan**: Análisis y planificación sin realizar cambios -- **Modo Review**: revisión de código con acceso de solo lectura más herramientas de documentación -- **Modo Debug**: centrado en la investigación con bash y herramientas de lectura habilitadas -- **Modo Docs**: escritura de documentacion con operaciones de archivos pero sin comandos del sistema - -También puede encontrar que diferentes modelos son buenos para diferentes casos de uso. diff --git a/packages/web/src/content/docs/fr/modes.mdx b/packages/web/src/content/docs/fr/modes.mdx deleted file mode 100644 index 6985dbd57..000000000 --- a/packages/web/src/content/docs/fr/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: Modes -description: Différents modes pour différents cas d'utilisation. ---- - -:::caution -Les modes sont désormais configurés via l'option `agent` dans la configuration opencode. -L’option `mode` est désormais obsolète. [En savoir plus](/docs/agents). -::: - -Les modes dans opencode vous permettent de personnaliser le comportement, les outils et les prompts pour différents cas d'utilisation. - -Il est livré avec deux modes intégrés : **build** et **plan**. Vous pouvez personnaliser ceux-ci ou configurez les vôtres via la configuration opencode. - -Vous pouvez basculer entre les modes au cours d'une session ou les configurer dans votre fichier de configuration. - ---- - -## Modes intégrés - -opencode est livré avec deux modes intégrés. - ---- - -### Build - -Build est le mode **par défaut** avec tous les outils activés. Il s'agit du mode standard pour le travail de développement dans lequel vous avez besoin d'un accès complet aux opérations sur les fichiers et aux commandes système. - ---- - -### Plan - -Un mode restreint conçu pour la planification et l’analyse. En mode plan, les outils suivants sont désactivés par défaut : - -- `write` - Impossible de créer de nouveaux fichiers -- `edit` - Impossible de modifier les fichiers existants, à l'exception des fichiers situés à `.opencode/plans/*.md` pour détailler le plan lui-même -- `patch` - Impossible d'appliquer les correctifs -- `bash` - Impossible d'exécuter les commandes shell - -Ce mode est utile lorsque vous souhaitez que l'IA analyse le code, suggère des modifications ou crée des plans sans apporter de modifications réelles à votre base de code. - ---- - -## Changement de mode - -Vous pouvez basculer entre les modes au cours d'une session à l'aide de la touche _Tab_. Ou votre raccourci clavier `switch_mode` configuré. - -Voir également : [Formatters](/docs/formatters) pour plus d'informations sur la configuration du formatage du code. - ---- - -## Configuration - -Vous pouvez personnaliser les modes intégrés ou créer les vôtres via la configuration. Les modes peuvent être configurés de deux manières : - -### Configuration JSON - -Configurez les modes dans votre fichier de configuration `opencode.json` : - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuration Markdown - -Vous pouvez également définir des modes à l'aide de fichiers markdown. Placez-les dans : - -- Global : `~/.config/opencode/modes/` -- Projet : `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Le nom du fichier markdown devient le nom du mode (par exemple, `review.md` crée un mode `review`). - -Examinons ces options de configuration en détail. - ---- - -### Modèle - -Utilisez la configuration `model` pour remplacer le modèle par défaut pour ce mode. Utile pour utiliser différents modèles optimisés pour différentes tâches. Par exemple, un modèle de planification plus rapide, un modèle de mise en œuvre plus performant. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Température - -Contrôlez le caractère aléatoire et la créativité des réponses de l'IA avec la configuration `temperature`. Des valeurs faibles rendent les réponses plus ciblées et déterministes, tandis que des valeurs plus élevées augmentent la créativité et la variabilité. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Les valeurs de température varient généralement de 0,0 à 1,0 : - -- **0,0-0,2** : réponses très ciblées et déterministes, idéales pour l'analyse et la planification du code -- **0,3-0,5** : réponses équilibrées avec une certaine créativité, idéales pour les tâches de développement générales -- **0,6-1,0** : réponses plus créatives et variées, utiles pour le brainstorming et l'exploration - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Si aucune température n'est spécifiée, opencode utilise les valeurs par défaut spécifiques au modèle (généralement 0 pour la plupart des modèles, 0,55 pour les modèles Qwen). - ---- - -### Invite - -Spécifiez un fichier de prompt système personnalisé pour ce mode avec la configuration `prompt`. Le fichier de prompt doit contenir des instructions spécifiques à l'objectif du mode. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ce chemin est relatif à l'emplacement du fichier de configuration. Donc ça marche pour à la fois la configuration globale opencode et la configuration spécifique au projet. - ---- - -### Outils - -Contrôlez quels outils sont disponibles dans ce mode avec la configuration `tools`. Vous pouvez activer ou désactiver des outils spécifiques en les définissant sur `true` ou `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Si aucun outil n'est spécifié, tous les outils sont activés par défaut. - ---- - -#### Outils disponibles - -Voici tous les outils pouvant être contrôlés via le mode config. - -| Outil | Description | -| ----------- | ------------------------------------- | -| `bash` | Exécuter des commandes shell | -| `edit` | Modifier des fichiers existants | -| `write` | Créer de nouveaux fichiers | -| `read` | Lire le contenu du fichier | -| `grep` | Rechercher le contenu du fichier | -| `glob` | Rechercher des fichiers par modèle | -| `patch` | Appliquer des correctifs aux fichiers | -| `todowrite` | Gérer les listes de tâches | -| `webfetch` | Récupérer du contenu Web | - ---- - -## Modes personnalisés - -Vous pouvez créer vos propres modes personnalisés en les ajoutant à la configuration. Voici des exemples utilisant les deux approches : - -### Utilisation de la configuration JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Utiliser des fichiers Markdown - -Créez des fichiers de mode dans `.opencode/modes/` pour les modes spécifiques au projet ou `~/.config/opencode/modes/` pour les modes globaux : - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Cas d'utilisation - -Voici quelques cas d’utilisation courants pour différents modes. - -- **Mode Build** : travail de développement complet avec tous les outils activés -- **Mode Plan** : Analyse et planification sans apporter de modifications -- **Mode Review** : révision du code avec accès en lecture seule et outils de documentation -- **Mode Debug** : axé sur l'investigation avec les outils bash et read activés -- **Mode Docs** : écriture de documentation avec des opérations sur les fichiers mais pas de commandes système - -Vous constaterez peut-être également que différents modèles conviennent à différents cas d’utilisation. diff --git a/packages/web/src/content/docs/it/modes.mdx b/packages/web/src/content/docs/it/modes.mdx deleted file mode 100644 index 052808f7a..000000000 --- a/packages/web/src/content/docs/it/modes.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Modalità -description: Modalita diverse per casi d'uso diversi. ---- - -:::caution -Le modalita ora si configurano tramite l'opzione `agent` nella configurazione di opencode. L'opzione -`mode` e ora deprecata. [Scopri di piu](/docs/agents). -::: - -Le modalita in opencode ti permettono di personalizzare comportamento, strumenti e prompt per casi d'uso diversi. - -Include due modalita integrate: **build** e **plan**. Puoi personalizzarle oppure configurarne di tue tramite la configurazione di opencode. - -Puoi passare da una modalita all'altra durante una sessione oppure configurarle nel file di configurazione. - ---- - -## Integrate - -opencode include due modalita integrate. - ---- - -### Build - -Build e la modalita **predefinita** con tutti gli strumenti abilitati. E la modalita standard per il lavoro di sviluppo quando ti serve accesso completo alle operazioni sui file e ai comandi di sistema. - ---- - -### Plan - -Una modalita limitata pensata per pianificazione e analisi. In modalita plan, i seguenti strumenti sono disabilitati per impostazione predefinita: - -- `write` - Non puo creare nuovi file -- `edit` - Non puo modificare file esistenti, tranne i file in `.opencode/plans/*.md` per dettagliare il piano -- `patch` - Non puo applicare patch -- `bash` - Non puo eseguire comandi shell - -Questa modalita e utile quando vuoi che l'AI analizzi il codice, suggerisca modifiche o crei piani senza apportare modifiche effettive alla codebase. - ---- - -## Cambiare modalità - -Puoi cambiare modalita durante una sessione usando il tasto _Tab_. In alternativa, puoi usare il keybind `switch_mode` che hai configurato. - -Vedi anche: [Formatter](/docs/formatters) per informazioni sulla configurazione della formattazione del codice. - ---- - -## Configurazione - -Puoi personalizzare le modalita integrate o crearne di tue tramite configurazione. Le modalita si possono configurare in due modi: - -### Configurazione JSON - -Configura le modalita nel file di configurazione `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configurazione Markdown - -Puoi anche definire modalita usando file markdown. Mettili in: - -- Globale: `~/.config/opencode/modes/` -- Progetto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Il nome del file markdown diventa il nome della modalita (ad es. `review.md` crea una modalita `review`). - -Vediamo queste opzioni di configurazione nel dettaglio. - ---- - -### Modello - -Usa la configurazione `model` per sovrascrivere il modello predefinito per questa modalita. E utile per usare modelli diversi ottimizzati per task diversi: per esempio, un modello piu veloce per la pianificazione e uno piu capace per l'implementazione. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controlla casualita e creativita delle risposte dell'AI con la configurazione `temperature`. Valori piu bassi rendono le risposte piu focalizzate e deterministiche, mentre valori piu alti aumentano creativita e variabilita. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -I valori di temperatura di solito vanno da 0.0 a 1.0: - -- **0.0-0.2**: risposte molto focalizzate e deterministiche, ideali per analisi del codice e pianificazione -- **0.3-0.5**: risposte bilanciate con un po' di creativita, buone per task di sviluppo generici -- **0.6-1.0**: risposte piu creative e variabili, utili per brainstorming ed esplorazione - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Se non viene specificata alcuna temperatura, opencode usa i default specifici del modello (tipicamente 0 per la maggior parte dei modelli, 0.55 per i modelli Qwen). - ---- - -### Prompt - -Specifica un file di prompt di sistema personalizzato per questa modalita con la configurazione `prompt`. Il file dovrebbe contenere istruzioni specifiche per lo scopo della modalita. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Questo percorso e relativo a dove si trova il file di configurazione. Quindi funziona -sia per la configurazione globale di opencode sia per quella specifica del progetto. - ---- - -### Strumenti - -Controlla quali strumenti sono disponibili in questa modalita con la configurazione `tools`. Puoi abilitare o disabilitare strumenti specifici impostandoli a `true` o `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Se non specifichi gli strumenti, tutti gli strumenti sono abilitati per impostazione predefinita. - ---- - -#### Strumenti disponibili - -Ecco tutti gli strumenti che possono essere controllati tramite la configurazione della modalita. - -| Strumento | Descrizione | -| ----------- | ---------------------------- | -| `bash` | Esegue comandi shell | -| `edit` | Modifica file esistenti | -| `write` | Crea nuovi file | -| `read` | Legge contenuti dei file | -| `grep` | Cerca nei contenuti dei file | -| `glob` | Trova file per pattern | -| `patch` | Applica patch ai file | -| `todowrite` | Gestisce liste todo | -| `webfetch` | Recupera contenuti web | - ---- - -## Modalità personalizzate - -Puoi creare modalita personalizzate aggiungendole alla configurazione. Ecco esempi con entrambi gli approcci: - -### Usando la configurazione JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando file markdown - -Crea file di modalita in `.opencode/modes/` per modalita specifiche del progetto oppure in `~/.config/opencode/modes/` per modalita globali: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casi d'uso - -Ecco alcuni casi d'uso comuni per le diverse modalita. - -- **Modalita Build**: sviluppo completo con tutti gli strumenti abilitati -- **Modalita Plan**: analisi e pianificazione senza apportare modifiche -- **Modalita Review**: code review con accesso in sola lettura piu strumenti di documentazione -- **Modalita Debug**: focalizzata sull'investigazione con strumenti bash e read abilitati -- **Modalita Docs**: scrittura di documentazione con operazioni sui file ma senza comandi di sistema - -Potresti anche trovare che modelli diversi funzionano meglio per casi d'uso diversi. diff --git a/packages/web/src/content/docs/ja/modes.mdx b/packages/web/src/content/docs/ja/modes.mdx deleted file mode 100644 index 8ebe7f5e6..000000000 --- a/packages/web/src/content/docs/ja/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: モード -description: さまざまなユースケースに応じたさまざまなモード。 ---- - -:::caution -モードは、OpenCode 設定の `agent` オプションを通じて設定されるようになりました。 -`mode` オプションは非推奨になりました。 [詳細はこちら](/docs/agents)。 -::: -モードを使用すると、さまざまなユースケースに合わせて動作、ツール、プロンプトをカスタマイズできます。 - -**Build** と **Plan** という 2 つの組み込みモードが付属しています。カスタマイズできます -これらを使用するか、OpenCode 設定を通じて独自の設定を行います。 - -セッション中にモードを切り替えることも、設定ファイルでモードを構成することもできます。 - ---- - -## 組み込み - -OpenCode には 2 つの組み込みモードが付属しています。 - ---- - -### Build - -Build は、すべてのツールが有効になっている **デフォルト** モードです。これは、ファイル操作やシステムコマンドへのフルアクセスが必要な開発作業の標準モードです。 - ---- - -### Plan - -計画と分析のために設計された制限付きモード。Plan モードでは、次のツールはデフォルトで無効になっています。 - -- `write` - 新しいファイルを作成できません -- `edit` - 計画自体の詳細を示す `.opencode/plans/*.md` にあるファイルを除き、既存のファイルを変更できません -- `patch` - パッチを適用できません -- `bash` - シェルコマンドを実行できません - -このモードは、コードベースに実際の変更を加えずに、AI にコードを分析させたり、変更を提案したり、計画を作成させたい場合に便利です。 - ---- - -## 切り替え - -セッション中に _Tab_ キーを使用してモードを切り替えることができます。または、設定された `switch_mode` キーバインド。 - -参照: [コードのフォーマット設定については、Formatters](/docs/formatters)。 - ---- - -## 設定 - -組み込みモードをカスタマイズしたり、構成を通じて独自のモードを作成したりできます。モードは次の 2 つの方法で設定できます。 - -### JSON 設定 - -`opencode.json` 設定ファイルでモードを構成します。 - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 設定 - -Markdown ファイルを使用してモードを定義することもできます。それらを次の場所に置きます。 - -- グローバル: `~/.config/opencode/modes/` -- プロジェクト: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown ファイル名はモード名になります (例: `review.md` は `review` モードを作成します)。 - -これらの設定オプションを詳しく見てみましょう。 - ---- - -### モデル - -`model` 設定を使用して、このモードのデフォルトモデルをオーバーライドします。さまざまなタスクに最適化されたさまざまなモデルを使用する場合に役立ちます。たとえば、計画にはより高速なモデルを、実装にはより有能なモデルを使用します。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 温度 - -`temperature` 設定を使用して、AI の応答のランダム性と創造性を制御します。値が低いほど、応答はより集中的かつ決定的になりますが、値が高いほど、創造性と変動性が高まります。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -通常、温度値の範囲は 0.0 ~ 1.0 です。 - -- **0.0-0.2**: 非常に焦点が絞られた決定的な応答。コード分析と計画に最適です。 -- **0.3-0.5**: 創造性を備えたバランスの取れた応答。一般的な開発タスクに適しています。 -- **0.6-1.0**: より創造的で多様な応答。ブレーンストーミングや探索に役立ちます。 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -温度が指定されていない場合、OpenCode はモデル固有のデフォルトを使用します (通常、ほとんどのモデルでは 0、Qwen モデルでは 0.55)。 - ---- - -### プロンプト - -`prompt` 設定を使用して、このモードのカスタムシステムプロンプトファイルを指定します。プロンプトファイルには、モードの目的に固有の指示が含まれている必要があります。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -このパスは、設定ファイルが配置されている場所に対する相対パスです。したがって、これはグローバルな OpenCode 設定とプロジェクト固有の設定の両方で機能します。 - ---- - -### ツール - -`tools` 設定を使用して、このモードでどのツールを使用できるかを制御します。特定のツールを `true` または `false` に設定することで、有効または無効にすることができます。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -ツールが指定されていない場合は、すべてのツールがデフォルトで有効になります。 - ---- - -#### 利用可能なツール - -ここでは、モード設定を通じて制御できるすべてのツールを示します。 - -| ツール | 説明 | -| ----------- | -------------------------- | -| `bash` | シェルコマンドを実行する | -| `edit` | 既存のファイルを変更する | -| `write` | 新しいファイルを作成する | -| `read` | ファイルの内容を読み取る | -| `grep` | ファイルの内容を検索 | -| `glob` | パターンでファイルを検索 | -| `patch` | ファイルにパッチを適用する | -| `todowrite` | ToDo リストを管理する | -| `webfetch` | Web コンテンツを取得する | - ---- - -## カスタムモード - -構成に追加することで、独自のカスタムモードを作成できます。両方のアプローチを使用した例を次に示します。 - -### JSON 設定の使用 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Markdown ファイルの使用 - -モードファイルをプロジェクト固有モードの場合は `.opencode/modes/` に、グローバルモードの場合は `~/.config/opencode/modes/` に作成します。 - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### ユースケース - -さまざまなモードの一般的な使用例をいくつか示します。 - -- **ビルドモード**: すべてのツールを有効にした完全な開発作業 -- **計画モード**: 変更を加えずに分析および計画を立てる -- **レビューモード**: 読み取り専用アクセスとドキュメントツールによるコードレビュー -- **デバッグモード**: bash および読み取りツールを有効にして調査に重点を置きます -- **ドキュメントモード**: ファイル操作を使用してドキュメントを作成しますが、システムコマンドは使用しません - -また、さまざまなユースケースにさまざまなモデルが適していることがわかるかもしれません。 diff --git a/packages/web/src/content/docs/ko/modes.mdx b/packages/web/src/content/docs/ko/modes.mdx deleted file mode 100644 index 32746d1d0..000000000 --- a/packages/web/src/content/docs/ko/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: 모드 -description: 다양한 사용 사례를 위한 다양한 모드. ---- - -:::caution -모드는 opencode 설정에서 `agent` 옵션을 통해 구성되어 있습니다. 더 보기 -`mode` 옵션이 이제 비활성화되었습니다. [더 알아보기](/docs/agents). -::: - -opencode의 모드는 다른 사용 사례에 대한 행동, 도구 및 프롬프트를 사용자 정의 할 수 있습니다. - -그것은 두 개의 내장 모드와 함께 제공됩니다 : ** 빌드 ** 및 ** 계획 **. 사용자 정의 할 수 있습니다. -opencode config를 통해 자체를 구성합니다. - -세션 중에 모드를 전환하거나 구성 파일에서 구성할 수 있습니다. - ---- - -## 내장 - -opencode는 2개의 붙박이 형태로 옵니다. - ---- - -### 빌드 - -빌드는 **default** 모드로 모든 도구가 활성화됩니다. 이것은 파일 운영 및 시스템 명령에 대한 전체 액세스가 필요한 개발 작업을위한 표준 모드입니다. - ---- - -## 계획 - -계획 및 분석을 위해 설계된 제한 모드. 계획 모드에서 다음 도구는 기본적으로 비활성화됩니다: - -- `write` - 새로운 파일을 만들 수 없습니다 -- `edit` - `.opencode/plans/*.md`에 위치한 파일을 제외하고 기존 파일을 수정할 수 없습니다. -- `patch` - 패치 적용 -- `bash` - shell 명령을 실행할 수 없습니다 - -이 모드는 코드를 분석하기 위해 AI를 원할 때 유용합니다. 변경 사항을 제안하거나 코드베이스에 실제 수정없이 계획을 만들 수 있습니다. - ---- - -## 전환 - -Tab 키를 사용하여 세션 중에 모드를 전환할 수 있습니다. 또는 당신의 형성된 `switch_mode` keybind. - -참조 : [Formatters](/docs/formatters) 코드 형식 설정에 대한 정보. - ---- - -## 구성 - -내장 모드를 사용자 정의하거나 구성을 통해 자신의 만들 수 있습니다. 형태는 2가지 방법으로 형성될 수 있습니다: - -### JSON 구성 - -`opencode.json` 설정 파일에서 모드 구성: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 구성 - -Markdown 파일을 사용하여 모드를 정의할 수 있습니다. 그들에 게: - -- 글로벌: `~/.config/opencode/modes/` -- 프로젝트: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 파일 이름은 모드 이름 (예 : `review.md`는 `review` 모드를 만듭니다)이됩니다. - -이 구성 옵션을 자세히 살펴봅시다. - ---- - -### 모델 - -`model` config를 사용하여 이 모드의 기본 모델을 무시합니다. 다른 작업에 최적화 된 다른 모델을 사용하는 데 유용합니다. 예를 들어, 계획을위한 빠른 모델, 구현을위한 더 많은 모델. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 온도 - -`temperature` config와 AI의 응답의 임의성과 창의성을 제어합니다. 더 낮은 값은 더 집중하고 세심한 응답을 만듭니다. 더 높은 값은 창의력과 가변성을 증가하면서. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -온도 값은 일반적으로 0.0에서 1.0에 배열합니다: - -- **0.0-0.2**: 매우 집중하고 신중한 응답, 코드 분석 및 계획에 이상 -**0.3-0.5**: 일부 창의력과 균형 잡힌 응답, 일반 개발 작업에 좋은 -- **0.6-1.0**: 더 창조적이고 다양한 응답, 브레인스토밍 및 탐험에 유용한 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -온도가 지정되지 않은 경우, opencode는 모델별 기본 (일반적으로 0 대부분의 모델에 대한, 0.55 Qwen 모델)을 사용합니다. - ---- - -#### 프롬프트 - -`prompt` config를 가진 이 형태를 위한 주문 체계 prompt 파일을 지정하십시오. prompt 파일은 모드의 목적에 특정한 지시를 포함해야 합니다. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -이 경로는 config 파일이 있는 곳에 관계됩니다. 그래서이 작품 -글로벌 opencode config 및 프로젝트 특정 구성 모두. - ---- - -## 도구 - -이 모드에서는 `tools` config를 사용할 수 있습니다. `true` 또는 `false`로 설정하여 특정 도구를 활성화하거나 비활성화 할 수 있습니다. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -도구가 지정되지 않은 경우, 모든 도구는 기본적으로 활성화됩니다. - ---- - -### 유효한 도구 - -여기에 모든 도구는 모드 구성을 통해 제어 할 수 있습니다. - -| 도구 | 설명 | -| ----------- | --------------------- | -| `bash` | shell 명령 실행 | -| `edit` | 기존 파일 수정 | -| `write` | 새 파일 만들기 | -| `read` | 읽는 파일 내용 | -| `grep` | 파일 검색 | -| `glob` | 패턴으로 찾기 | -| `patch` | 파일에 패치 적용 | -| `todowrite` | 할 일(Todo) 목록 관리 | -| `webfetch` | 웹사이트 가져오기 | - ---- - -## 사용자 정의 모드 - -구성에 추가하여 사용자 정의 모드를 만들 수 있습니다. 여기에는 두 가지 접근법이 있습니다. - -### JSON 구성 사용 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### markdown 파일 사용 - -프로젝트 별 모드 또는 `~/.config/opencode/modes/`의 모드 파일을 만들 수 있습니다. - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 사용 사례 - -다음은 다른 모드에 대한 일반적인 사용 사례입니다. - -- **Build 모드**: 모든 도구와 함께 전체 개발 작업 -- **Plan 모드**: 변화없이 분석 및 계획 -**Review 모드**: Code review with read-only access plus 문서 도구 -- **Debug 모드**: bash 및 읽기 도구와 함께 조사에 집중 -- **Docs 모드**: 파일 작업과 문서 작성하지만 시스템 명령 없음 - -다른 모델을 찾을 수 있습니다 다른 사용 케이스에 대 한 좋은. diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx deleted file mode 100644 index b8ea69739..000000000 --- a/packages/web/src/content/docs/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modes -description: Different modes for different use cases. ---- - -:::caution -Modes are now configured through the `agent` option in the opencode config. The -`mode` option is now deprecated. [Learn more](/docs/agents). -::: - -Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases. - -It comes with two built-in modes: **build** and **plan**. You can customize -these or configure your own through the opencode config. - -You can switch between modes during a session or configure them in your config file. - ---- - -## Built-in - -opencode comes with two built-in modes. - ---- - -### Build - -Build is the **default** mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands. - ---- - -### Plan - -A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default: - -- `write` - Cannot create new files -- `edit` - Cannot modify existing files, except for files located at `.opencode/plans/*.md` to detail the plan itself -- `patch` - Cannot apply patches -- `bash` - Cannot execute shell commands - -This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase. - ---- - -## Switching - -You can switch between modes during a session using the _Tab_ key. Or your configured `switch_mode` keybind. - -See also: [Formatters](/docs/formatters) for information about code formatting configuration. - ---- - -## Configure - -You can customize the built-in modes or create your own through configuration. Modes can be configured in two ways: - -### JSON Configuration - -Configure modes in your `opencode.json` config file: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown Configuration - -You can also define modes using markdown files. Place them in: - -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -The markdown file name becomes the mode name (e.g., `review.md` creates a `review` mode). - -Let's look at these configuration options in detail. - ---- - -### Model - -Use the `model` config to override the default model for this mode. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperature - -Control the randomness and creativity of the AI's responses with the `temperature` config. Lower values make responses more focused and deterministic, while higher values increase creativity and variability. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperature values typically range from 0.0 to 1.0: - -- **0.0-0.2**: Very focused and deterministic responses, ideal for code analysis and planning -- **0.3-0.5**: Balanced responses with some creativity, good for general development tasks -- **0.6-1.0**: More creative and varied responses, useful for brainstorming and exploration - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -If no temperature is specified, opencode uses model-specific defaults (typically 0 for most models, 0.55 for Qwen models). - ---- - -### Prompt - -Specify a custom system prompt file for this mode with the `prompt` config. The prompt file should contain instructions specific to the mode's purpose. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -This path is relative to where the config file is located. So this works for -both the global opencode config and the project specific config. - ---- - -### Tools - -Control which tools are available in this mode with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -If no tools are specified, all tools are enabled by default. - ---- - -#### Available tools - -Here are all the tools can be controlled through the mode config. - -| Tool | Description | -| ----------- | ---------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | - ---- - -## Custom modes - -You can create your own custom modes by adding them to the configuration. Here are examples using both approaches: - -### Using JSON configuration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Using markdown files - -Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Use cases - -Here are some common use cases for different modes. - -- **Build mode**: Full development work with all tools enabled -- **Plan mode**: Analysis and planning without making changes -- **Review mode**: Code review with read-only access plus documentation tools -- **Debug mode**: Focused on investigation with bash and read tools enabled -- **Docs mode**: Documentation writing with file operations but no system commands - -You might also find different models are good for different use cases. diff --git a/packages/web/src/content/docs/nb/modes.mdx b/packages/web/src/content/docs/nb/modes.mdx deleted file mode 100644 index e99c511be..000000000 --- a/packages/web/src/content/docs/nb/modes.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Moduser -description: Ulike moduser for forskjellige brukstilfeller. ---- - -:::caution -Moduser konfigureres nå gjennom alternativet `agent` i OpenCode-konfigurasjonen. -Alternativet `mode` er nå utdatert. [Finn ut mer](/docs/agents). -::: - -Moduser i OpenCode lar deg tilpasse oppførselen, verktøyene og prompter for ulike brukstilfeller. - -Den kommer med to innebygde moduser: **bygg** og **plan**. Du kan tilpasse -disse eller konfigurer din egen gjennom OpenCode-konfigurasjonen. - -Du kan bytte mellom moduser under en økt eller konfigurere dem i konfigurasjonsfilen din. - ---- - -## Innebygd - -OpenCode kommer med to innebygde moduser. - ---- - -### Bygg - -Bygg er **standard**-modusen med alle verktøy aktivert. Dette er standardmodusen for utviklingsarbeid der du trenger full tilgang til filoperasjoner og systemkommandoer. - ---- - -### Plan - -En begrenset modus designet for planlegging og analyse. I planmodus er følgende verktøy deaktivert som standard: - -- `write` - Kan ikke opprette nye filer -- `edit` - Kan ikke endre eksisterende filer, bortsett fra filer som ligger på `.opencode/plans/*.md` for å detaljere selve planen -- `patch` - Kan ikke bruke patcher -- `bash` - Kan ikke utføre shell-kommandoer - -Denne modusen er nyttig når du vil at AI skal analysere kode, foreslå endringer eller lage planer uten å gjøre noen faktiske endringer i kodebasen. - ---- - -## Bytte - -Du kan bytte mellom moduser under en økt ved å bruke _Tab_-tasten. Eller din konfigurerte `switch_mode` hurtigtast. - -Se også: [Formatters](/docs/formatters) for informasjon om kodeformateringskonfigurasjon. - ---- - -## Konfigurer - -Du kan tilpasse de innebygde modusene eller opprette din egen gjennom konfigurasjon. Moduser kan konfigureres på to måter: - -### JSON-konfigurasjon - -Konfigurer moduser i din `opencode.json` konfigurasjonsfil: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-konfigurasjon - -Du kan også definere moduser ved hjelp av markdown-filer. Plasser dem i: - -- Globalt: `~/.config/opencode/modes/` -- Prosjekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Filnavnet til markdown-filen blir modusnavnet (f.eks. `review.md` oppretter en `review`-modus). - -La oss se på disse konfigurasjonsalternativene i detalj. - ---- - -### Modell - -Bruk `model`-konfigurasjonen for å overstyre standardmodellen for denne modusen. Nyttig for å bruke forskjellige modeller optimalisert for forskjellige oppgaver. For eksempel en raskere modell for planlegging, en mer kapabel modell for implementering. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Kontroller tilfeldigheten og kreativiteten til AI-ens svar med `temperature`-konfigurasjonen. Lavere verdier gjør svarene mer fokuserte og deterministiske, mens høyere verdier øker kreativiteten og variasjonen. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturverdier varierer vanligvis fra 0,0 til 1,0: - -- **0.0-0.2**: Veldig fokuserte og deterministiske svar, ideelt for kodeanalyse og planlegging -- **0.3-0.5**: Balanserte svar med litt kreativitet, bra for generelle utviklingsoppgaver -- **0.6-1.0**: Mer kreative og varierte svar, nyttig for idédugnad og utforskning - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Hvis ingen temperatur er spesifisert, bruker OpenCode modellspesifikke standardinnstillinger (vanligvis 0 for de fleste modeller, 0,55 for Qwen-modeller). - ---- - -### Prompt - -Angi en tilpasset systemprompt-fil for denne modusen med `prompt`-konfigurasjonen. Prompt-filen skal inneholde instruksjoner som er spesifikke for modusens formål. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Denne banen er relativ til der konfigurasjonsfilen er plassert. Så dette fungerer for både den globale OpenCode-konfigurasjonen og den prosjektspesifikke konfigurasjonen. - ---- - -### Verktøy - -Kontroller hvilke verktøy som er tilgjengelige i denne modusen med `tools`-konfigurasjonen. Du kan aktivere eller deaktivere spesifikke verktøy ved å sette dem til `true` eller `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hvis ingen verktøy er spesifisert, er alle verktøy aktivert som standard. - ---- - -#### Tilgjengelige verktøy - -Her er alle verktøyene som kan kontrolleres gjennom moduskonfigurasjonen. - -| Verktøy | Beskrivelse | -| ----------- | --------------------------- | -| `bash` | Utfør shell-kommandoer | -| `edit` | Endre eksisterende filer | -| `write` | Opprett nye filer | -| `read` | Les filinnhold | -| `grep` | Søk i filinnhold | -| `glob` | Finn filer etter mønster | -| `patch` | Bruk patcher på filer | -| `todowrite` | Administrer gjøremålslister | -| `webfetch` | Hent webinnhold | - ---- - -## Egendefinerte moduser - -Du kan opprette dine egne tilpassede moduser ved å legge dem til i konfigurasjonen. Her er eksempler på bruk av begge metodene: - -### Bruke JSON-konfigurasjon - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Bruke markdown-filer - -Opprett modusfiler i `.opencode/modes/` for prosjektspesifikke moduser eller `~/.config/opencode/modes/` for globale moduser: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Bruksområder - -Her er noen vanlige bruksområder for forskjellige moduser. - -- **Bygg-modus**: Fullt utviklingsarbeid med alle verktøy aktivert -- **Plan-modus**: Analyse og planlegging uten å gjøre endringer -- **Review-modus**: Kodegjennomgang med skrivebeskyttet tilgang pluss dokumentasjonsverktøy -- **Debug-modus**: Fokusert på etterforskning med bash- og leseverktøy aktivert -- **Docs-modus**: Dokumentasjonsskriving med filoperasjoner, men ingen systemkommandoer - -Du kan også finne at forskjellige modeller er gode for forskjellige bruksområder. diff --git a/packages/web/src/content/docs/pl/modes.mdx b/packages/web/src/content/docs/pl/modes.mdx deleted file mode 100644 index 8d7c2568d..000000000 --- a/packages/web/src/content/docs/pl/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Tryby -description: Różne tryby dla różnych zastosowań. ---- - -:::caution -Tryby są teraz konfigurowane za pomocą opcji `agent` w konfiguracji opencode. -Opcja `mode` jest obecnie przestarzała. [Dowiedz się więcej](/docs/agents). -::: - -Tryb udostępniania możliwości stosowania, narzędzie i podpowiedzi do różnych zastosowań. - -Posiadanie dwa tryby: **Build** i **Plan**. Można dostosować -te lub skonfiguruj własne za pomocą konfiguracji opencode. - -Można przełączać się między trybami podczas sesji lub konfigurować je w pliku konfiguracyjnym. - ---- - -## Wbudowane - -opencode ma dwa puste tryby. - ---- - -### Build - -Build jest trybem **domyślnym** z dostępnymi narzędziami. Jest to standardowy tryb pracy programistycznej, który jest dostępny z pełnym dostępem do operacji na plikach i oryginalnych systemach systemowych. - ---- - -### Plan - -Tryb ograniczony do analizy. W urządzeniu planowym narzędzia są przydatne: - -- `write` - Nie można stworzyć nowych plików -- `edit` — Nie można zastosować naruszenia plików, z naruszeniem praw autorskich w `.opencode/plans/*.md` w celu uszczegółowienia samego planu -- `patch` - Nie można zastosować poprawek -- `bash` - Nie można wykonać poleceń shell - -Ten tryb jest alternatywny, gdy chcesz, aby sztuczna inteligencja analizowała kod, sugerowała zmianę lub tworzyła projekty bez źródła zewnętrznego, które stanowi bazę kodu. - ---- - -## Przełączanie - -Możesz przełączać się między trybami podczas sesji za pomocą klawisza _Tab_. Lub skrót klawiszowy `switch_mode`. - -Zobacz także: [Formaterzy](/docs/formatters), aby uzyskać informacje na temat konfiguracji formatowania kodu. - ---- - -## Skonfiguruj - -Możliwość dostosowania alternatywnego trybu lub konfiguracji poprzez własną konfigurację. Tryb można skonfigurować na dwa systemy: - -### Konfiguracja JSON - -Skonfiguruj tryb w pliku konfiguracyjnym `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Konfiguracja Markdown - -Można także definiować tryby za pomocą plików przecen. Trzymaj je w: - -- Globalnie: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Nazwa pliku przecen staje się kluczem trybu (np. `review.md` tworzy tryb `review`). - -Przyjrzyjmy się szczegółowo tym opcjom konfiguracji. - ---- - -### Model - -Zastosowanie konstrukcji `model`, aby zastosować domyślny model dla tego trybu. Przydatne przy wykorzystaniu różnych modeli dostępnych w różnych zadaniach. Na przykład zastosowanie modelu, skuteczniejsze wykonanie modelu. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Kontroluj losowość i kreatywność reakcji AI za pomocą konstrukcji `temperature`. Niższe wartości, że odpowiedzi są bardziej skupione i deterministyczne, podczas gdy najwyższa wartość jest innowacyjna i złożona. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Wartości temperatury zazwyczaj wahają się od 0,0 do 1,0: - -- **0,0-0,2**: Bardzo skoncentrowane i deterministyczne odpowiedzi, idealne do analizy i kodu źródłowego -- **0,3-0,5**: Zrównoważona odpowiedź z chwilą powstania, dobre do ogólnych zadań rozwojowych -- **0,6–1,0**: Bardziej kreatywne i odpowiedzi, rozstrzygnięte podczas burzy mózgów i eksploracji - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Jeśli nie ma wpływu na temperaturę, opencode stosuje się narzędzia badawcze dla modelu (zwykle 0 dla największych modeli, 0,55 dla modeli Qwen). - ---- - -### Prompt (Monit) - -niestandardowy plik podpowiedzi systemowych dla tej procedury za pomocą konfiguracji `prompt`. Plik informacyjny powinien zawierać instrukcje dotyczące przeznaczenia trybu. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ścieżka ta zależy od miejsca, w którym znajduje się plik konfiguracyjny. Więc to dla działa -zarówno globalna opencode, jak i specjalna dla projektu. - ---- - -### Narzędzia - -Kontroluj, które narzędzia są dostępne w tym urządzeniu, za pomocą konfiguracji `tools`. Można włączyć lub dostępne narzędzie, ustawiając je na `true` lub `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Jeśli nie ma żadnych narzędzi, wszystkie narzędzia są wyłączone. - ---- - -#### Dostępne narzędzia - -Oto wszystkie narzędzia, które można sterować za pomocą konfiguracji trybów. - -| Narzędzie | Opis | -| ----------- | ------------------------------------- | -| `bash` | Wykonaj polecenia shell | -| `edit` | Modyfikuj istniejące pliki | -| `write` | Utwórz nowe pliki | -| `read` | Przeczytaj zawartość pliku | -| `grep` | Wyszukaj zawartość pliku | -| `glob` | Znajdź pliki według wzorca | -| `patch` | Zastosuj poprawki do plików | -| `todowrite` | Zarządzaj listami rzeczy do wykonania | -| `webfetch` | Pobierz zawartość internetową | - ---- - -## Tryby niestandardowe - -Możesz stworzyć własny tryb prywatny, dodając je do konfiguracji. Oto zastosowanie obu rozwiązań: - -### Korzystanie z konfiguracji JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Korzystanie z plików Markdown - -Utwórz pliki trybów w `.opencode/modes/` dla trybów zapisanych dla projektu lub `~/.config/opencode/modes/` dla trybów globalnych: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Przypadki użycia - -Oto kilka typowych zastosowań dla różnych trybów. - -- **Build mode**: Pełne prace programistyczne z dostępnymi narzędziami -- **Plan mode**: Analiza i planowanie bez zmian -- **Review mode**: Przegląd kodu z możliwością odczytu i narzędzi do dokumentacji -- **Debug mode**: Koncentruje się na urządzeniu z dostępnymi narzędziami bash i odczytu -- **Docs mode**: Zapisywanie dokumentacji przy użyciu operacji na plikach, ale bez oryginalnych systemów systemowych - -Może się również zdarzyć, że różne modele są dobre w różnych wersjach użycia. diff --git a/packages/web/src/content/docs/pt-br/modes.mdx b/packages/web/src/content/docs/pt-br/modes.mdx deleted file mode 100644 index b53fb4fcb..000000000 --- a/packages/web/src/content/docs/pt-br/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: Modos -description: Modos diferentes para diferentes casos de uso. ---- - -:::caution -Os modos agora são configurados através da opção `agent` na configuração do opencode. A opção `mode` agora está obsoleta. [Saiba mais](/docs/agents). -::: - -Os modos no opencode permitem que você personalize o comportamento, as ferramentas e os prompts para diferentes casos de uso. - -Ele vem com dois modos integrados: **build** e **plan**. Você pode personalizar esses ou configurar os seus próprios através da configuração do opencode. - -Você pode alternar entre os modos durante uma sessão ou configurá-los no seu arquivo de configuração. - ---- - -## Integrados - -O opencode vem com dois modos integrados. - ---- - -### build - -Build é o modo **padrão** com todas as ferramentas habilitadas. Este é o modo padrão para trabalho de desenvolvimento onde você precisa de acesso total a operações de arquivos e comandos do sistema. - ---- - -### plan - -Um modo restrito projetado para planejamento e análise. No modo plan, as seguintes ferramentas estão desativadas por padrão: - -- `write` - Não pode criar novos arquivos -- `edit` - Não pode modificar arquivos existentes, exceto para arquivos localizados em `.opencode/plans/*.md` para detalhar o plano em si -- `patch` - Não pode aplicar patches -- `bash` - Não pode executar comandos de shell - -Este modo é útil quando você deseja que a IA analise o código, sugira alterações ou crie planos sem fazer modificações reais em sua base de código. - ---- - -## Alternando - -Você pode alternar entre modos durante uma sessão usando a tecla _Tab_. Ou sua tecla de atalho configurada `switch_mode`. - -Veja também: [Formatadores](/docs/formatters) para informações sobre configuração de formatação de código. - ---- - -## Configuração - -Você pode personalizar os modos integrados ou criar os seus próprios através da configuração. Os modos podem ser configurados de duas maneiras: - -### Configuração JSON - -Configure os modos no seu arquivo de configuração `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuração Markdown - -Você também pode definir modos usando arquivos markdown. Coloque-os em: - -- Global: `~/.config/opencode/modes/` -- Projeto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -O nome do arquivo markdown se torna o nome do modo (por exemplo, `review.md` cria um modo `review`). - -Vamos analisar essas opções de configuração em detalhes. - ---- - -### Modelo - -Use a configuração `model` para substituir o modelo padrão para este modo. Útil para usar diferentes modelos otimizados para diferentes tarefas. Por exemplo, um modelo mais rápido para planejamento, um modelo mais capaz para implementação. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controle a aleatoriedade e a criatividade das respostas da IA com a configuração `temperature`. Valores mais baixos tornam as respostas mais focadas e determinísticas, enquanto valores mais altos aumentam a criatividade e a variabilidade. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Os valores de temperatura geralmente variam de 0.0 a 1.0: - -- **0.0-0.2**: Respostas muito focadas e determinísticas, ideais para análise de código e planejamento -- **0.3-0.5**: Respostas equilibradas com alguma criatividade, boas para tarefas de desenvolvimento geral -- **0.6-1.0**: Respostas mais criativas e variadas, úteis para brainstorming e exploração - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Se nenhuma temperatura for especificada, o opencode usa padrões específicos do modelo (geralmente 0 para a maioria dos modelos, 0.55 para modelos Qwen). - ---- - -### Prompt - -Especifique um arquivo de prompt do sistema personalizado para este modo com a configuração `prompt`. O arquivo de prompt deve conter instruções específicas para o propósito do modo. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Este caminho é relativo a onde o arquivo de configuração está localizado. Portanto, isso funciona tanto para a configuração global do opencode quanto para a configuração específica do projeto. - ---- - -### Ferramentas - -Controle quais ferramentas estão disponíveis neste modo com a configuração `tools`. Você pode habilitar ou desabilitar ferramentas específicas definindo-as como `true` ou `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Se nenhuma ferramenta for especificada, todas as ferramentas estão habilitadas por padrão. - ---- - -#### Ferramentas disponíveis - -Aqui estão todas as ferramentas que podem ser controladas através da configuração do modo. - -| Ferramenta | Descrição | -| ----------- | ------------------------------- | -| `bash` | Executar comandos de shell | -| `edit` | Modificar arquivos existentes | -| `write` | Criar novos arquivos | -| `read` | Ler conteúdos de arquivos | -| `grep` | Pesquisar conteúdos de arquivos | -| `glob` | Encontrar arquivos por padrão | -| `patch` | Aplicar patches a arquivos | -| `todowrite` | Gerenciar listas de tarefas | -| `webfetch` | Buscar conteúdo da web | - ---- - -## Modos personalizados - -Você pode criar seus próprios modos personalizados adicionando-os à configuração. Aqui estão exemplos usando ambas as abordagens: - -### Usando configuração JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando arquivos markdown - -Crie arquivos de modo em `.opencode/modes/` para modos específicos do projeto ou `~/.config/opencode/modes/` para modos globais: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casos de uso - -Aqui estão alguns casos de uso comuns para diferentes modos. - -- **Modo Build**: Trabalho de desenvolvimento completo com todas as ferramentas habilitadas -- **Modo Plan**: Análise e planejamento sem fazer alterações -- **Modo Review**: Revisão de código com acesso somente leitura e ferramentas de documentação -- **Modo Debug**: Focado em investigação com ferramentas bash e de leitura habilitadas -- **Modo Docs**: Redação de documentação com operações de arquivo, mas sem comandos do sistema - -Você também pode descobrir que diferentes modelos são bons para diferentes casos de uso. diff --git a/packages/web/src/content/docs/ru/modes.mdx b/packages/web/src/content/docs/ru/modes.mdx deleted file mode 100644 index e63c91ace..000000000 --- a/packages/web/src/content/docs/ru/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Режимы -description: Различные режимы для разных случаев использования. ---- - -:::caution -Режимы теперь настраиваются с помощью опции `agent` в конфигурации opencode. -Опция `mode` устарела. [Подробнее ](/docs/agents). -::: - -Режимы в opencode позволяют настраивать поведение, инструменты и подсказки для различных вариантов использования. - -Он имеет два встроенных режима: **сборка** и **планирование**. Вы можете настроить -эти или настройте свои собственные через конфигурацию opencode. - -Вы можете переключаться между режимами во время сеанса или настраивать их в своем файле конфигурации. - ---- - -## Встроенный - -opencode имеет два встроенных режима. - ---- - -### Build - -Build — это режим **по умолчанию** со всеми включенными инструментами. Это стандартный режим разработки, в котором вам необходим полный доступ к файловым операциям и системным командам. - ---- - -### Plan - -Ограниченный режим, предназначенный для планирования и анализа. В режиме Plan по умолчанию отключены следующие инструменты: - -- `write` – невозможно создавать новые файлы. -- `edit` – невозможно изменить существующие файлы, за исключением файлов, расположенных по адресу `.opencode/plans/*.md`, для детализации самого плана. -- `patch` – невозможно применить исправления. -- `bash` — невозможно выполнить shell-команды. - -Этот режим полезен, если вы хотите, чтобы ИИ анализировал код, предлагал изменения или создавал планы без внесения каких-либо фактических изменений в вашу кодовую базу. - ---- - -## Переключение - -Переключаться между режимами можно во время сеанса с помощью клавиши _Tab_. Или настроенную вами привязку клавиш `switch_mode`. - -См. также: [Formatters](/docs/formatters) для получения информации о конфигурации форматирования кода. - ---- - -## Настроить - -Вы можете настроить встроенные режимы или создать свои собственные посредством настройки. Режимы можно настроить двумя способами: - -### Конфигурация JSON - -Настройте режимы в файле конфигурации `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Конфигурация Markdown - -Вы также можете определить режимы, используя файлы Markdown. Поместите их в: - -- Глобальный: `~/.config/opencode/modes/` -- Проект: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Имя Markdown файла становится именем режима (например, `review.md` создает режим `review`). - -Давайте рассмотрим эти параметры конфигурации подробно. - ---- - -### Модель - -Используйте конфигурацию `model`, чтобы переопределить модель по умолчанию для этого режима. Полезно для использования разных моделей, оптимизированных под разные задачи. Например, более быстрая модель планирования и более эффективная модель реализации. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Температура - -Управляйте случайностью и креативностью ответов ИИ с помощью конфигурации `temperature`. Более низкие значения делают ответы более целенаправленными и детерминированными, а более высокие значения повышают креативность и вариативность. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Значения температуры обычно находятся в диапазоне от 0,0 до 1,0: - -- **0,0–0,2**: очень целенаправленные и детерминированные ответы, идеальные для анализа кода и планирования. -- **0,3–0,5**: сбалансированные ответы с некоторой креативностью, подходят для общих задач развития. -- **0,6–1,0**: более творческие и разнообразные ответы, полезные для мозгового штурма и исследования. - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Если температура не указана, opencode использует значения по умолчанию для конкретной модели (обычно 0 для большинства моделей, 0,55 для моделей Qwen). - ---- - -### Промпт - -Укажите собственный файл системных подсказок для этого режима с помощью конфигурации `prompt`. Файл подсказки должен содержать инструкции, специфичные для целей режима. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Этот путь указан относительно того, где находится файл конфигурации. Так что это работает для -как глобальная конфигурация opencode, так и конфигурация конкретного проекта. - ---- - -### Инструменты - -Контролируйте, какие инструменты доступны в этом режиме, с помощью конфигурации `tools`. Вы можете включить или отключить определенные инструменты, установив для них значение `true` или `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Если инструменты не указаны, все инструменты включены по умолчанию. - ---- - -#### Доступные инструменты - -Вот всеми инструментами можно управлять через конфигурацию режима. - -| Инструмент | Описание | -| ----------- | ---------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | - ---- - -## Пользовательские режимы - -Вы можете создавать свои собственные режимы, добавляя их в конфигурацию. Вот примеры использования обоих подходов: - -### Использование конфигурации JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Использование файлов Markdown - -Создайте файлы режимов в `.opencode/modes/` для режимов, специфичных для проекта, или в `~/.config/opencode/modes/` для глобальных режимов: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Варианты использования - -Вот несколько распространенных случаев использования различных режимов. - -- **Режим сборки**: полная работа по разработке со всеми включенными инструментами. -- **Режим планирования**: анализ и планирование без внесения изменений. -- **Режим проверки**: проверка кода с доступом только для чтения и инструментами документирования. -- **Режим отладки**: сосредоточен на исследовании с включенными инструментами bash и чтения. -- **Режим документации**: запись документации с использованием файловых операций, но без системных команд. - -Вы также можете обнаружить, что разные модели подходят для разных случаев использования. diff --git a/packages/web/src/content/docs/th/modes.mdx b/packages/web/src/content/docs/th/modes.mdx deleted file mode 100644 index 6cca30998..000000000 --- a/packages/web/src/content/docs/th/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: โหมด -description: โหมดที่แตกต่างกันสำหรับกรณีการใช้งานที่แตกต่างกัน ---- - -:::caution -ขณะนี้โหมดได้รับการกำหนดค่าผ่านตัวเลือก `agent` ในการกำหนดค่า opencode ที่ -ตัวเลือก `mode` เลิกใช้แล้ว [เรียนรู้เพิ่มเติม](/docs/agents) -::: - -โหมดใน opencode ช่วยให้คุณปรับแต่งพฤติกรรม เครื่องมือ และพร้อมท์สำหรับกรณีการใช้งานที่แตกต่างกันได้ - -มาพร้อมกับโหมดในตัวสองโหมด: **สร้าง** และ **วางแผน** คุณสามารถปรับแต่งได้ -สิ่งเหล่านี้หรือกำหนดค่าของคุณเองผ่านการกำหนดค่า opencode - -คุณสามารถสลับระหว่างโหมดระหว่างเซสชันหรือกำหนดค่าในไฟล์กำหนดค่าของคุณ - ---- - -## บิวท์อิน - -opencode มาพร้อมกับโหมดในตัวสองโหมด - ---- - -### Build - -Build เป็นโหมด **ค่าเริ่มต้น** โดยที่เครื่องมือทั้งหมดเปิดใช้งานอยู่ นี่คือโหมดมาตรฐานสำหรับงานพัฒนาที่คุณต้องการสิทธิ์เข้าถึงการทำงานของไฟล์และคำสั่งระบบโดยสมบูรณ์ - ---- - -### Plan - -โหมดจำกัดที่ออกแบบมาเพื่อการวางแผนและการวิเคราะห์ ในโหมดแผน เครื่องมือต่อไปนี้จะถูกปิดใช้งานตามค่าเริ่มต้น: - -- `write` - ​​ไม่สามารถสร้างไฟล์ใหม่ได้ -- `edit` - ​​ไม่สามารถแก้ไขไฟล์ที่มีอยู่ได้ ยกเว้นไฟล์ที่อยู่ใน `.opencode/plans/*.md` เพื่อดูรายละเอียดแผนงาน -- `patch` - ​​ไม่สามารถใช้แพตช์ได้ -- `bash` - ​​ไม่สามารถรันคำสั่ง shell ได้ - -โหมดนี้มีประโยชน์เมื่อคุณต้องการให้ AI วิเคราะห์โค้ด แนะนำการเปลี่ยนแปลง หรือสร้างแผนโดยไม่ต้องทำการแก้ไขโค้ดเบสของคุณจริง ๆ - ---- - -## การสลับ - -คุณสามารถสลับระหว่างโหมดระหว่างเซสชันได้โดยใช้ปุ่ม _Tab_ หรือการเชื่อมโยงคีย์ `switch_mode` ที่คุณกำหนดค่าไว้ - -ดูเพิ่มเติมที่: [Formatters](/docs/formatters) สำหรับข้อมูลเกี่ยวกับการกำหนดค่าการจัดรูปแบบโค้ด - ---- - -## กำหนดค่า - -คุณสามารถปรับแต่งโหมดในตัวหรือสร้างโหมดของคุณเองผ่านการกำหนดค่าได้ โหมดสามารถกำหนดค่าได้สองวิธี: - -### การกำหนดค่าด้วย JSON - -กำหนดค่าโหมดในไฟล์กำหนดค่า `opencode.json` ของคุณ: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### การกำหนดค่า Markdown - -คุณยังสามารถกำหนดโหมดโดยใช้ไฟล์ Markdown ได้ วางไว้ใน: - -- ทั่วโลก: `~/.config/opencode/modes/` -- โครงการ: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -ชื่อไฟล์ Markdown จะกลายเป็นชื่อโหมด (เช่น `review.md` สร้างโหมด `review`) - -มาดูรายละเอียดตัวเลือกการกำหนดค่าเหล่านี้กัน - ---- - -### Model (โมเดล) - -ใช้การกำหนดค่า `model` เพื่อแทนที่โมเดลเริ่มต้นสำหรับโหมดนี้ มีประโยชน์สำหรับการใช้โมเดลที่แตกต่างกันซึ่งปรับให้เหมาะกับงานที่แตกต่างกัน ตัวอย่างเช่น โมเดลสำหรับการวางแผนที่เร็วขึ้น โมเดลที่มีความสามารถมากขึ้นสำหรับการนำไปปฏิบัติ - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperature (อุณหภูมิ) - -ควบคุมการสุ่มและความคิดสร้างสรรค์ของการตอบสนองของ AI ด้วยการกำหนดค่า `temperature` ค่าที่ต่ำกว่าจะทำให้คำตอบมีจุดมุ่งหมายและกำหนดได้มากขึ้น ในขณะที่ค่าที่สูงกว่าจะเพิ่มความคิดสร้างสรรค์และความแปรปรวน - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -โดยทั่วไปค่าอุณหภูมิจะอยู่ในช่วงตั้งแต่ 0.0 ถึง 1.0: - -- **0.0-0.2**: การตอบสนองที่มุ่งเน้นและกำหนดไว้อย่างมาก เหมาะสำหรับการวิเคราะห์และวางแผนโค้ด -- **0.3-0.5**: การตอบสนองที่สมดุลและความคิดสร้างสรรค์บางส่วน เหมาะสำหรับงานพัฒนาทั่วไป -- **0.6-1.0**: คำตอบที่สร้างสรรค์และหลากหลายยิ่งขึ้น ซึ่งมีประโยชน์สำหรับการระดมความคิดและการสำรวจ - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -หากไม่มีการระบุอุณหภูมิ opencode จะใช้ค่าเริ่มต้นเฉพาะรุ่น (โดยทั่วไปจะเป็น 0 สำหรับรุ่นส่วนใหญ่ และ 0.55 สำหรับรุ่น Qwen) - ---- - -### พรอมต์ - -ระบุไฟล์พรอมต์ระบบที่กำหนดเองสำหรับโหมดนี้ด้วยการกำหนดค่า `prompt` ไฟล์พร้อมท์ควรมีคำแนะนำเฉพาะสำหรับวัตถุประสงค์ของโหมด - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -เส้นทางนี้สัมพันธ์กับตำแหน่งของไฟล์กำหนดค่า ดังนั้นสิ่งนี้จึงใช้ได้กับ -ทั้งการกำหนดค่า opencode ส่วนกลางและการกำหนดค่าเฉพาะโครงการ - ---- - -### เครื่องมือ - -ควบคุมว่าเครื่องมือใดบ้างที่พร้อมใช้งานในโหมดนี้ด้วยการกำหนดค่า `tools` คุณสามารถเปิดหรือปิดใช้งานเครื่องมือเฉพาะได้โดยตั้งค่าเป็น `true` หรือ `false` - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -หากไม่มีการระบุเครื่องมือ เครื่องมือทั้งหมดจะถูกเปิดใช้งานตามค่าเริ่มต้น - ---- - -#### เครื่องมือที่มีอยู่ - -นี่คือเครื่องมือทั้งหมดที่สามารถควบคุมได้ผ่านการกำหนดค่าโหมด - -| เครื่องมือ | คำอธิบาย | -| ----------- | ------------------------- | -| `bash` | ดำเนินการคำสั่ง shell | -| `edit` | แก้ไขไฟล์ที่มีอยู่ | -| `write` | สร้างไฟล์ใหม่ | -| `read` | อ่านเนื้อหาไฟล์ | -| `grep` | ค้นหาเนื้อหาไฟล์ | -| `glob` | ค้นหาไฟล์ตามรูปแบบ | -| `patch` | ใช้แพทช์กับไฟล์ | -| `todowrite` | จัดการรายการสิ่งที่ต้องทำ | -| `webfetch` | ดึงเนื้อหาเว็บ | - ---- - -## โหมดกำหนดเอง - -คุณสามารถสร้างโหมดที่คุณกำหนดเองได้โดยเพิ่มเข้าไปในการกำหนดค่า นี่คือตัวอย่างที่ใช้ทั้งสองวิธี: - -### ใช้การกำหนดค่า JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### การใช้ไฟล์ Markdown - -สร้างไฟล์โหมดใน `.opencode/modes/` สำหรับโหมดเฉพาะโครงการหรือ `~/.config/opencode/modes/` สำหรับโหมดทั่วโลก: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### กรณีการใช้งาน - -ต่อไปนี้เป็นกรณีการใช้งานทั่วไปสำหรับโหมดต่างๆ - -- **Build mode**: งานพัฒนาเต็มรูปแบบโดยเปิดใช้งานเครื่องมือทั้งหมด -- **Plan mode**: วิเคราะห์และวางแผนโดยไม่ทำการเปลี่ยนแปลง -- **Review mode**: การตรวจสอบโค้ดพร้อมการเข้าถึงแบบอ่านอย่างเดียวพร้อมเครื่องมือเอกสารประกอบ -- **Debug mode**: มุ่งเน้นไปที่การตรวจสอบโดยเปิดใช้งานเครื่องมือ bash และอ่าน -- **Docs mode**: การเขียนเอกสารด้วยการทำงานของไฟล์ แต่ไม่มีคำสั่งระบบ - -คุณอาจพบว่ารุ่นต่างๆ นั้นดีสำหรับกรณีการใช้งานที่แตกต่างกัน diff --git a/packages/web/src/content/docs/tr/modes.mdx b/packages/web/src/content/docs/tr/modes.mdx deleted file mode 100644 index 8f722ec22..000000000 --- a/packages/web/src/content/docs/tr/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modlar -description: farklı kullanım durumları için farklı modlar. ---- - -:::caution -Modlar artık opencode yapılandırmasındaki `agent` seçeneği aracılığıyla yapılandırılıyor. -`mode` seçeneği artık kullanımdan kaldırıldı. [Daha fazla bilgi](/docs/agents). -::: - -opencode'daki modlar, farklı kullanım durumları için davranışı, araçları ve istemleri özelleştirmenize olanak tanır. - -İki yerleşik modla birlikte gelir: **build** ve **plan**. Kişiselleştirebilirsiniz -bunları veya opencode yapılandırması aracılığıyla kendinizinkini yapılandırın. - -Bir oturum sırasında modlar arasında geçiş yapabilir veya bunları yapılandırma dosyanızda yapılandırabilirsiniz. - ---- - -## Yerleşik - -opencode iki yerleşik modla birlikte gelir. - ---- - -### Build - -Derleme, tüm araçların etkin olduğu **varsayılan** moddur. Bu, dosya işlemlerine ve sistem komutlarına tam erişime ihtiyaç duyduğunuz geliştirme çalışmaları için standart moddur. - ---- - -### Plan - -Planlama ve analiz için tasarlanmış sınırlı bir mod. Plan modunda aşağıdaki araçlar varsayılan olarak devre dışıdır: - -- `write` - Yeni dosyalar oluşturulamıyor -- `edit` - Planın kendisini detaylandırmak için `.opencode/plans/*.md` adresinde bulunan dosyalar dışında mevcut dosyalar değiştirilemez -- `patch` - Yamalar uygulanamaz -- `bash` - Kabuk komutları yürütülemiyor - -Bu mod, yapay zekanın kod tabanınızda herhangi bir gerçek değişiklik yapmadan kodu analiz etmesini, değişiklik önermesini veya plan oluşturmasını istediğinizde kullanışlıdır. - ---- - -## Geçiş - -Bir oturum sırasında _Tab_ tuşunu kullanarak modlar arasında geçiş yapabilirsiniz. Veya yapılandırılmış `switch_mode` tuş bağlantınız. - -Ayrıca bkz.: Kod biçimlendirme yapılandırması hakkında bilgi için [Formatters](/docs/formatters). - ---- - -## Yapılandırma - -Yerleşik modları özelleştirebilir veya yapılandırma aracılığıyla kendinizinkini oluşturabilirsiniz. Modlar iki şekilde yapılandırılabilir: - -### JSON Yapılandırması - -`opencode.json` yapılandırma dosyanızdaki modları yapılandırın: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown Yapılandırması - -Markdown dosyalarını kullanarak modları da tanımlayabilirsiniz. Bunları şuraya yerleştirin: - -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown dosyası adı mod adı olur (örneğin, `review.md` bir `review` modu oluşturur). - -Bu yapılandırma seçeneklerine ayrıntılı olarak bakalım. - ---- - -### Model - -Bu modun varsayılan modelini geçersiz kılmak için `model` ayarını kullanın. Farklı bölümler için optimize edilmiş farklı modelleri kullanmak için kullanışlıdır. Örneğin planlama için daha hızlı bir model, uygulama için daha yetenekli bir model. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Sıcaklık - -`temperature` yapılandırmasıyla yapay zekanın yanıtlarının rastgeleliğini ve yaratıcılığını kontrol edin. Düşük değerler yanıtları daha odaklı ve belirleyici hale getirirken, yüksek değerler yaratıcılığı ve değişkenliği artırır. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Sıcaklık değerleri tipik olarak 0,0 ila 1,0 arasındadır: - -- **0,0-0,2**: Çok odaklı ve belirleyici yanıtlar, kod analizi ve planlaması için idealdir -- **0,3-0,5**: Biraz yaratıcılık içeren dengeli yanıtlar, genel gelişim görevleri için iyi -- **0,6-1,0**: Daha yaratıcı ve çeşitli yanıtlar, beyin fırtınası ve keşif için yararlı - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Sıcaklık belirtilmezse, opencode modele özgü varsayılanları kullanır (çoğu model için genellikle 0, Qwen modelleri için 0,55). - ---- - -### İstem - -`prompt` yapılandırmasıyla bu mod için özel bir sistem bilgi istemi dosyası belirtin. Bilgi istemi dosyası, modun amacına özel talimatlar içermelidir. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Bu yol, yapılandırma dosyasının bulunduğu yere göredir. Yani bu işe yarıyor -hem global opencode yapılandırması hem de projeye özel yapılandırma. - ---- - -### Araçlar - -`tools` yapılandırmasıyla bu modda hangi araçların kullanılabileceğini kontrol edin. Belirli araçları `true` veya `false` olarak ayarlayarak etkinleştirebilir veya devre dışı bırakabilirsiniz. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hiçbir araç belirtilmezse tüm araçlar varsayılan olarak etkindir. - ---- - -#### Mevcut araçlar - -İşte mod yapılandırması aracılığıyla kontrol edilebilecek tüm araçlar. - -| Araç | Açıklama | -| ----------- | ------------------------------ | -| `bash` | Kabuk komutlarını yürütün | -| `edit` | Mevcut dosyaları değiştirin | -| `write` | Yeni dosyalar oluştur | -| `read` | Dosya içeriğini oku | -| `grep` | Dosya içeriğini ara | -| `glob` | Dosyaları desene göre bul | -| `patch` | Dosyalara yama uygula | -| `todowrite` | Yapılacaklar listelerini yönet | -| `webfetch` | Web içeriğini getir | - ---- - -## Özel modlar - -Yapılandırmaya ekleyerek kendi özel modlarınızı oluşturabilirsiniz. Her iki yaklaşımın kullanıldığı örnekler aşağıda verilmiştir: - -### JSON yapılandırması kullanma - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Markdown dosyaları kullanma - -Projeye özel modlar için `.opencode/modes/` veya genel modlar için `~/.config/opencode/modes/`'de mod dosyaları oluşturun: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Kullanım Senaryoları - -Farklı modlar için bazı yaygın kullanım senaryoları aşağıda verilmiştir. - -- **Derleme modu**: Tüm araçların etkinleştirildiği tam geliştirme çalışması -- **Plan modu**: Değişiklik yapmadan analiz ve planlama -- **İnceleme modu**: Salt okunur erişim ve belgeleme araçlarıyla kod incelemesi -- **Hata ayıklama modu**: Bash ve okuma araçları etkinken araştırmaya odaklanıldı -- **Belgeler modu**: Dosya işlemleriyle ancak sistem komutları olmadan belge yazma - -Ayrıca farklı modellerin farklı kullanım durumları için iyi olduğunu da görebilirsiniz. diff --git a/packages/web/src/content/docs/zh-cn/modes.mdx b/packages/web/src/content/docs/zh-cn/modes.mdx deleted file mode 100644 index 256cffe81..000000000 --- a/packages/web/src/content/docs/zh-cn/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: 模式 -description: 不同模式适用于不同的使用场景。 ---- - -:::caution -模式现在通过 opencode 配置中的 `agent` 选项进行配置。`mode` 选项已废弃。[了解更多](/docs/agents)。 -::: - -opencode 中的模式允许你为不同的使用场景自定义行为、工具和提示词。 - -opencode 自带两种内置模式:**build** 和 **plan**。你可以自定义这些模式,也可以通过 opencode 配置创建自己的模式。 - -你可以在会话中切换模式,也可以在配置文件中进行配置。 - ---- - -## 内置模式 - -opencode 自带两种内置模式。 - ---- - -### Build - -Build 是启用了所有工具的**默认**模式。这是进行开发工作的标准模式,你可以完全访问文件操作和系统命令。 - ---- - -### Plan - -Plan 是一种为规划和分析设计的受限模式。在 plan 模式下,以下工具默认被禁用: - -- `write` - 无法创建新文件 -- `edit` - 无法修改现有文件,但位于 `.opencode/plans/*.md` 的文件除外,用于详细说明计划本身 -- `patch` - 无法应用补丁 -- `bash` - 无法执行 shell 命令 - -当你希望 AI 分析代码、提出修改建议或制定计划,而不对代码库进行任何实际更改时,此模式非常有用。 - ---- - -## 切换 - -你可以在会话中使用 _Tab_ 键切换模式,或者使用你配置的 `switch_mode` 快捷键。 - -另请参阅:[格式化工具](/docs/formatters)了解代码格式化配置的相关信息。 - ---- - -## 配置 - -你可以自定义内置模式或通过配置创建自己的模式。模式可以通过两种方式进行配置: - -### JSON 配置 - -在 `opencode.json` 配置文件中配置模式: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 配置 - -你还可以使用 Markdown 文件定义模式。将文件放置在以下位置: - -- 全局:`~/.config/opencode/modes/` -- 项目:`.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 文件名即为模式名称(例如,`review.md` 创建一个名为 `review` 的模式)。 - -下面让我们详细了解这些配置选项。 - ---- - -### 模型 - -使用 `model` 配置可以覆盖该模式的默认模型。这对于针对不同任务使用不同模型非常有用。例如,规划时使用更快的模型,实现时使用更强大的模型。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 温度 - -使用 `temperature` 配置控制 AI 响应的随机性和创造性。较低的值使响应更加集中和确定性,较高的值则增加创造性和多样性。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -温度值的范围通常为 0.0 到 1.0: - -- **0.0-0.2**:非常集中且确定性高的响应,适合代码分析和规划 -- **0.3-0.5**:兼顾稳定性与创造力的平衡型响应,适合一般开发任务 -- **0.6-1.0**:更具创造性和多样性的响应,适合头脑风暴和探索性工作 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -如果未指定温度,opencode 将使用模型特定的默认值(大多数模型通常为 0,Qwen 模型为 0.55)。 - ---- - -### 提示词 - -使用 `prompt` 配置为模式指定自定义系统提示词文件。提示词文件应包含针对该模式用途的具体指令。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -此路径相对于配置文件所在位置。因此,全局 opencode 配置和项目特定配置均可使用。 - ---- - -### 工具 - -使用 `tools` 配置控制该模式下可用的工具。你可以将特定工具设置为 `true` 或 `false` 来启用或禁用它们。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -如果未指定任何工具,则默认启用所有工具。 - ---- - -#### 可用工具 - -以下是所有可通过模式配置控制的工具。 - -| 工具 | 描述 | -| ----------- | ---------------- | -| `bash` | 执行 shell 命令 | -| `edit` | 修改现有文件 | -| `write` | 创建新文件 | -| `read` | 读取文件内容 | -| `grep` | 搜索文件内容 | -| `glob` | 按模式查找文件 | -| `patch` | 对文件应用补丁 | -| `todowrite` | 管理待办事项列表 | -| `webfetch` | 获取网页内容 | - ---- - -## 自定义模式 - -你可以通过在配置中添加自定义模式来创建自己的模式。以下是两种方式的示例: - -### 使用 JSON 配置 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### 使用 Markdown 文件 - -在 `.opencode/modes/` 中创建项目特定的模式文件,或在 `~/.config/opencode/modes/` 中创建全局模式文件: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 使用场景 - -以下是不同模式的一些常见使用场景。 - -- **Build 模式**:启用所有工具的完整开发工作 -- **Plan 模式**:分析和规划,不做任何更改 -- **Review 模式**:使用只读访问权限加文档工具进行代码审查 -- **Debug 模式**:启用 bash 和读取工具,专注于问题排查 -- **Docs 模式**:支持文件操作但不支持系统命令的文档编写 - -你可能还会发现不同的模型适用于不同的使用场景。 diff --git a/packages/web/src/content/docs/zh-tw/modes.mdx b/packages/web/src/content/docs/zh-tw/modes.mdx deleted file mode 100644 index 625b4d021..000000000 --- a/packages/web/src/content/docs/zh-tw/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: 模式 -description: 不同模式適用於不同的使用情境。 ---- - -:::caution -模式現在透過 opencode 設定中的 `agent` 選項進行設定。`mode` 選項已廢棄。[了解更多](/docs/agents)。 -::: - -opencode 中的模式允許您為不同的使用情境自訂行為、工具和提示詞。 - -opencode 自帶兩種內建模式:**build** 和 **plan**。您可以自訂這些模式,也可以透過 opencode 設定建立自己的模式。 - -您可以在工作階段中切換模式,也可以在設定檔中進行設定。 - ---- - -## 內建模式 - -opencode 自帶兩種內建模式。 - ---- - -### Build - -Build 是啟用了所有工具的**預設**模式。這是進行開發工作的標準模式,您可以完全存取檔案操作和系統指令。 - ---- - -### Plan - -Plan 是一種為規劃和分析設計的受限模式。在 plan 模式下,以下工具預設被停用: - -- `write` - 無法建立新檔案 -- `edit` - 無法修改現有檔案,但位於 `.opencode/plans/*.md` 的檔案除外,用於詳細說明計畫本身 -- `patch` - 無法套用補丁 -- `bash` - 無法執行 shell 指令 - -當您希望 AI 分析程式碼、提出修改建議或制定計畫,而不對程式碼庫進行任何實際更改時,此模式非常有用。 - ---- - -## 切換 - -您可以在工作階段中使用 _Tab_ 鍵切換模式,或者使用您設定的 `switch_mode` 快捷鍵。 - -另請參閱:[格式化工具](/docs/formatters)了解程式碼格式化設定的相關資訊。 - ---- - -## 設定 - -您可以自訂內建模式或透過設定建立自己的模式。模式可以透過兩種方式進行設定: - -### JSON 設定 - -在 `opencode.json` 設定檔中設定模式: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 設定 - -您還可以使用 Markdown 檔案定義模式。將檔案放置在以下位置: - -- 全域:`~/.config/opencode/modes/` -- 專案:`.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 檔案名稱即為模式名稱(例如,`review.md` 建立一個名為 `review` 的模式)。 - -下面讓我們詳細了解這些設定選項。 - ---- - -### 模型 - -使用 `model` 設定可以覆寫該模式的預設模型。這對於針對不同任務使用不同模型非常有用。例如,規劃時使用更快的模型,實作時使用更強大的模型。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 溫度 - -使用 `temperature` 設定控制 AI 回應的隨機性和創造性。較低的值使回應更加集中和確定性,較高的值則增加創造性和多樣性。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -溫度值的範圍通常為 0.0 到 1.0: - -- **0.0-0.2**:非常集中且確定性高的回應,適合程式碼分析和規劃 -- **0.3-0.5**:兼顧穩定性與創造力的平衡型回應,適合一般開發任務 -- **0.6-1.0**:更具創造性和多樣性的回應,適合腦力激盪和探索性工作 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -如果未指定溫度,opencode 將使用模型特定的預設值(大多數模型通常為 0,Qwen 模型為 0.55)。 - ---- - -### 提示詞 - -使用 `prompt` 設定為模式指定自訂系統提示詞檔案。提示詞檔案應包含針對該模式用途的具體指令。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -此路徑相對於設定檔所在位置。因此,全域 opencode 設定和專案特定設定均可使用。 - ---- - -### 工具 - -使用 `tools` 設定控制該模式下可用的工具。您可以將特定工具設定為 `true` 或 `false` 來啟用或停用它們。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -如果未指定任何工具,則預設啟用所有工具。 - ---- - -#### 可用工具 - -以下是所有可透過模式設定控制的工具。 - -| 工具 | 描述 | -| ----------- | ---------------- | -| `bash` | 執行 shell 指令 | -| `edit` | 修改現有檔案 | -| `write` | 建立新檔案 | -| `read` | 讀取檔案內容 | -| `grep` | 搜尋檔案內容 | -| `glob` | 按模式尋找檔案 | -| `patch` | 對檔案套用補丁 | -| `todowrite` | 管理待辦事項清單 | -| `webfetch` | 擷取網頁內容 | - ---- - -## 自訂模式 - -您可以透過在設定中新增自訂模式來建立自己的模式。以下是兩種方式的範例: - -### 使用 JSON 設定 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### 使用 Markdown 檔案 - -在 `.opencode/modes/` 中建立專案特定的模式檔案,或在 `~/.config/opencode/modes/` 中建立全域模式檔案: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 使用情境 - -以下是不同模式的一些常見使用情境。 - -- **Build 模式**:啟用所有工具的完整開發工作 -- **Plan 模式**:分析和規劃,不做任何更改 -- **Review 模式**:使用唯讀存取權限加文件工具進行程式碼審查 -- **Debug 模式**:啟用 bash 和讀取工具,專注於問題排查 -- **Docs 模式**:支援檔案操作但不支援系統指令的文件編寫 - -您可能還會發現不同的模型適用於不同的使用情境。 From 163290bcf08eacaf8d62330b8fff92cf0701eab5 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 1 May 2026 11:56:31 +0800 Subject: [PATCH 002/174] desktop: sentry integration (#15300) Co-authored-by: Jay V --- .github/workflows/deploy.yml | 6 + .github/workflows/publish.yml | 7 ++ bun.lock | 114 ++++++++++++++++-- package.json | 2 + packages/app/package.json | 2 + packages/app/src/app.tsx | 20 ++- packages/app/src/entry.tsx | 20 +++ packages/app/src/env.d.ts | 4 + packages/app/src/i18n/ar.ts | 2 + packages/app/src/i18n/br.ts | 2 + packages/app/src/i18n/bs.ts | 2 + packages/app/src/i18n/da.ts | 2 + packages/app/src/i18n/de.ts | 2 + packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/es.ts | 2 + packages/app/src/i18n/fr.ts | 2 + packages/app/src/i18n/ja.ts | 2 + packages/app/src/i18n/ko.ts | 2 + packages/app/src/i18n/no.ts | 2 + packages/app/src/i18n/pl.ts | 2 + packages/app/src/i18n/ru.ts | 2 + packages/app/src/i18n/th.ts | 2 + packages/app/src/i18n/tr.ts | 2 + packages/app/src/i18n/zh.ts | 2 + packages/app/src/i18n/zht.ts | 2 + packages/app/src/pages/error.tsx | 22 +++- packages/app/vite.config.ts | 22 +++- .../desktop-electron/electron.vite.config.ts | 21 +++- packages/desktop-electron/package.json | 2 + .../desktop-electron/src/renderer/index.tsx | 20 +++ packages/desktop/package.json | 2 + packages/desktop/src/env.d.ts | 9 ++ packages/desktop/src/index.tsx | 1 + packages/desktop/vite.config.ts | 6 +- 34 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 packages/desktop/src/env.d.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 96f437a73..e346d0cd5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,9 @@ jobs: PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: web@${{ github.sha }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd9b60f8b..9981edad7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -494,6 +494,13 @@ jobs: working-directory: packages/desktop-electron env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} + VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - name: Package and publish if: needs.version.outputs.release diff --git a/bun.lock b/bun.lock index 093bb880a..a771dd529 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", @@ -69,6 +70,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -230,6 +232,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", @@ -250,6 +253,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", @@ -275,6 +279,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", + "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", @@ -688,6 +694,8 @@ "@opentui/solid": "0.2.0", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1956,6 +1964,44 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA=="], + + "@sentry-internal/replay": ["@sentry-internal/replay@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.36.0", "", { "dependencies": { "@sentry-internal/replay": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ=="], + + "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="], + + "@sentry/browser": ["@sentry/browser@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry-internal/feedback": "10.36.0", "@sentry-internal/replay": "10.36.0", "@sentry-internal/replay-canvas": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ=="], + + "@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="], + + "@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="], + + "@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="], + + "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="], + + "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="], + + "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="], + + "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="], + + "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="], + + "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="], + + "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="], + + "@sentry/core": ["@sentry/core@10.36.0", "", {}, "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ=="], + + "@sentry/solid": ["@sentry/solid@10.36.0", "", { "dependencies": { "@sentry/browser": "10.36.0", "@sentry/core": "10.36.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-AaDqz3JGBrQCm2YVqODVyJHwg7LRTNSJig9mjfProFyvkC7eUXQ/HBJrrhAD1Dct9ufmDH3G+f3/Ut9LgpItSg=="], + + "@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="], + "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], @@ -3240,7 +3286,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -3734,7 +3780,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4138,7 +4184,7 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -4188,7 +4234,7 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -4948,7 +4994,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], @@ -5056,7 +5102,9 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webpack-sources": ["webpack-sources@3.4.0", "", {}, "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -5608,6 +5656,16 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -5662,6 +5720,8 @@ "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -5846,8 +5906,6 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -5958,7 +6016,7 @@ "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -5970,6 +6028,8 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -6066,6 +6126,10 @@ "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6566,6 +6630,16 @@ "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6588,6 +6662,8 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -6734,8 +6810,12 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -6764,6 +6844,8 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -6988,6 +7070,12 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], @@ -7084,6 +7172,8 @@ "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], @@ -7096,6 +7186,8 @@ "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7150,6 +7242,8 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], @@ -7176,6 +7270,8 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 975f17774..9a0113030 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" diff --git a/packages/app/package.json b/packages/app/package.json index 1a5a1a007..c6a4f4398 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -40,6 +41,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@sentry/solid": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/core": "workspace:*", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index bf8138fcd..3189d8025 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,4 +1,5 @@ import "@/index.css" +import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -148,12 +149,19 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { > - }> - - - {props.children} - - + { + Sentry.captureException(error) + return + }} + > + + + + {props.children} + + + diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e7..ade572c2f 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,5 +1,6 @@ // @refresh reload +import * as Sentry from "@sentry/solid" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" @@ -125,6 +126,25 @@ const platform: Platform = { setDefaultServer: writeDefaultServerUrl, } +if (import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`, + initialScope: { + tags: { + platform: "web", + }, + }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, + }) +} + if (root instanceof HTMLElement) { const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } render( diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 9b03d336f..39f827eb6 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -2,6 +2,10 @@ interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" + + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string } interface ImportMeta { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 49808f3fb..405153647 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -402,6 +402,8 @@ export const dict = { "error.page.description": "حدث خطأ أثناء تحميل التطبيق.", "error.page.details.label": "تفاصيل الخطأ", "error.page.action.restart": "إعادة تشغيل", + "error.page.action.report": "الإبلاغ عن الخطأ", + "error.page.action.reported": "تم الإبلاغ عن الخطأ", "error.page.action.checking": "جارٍ التحقق...", "error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.updateTo": "تحديث إلى {{version}}", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 5b96ff57a..9a3a47680 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -403,6 +403,8 @@ export const dict = { "error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.details.label": "Detalhes do Erro", "error.page.action.restart": "Reiniciar", + "error.page.action.report": "Reportar erro", + "error.page.action.reported": "Erro reportado", "error.page.action.checking": "Verificando...", "error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.updateTo": "Atualizar para {{version}}", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 98e565464..ae34749c3 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -449,6 +449,8 @@ export const dict = { "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", "error.page.details.label": "Detalji greške", "error.page.action.restart": "Restartuj", + "error.page.action.report": "Prijavi grešku", + "error.page.action.reported": "Greška prijavljena", "error.page.action.checking": "Provjera...", "error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.updateTo": "Ažuriraj na {{version}}", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 5c1dcfcae..370420398 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -446,6 +446,8 @@ export const dict = { "error.page.description": "Der opstod en fejl under indlæsning af applikationen.", "error.page.details.label": "Fejldetaljer", "error.page.action.restart": "Genstart", + "error.page.action.report": "Rapportér fejl", + "error.page.action.reported": "Fejl rapporteret", "error.page.action.checking": "Tjekker...", "error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.updateTo": "Opdater til {{version}}", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 68a4def36..532ff7c08 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -410,6 +410,8 @@ export const dict = { "error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.", "error.page.details.label": "Fehlerdetails", "error.page.action.restart": "Neustart", + "error.page.action.report": "Fehler melden", + "error.page.action.reported": "Fehler gemeldet", "error.page.action.checking": "Prüfen...", "error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.updateTo": "Auf {{version}} aktualisieren", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1d2b03db7..250d26edb 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -465,6 +465,8 @@ export const dict = { "error.page.description": "An error occurred while loading the application.", "error.page.details.label": "Error Details", "error.page.action.restart": "Restart", + "error.page.action.report": "Report Error", + "error.page.action.reported": "Error Reported", "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4761cd200..cde8f3ec5 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -449,6 +449,8 @@ export const dict = { "error.page.description": "Ocurrió un error al cargar la aplicación.", "error.page.details.label": "Detalles del error", "error.page.action.restart": "Reiniciar", + "error.page.action.report": "Informar error", + "error.page.action.reported": "Error informado", "error.page.action.checking": "Comprobando...", "error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.updateTo": "Actualizar a {{version}}", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f470cace4..6cea948b1 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -406,6 +406,8 @@ export const dict = { "error.page.description": "Une erreur s'est produite lors du chargement de l'application.", "error.page.details.label": "Détails de l'erreur", "error.page.action.restart": "Redémarrer", + "error.page.action.report": "Signaler l'erreur", + "error.page.action.reported": "Erreur signalée", "error.page.action.checking": "Vérification...", "error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.updateTo": "Mettre à jour vers {{version}}", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 51eab3d09..ec4d95b98 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -402,6 +402,8 @@ export const dict = { "error.page.description": "アプリケーションの読み込み中にエラーが発生しました。", "error.page.details.label": "エラー詳細", "error.page.action.restart": "再起動", + "error.page.action.report": "エラーを報告", + "error.page.action.reported": "エラーを報告しました", "error.page.action.checking": "確認中...", "error.page.action.checkUpdates": "アップデートを確認", "error.page.action.updateTo": "{{version}}にアップデート", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 206ae23d8..6eb064244 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -401,6 +401,8 @@ export const dict = { "error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.", "error.page.details.label": "오류 세부 정보", "error.page.action.restart": "다시 시작", + "error.page.action.report": "오류 신고", + "error.page.action.reported": "오류가 신고됨", "error.page.action.checking": "확인 중...", "error.page.action.checkUpdates": "업데이트 확인", "error.page.action.updateTo": "{{version}} 버전으로 업데이트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 3014e1e5d..a280a3dbf 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -450,6 +450,8 @@ export const dict = { "error.page.description": "Det oppstod en feil under lasting av applikasjonen.", "error.page.details.label": "Feildetaljer", "error.page.action.restart": "Start på nytt", + "error.page.action.report": "Rapporter feil", + "error.page.action.reported": "Feil rapportert", "error.page.action.checking": "Sjekker...", "error.page.action.checkUpdates": "Se etter oppdateringer", "error.page.action.updateTo": "Oppdater til {{version}}", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index a555f09e3..eea33b723 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -403,6 +403,8 @@ export const dict = { "error.page.description": "Wystąpił błąd podczas ładowania aplikacji.", "error.page.details.label": "Szczegóły błędu", "error.page.action.restart": "Restartuj", + "error.page.action.report": "Zgłoś błąd", + "error.page.action.reported": "Błąd zgłoszony", "error.page.action.checking": "Sprawdzanie...", "error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.updateTo": "Zaktualizuj do {{version}}", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d8a81357b..6e455220b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -448,6 +448,8 @@ export const dict = { "error.page.description": "Произошла ошибка при загрузке приложения.", "error.page.details.label": "Детали ошибки", "error.page.action.restart": "Перезапустить", + "error.page.action.report": "Сообщить об ошибке", + "error.page.action.reported": "Об ошибке сообщено", "error.page.action.checking": "Проверка...", "error.page.action.checkUpdates": "Проверить обновления", "error.page.action.updateTo": "Обновить до {{version}}", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index b51b9a13c..998962db9 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -447,6 +447,8 @@ export const dict = { "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน", "error.page.details.label": "รายละเอียดข้อผิดพลาด", "error.page.action.restart": "รีสตาร์ท", + "error.page.action.report": "รายงานข้อผิดพลาด", + "error.page.action.reported": "รายงานข้อผิดพลาดแล้ว", "error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.updateTo": "อัปเดตเป็น {{version}}", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 0284e908b..4d006a43b 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -452,6 +452,8 @@ export const dict = { "error.page.description": "Uygulama yüklenirken bir hata oluştu.", "error.page.details.label": "Hata Detayları", "error.page.action.restart": "Yeniden Başlat", + "error.page.action.report": "Hatayı Bildir", + "error.page.action.reported": "Hata Bildirildi", "error.page.action.checking": "Kontrol ediliyor...", "error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.updateTo": "{{version}} sürümüne güncelle", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e9d0fa471..a422a5d61 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -452,6 +452,8 @@ export const dict = { "error.page.description": "加载应用程序时发生错误。", "error.page.details.label": "错误详情", "error.page.action.restart": "重启", + "error.page.action.report": "上报错误", + "error.page.action.reported": "错误已上报", "error.page.action.checking": "检查中...", "error.page.action.checkUpdates": "检查更新", "error.page.action.updateTo": "更新到 {{version}}", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index ff6392d25..7d894a83e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -445,6 +445,8 @@ export const dict = { "error.page.description": "載入應用程式時發生錯誤。", "error.page.details.label": "錯誤詳情", "error.page.action.restart": "重新啟動", + "error.page.action.report": "回報錯誤", + "error.page.action.reported": "已回報錯誤", "error.page.action.checking": "檢查中...", "error.page.action.checkUpdates": "檢查更新", "error.page.action.updateTo": "更新到 {{version}}", diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index ba0045ec9..5f3d7baa6 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -1,7 +1,8 @@ import { TextField } from "@opencode-ai/ui/text-field" +import * as Sentry from "@sentry/solid" import { Logo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component, Show } from "solid-js" +import { Component, createSignal, Show } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" @@ -270,10 +271,27 @@ export const ErrorPage: Component = (props) => { label={language.t("error.page.details.label")} hideLabel /> -
+
+ + {(_) => { + const [reported, setReported] = createSignal(false) + return ( + + ) + }} + { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, + }) +} + void initI18n() const deepLinkEvent = "opencode:deep-link" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ff81acaaf..61ef5d05c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", @@ -35,6 +36,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/desktop/src/env.d.ts b/packages/desktop/src/env.d.ts new file mode 100644 index 000000000..aff016842 --- /dev/null +++ b/packages/desktop/src/env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index a760cb409..1a0da014d 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -14,6 +14,7 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" +import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" import { readImage } from "@tauri-apps/plugin-clipboard-manager" diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 62c3a099a..e8f8f8465 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -15,9 +15,9 @@ export default defineConfig({ // Improves production stack traces keepNames: true, }, - // build: { - // sourcemap: true, - // }, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, From 4e451a4b0f71b8f0a22cbbc7cfdc7825a92915e8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 04:07:58 +0000 Subject: [PATCH 003/174] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index d9169d95c..b9ba578ac 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-QthnHaV7hTxvxEzQpxI7HWjb2n1ZMTYviA7DDdAzJwk=", - "aarch64-linux": "sha256-l6/2wVmNBBtCI0ovhfhyq3ZSebj6qZFWXptYqUy2Rh8=", - "aarch64-darwin": "sha256-upNQqcwa/b/XPyQFTMOkp6S5QvILg5Y3LnDCmEe9iqA=", - "x86_64-darwin": "sha256-/M91h4YUTsBveDhwtl5mTkQaRE+92WZmzs2p4D+RuBk=" + "x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=", + "aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=", + "aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=", + "x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY=" } } From 08895c396e76122d7b5d5d781542565371473b46 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:20:54 -0500 Subject: [PATCH 004/174] docs: fix tui and keybinds documentation (#25233) --- packages/web/src/content/docs/keybinds.mdx | 9 ++++++++- packages/web/src/content/docs/tui.mdx | 12 ++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 5488aaf81..4106cb232 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -23,7 +23,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_list": "l", "session_timeline": "g", "session_fork": "none", - "session_rename": "none", + "session_rename": "ctrl+r", "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", @@ -105,6 +105,13 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. } ``` +:::note +On Windows, the defaults for `input_undo` and `terminal_suspend` are different: + +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). +- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. +::: + --- ## Leader key diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index e89fb4af3..73ecce93b 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -99,8 +99,6 @@ Toggle tool execution details. /details ``` -**Keybind:** `ctrl+x d` - --- ### editor @@ -147,8 +145,6 @@ Show the help dialog. /help ``` -**Keybind:** `ctrl+x h` - --- ### init @@ -159,8 +155,6 @@ Guided setup for creating or updating `AGENTS.md`. [Learn more](/docs/rules). /init ``` -**Keybind:** `ctrl+x i` - --- ### models @@ -226,8 +220,6 @@ Share current session. [Learn more](/docs/share). /share ``` -**Keybind:** `ctrl+x s` - --- ### themes @@ -366,7 +358,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). }, "scroll_speed": 3, "scroll_acceleration": { - "enabled": true + "enabled": false }, "diff_style": "auto", "mouse": true @@ -390,7 +382,7 @@ Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. ## Customization -You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts. +You can customize various aspects of the TUI view using the command palette (`ctrl+p`). These settings persist across restarts. --- From 4eae8ec037356a587521c7bc38cd1416f7d92cdc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 04:21:51 +0000 Subject: [PATCH 005/174] chore: generate --- packages/web/src/content/docs/keybinds.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 4106cb232..86970638c 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -110,7 +110,7 @@ On Windows, the defaults for `input_undo` and `terminal_suspend` are different: - `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). - `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. -::: + ::: --- From 563177c6ac738e5899a1d401f578e840a16a32ad Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 1 May 2026 00:13:03 -0500 Subject: [PATCH 006/174] fix: fix issue if tool returned image and empty text and it caused api errors (#25241) --- packages/opencode/src/session/message-v2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 911f58efd..a017ead1e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -772,7 +772,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return { type: "content", value: [ - { type: "text", text: outputObject.text }, + ...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, From a5aa72bd7d6767ae491f603ac4ba19a48b099bd1 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 1 May 2026 13:39:22 +0800 Subject: [PATCH 007/174] fix: update provider store after loading providers in bootstrap (#25236) --- packages/app/src/context/global-sync.tsx | 3 +++ packages/app/src/context/global-sync/bootstrap.ts | 3 --- .../app/src/context/global-sync/child-store.test.ts | 1 + packages/app/src/context/global-sync/child-store.ts | 13 +++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ba9f6d52a..6190deb1e 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -204,6 +204,9 @@ function createGlobalSync() { }, translate: language.t, getSdk: sdkFor, + global: { + provider: globalStore.provider, + }, }) async function loadSessions(directory: string) { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 451531835..e85516bf1 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -260,9 +260,6 @@ export async function bootstrapDirectory(input: { const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined if (seededProject) input.setStore("project", seededProject) if (seededPath) input.setStore("path", seededPath) - if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { - input.setStore("provider", input.global.provider) - } if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 24b4a4650..30dda8691 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -23,6 +23,7 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, getSdk: () => null!, + global: { provider: null! }, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index a7209d3db..0138310cd 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -27,6 +27,9 @@ export function createChildStoreManager(input: { onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string getSdk: (directory: string) => OpencodeClient + global: { + provider: ProviderListResponse + } }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -189,7 +192,13 @@ export function createChildStoreManager(input: { get provider_ready() { return !providerQuery.isLoading }, - provider: { all: [], connected: [], default: {} }, + get provider() { + const EMPTY = { all: [], connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0) + return input.global.provider + return providerQuery.data ?? EMPTY + }, config: {}, get path() { if (pathQuery.isLoading || !pathQuery.data) From 21f8027ef74ee281cd9e94ddfd4fb96947fc1f6b Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 1 May 2026 06:13:48 +0000 Subject: [PATCH 008/174] sync release versions for v1.14.31 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index a771dd529..fcd8e9443 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.30", + "version": "1.14.31", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.30", + "version": "1.14.31", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index c6a4f4398..2decf1fce 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.30", + "version": "1.14.31", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 49b4cf48c..afb903377 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3e1bd07f3..3ef4ad2e3 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.30", + "version": "1.14.31", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 5e450446a..92b62a1bf 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.30", + "version": "1.14.31", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 4839bdc7e..b29e0d887 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 428419434..5cbf063d3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.30", + "version": "1.14.31", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 799270457..16eaad158 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 61ef5d05c..b2d2c975c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 98a5ddd83..7d93297d0 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.30", + "version": "1.14.31", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 80c73f876..340a747d5 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.30" +version = "1.14.31" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index bce26ed57..d7536425c 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.30", + "version": "1.14.31", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cf2e574f5..ea91bef74 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.30", + "version": "1.14.31", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9082f1183..75aba38d1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1fbf05d5e..3da6b1b8a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index b19077be0..01c0ab245 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 641f0667c..c350b1e30 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index c487d6ba4..093d4d91a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.30", + "version": "1.14.31", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 2d3783417..f107f9fa5 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.30", + "version": "1.14.31", "publisher": "sst-dev", "repository": { "type": "git", From a6b6395c8acd8ab096b344176f86c9aa2b8983a6 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 1 May 2026 11:33:44 +0200 Subject: [PATCH 009/174] fix(tui): gate logo subpixel rendering on truecolor support (#25265) --- packages/opencode/src/cli/cmd/tui/component/logo.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index bee104a35..e3e8074cd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,4 +1,5 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" +import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import * as Sound from "@tui/util/sound" @@ -554,6 +555,7 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState { export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) { const ctx = props.shape ? build(props.shape) : DEFAULT const { theme } = useTheme() + const renderer = useRenderer() const [rings, setRings] = createSignal([]) const [hold, setHold] = createSignal() const [release, setRelease] = createSignal() @@ -684,6 +686,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = }) const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined)) + const useSubpixelBlocks = () => renderer.capabilities?.rgb === true const renderLine = ( line: string, @@ -789,7 +792,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = } // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values - if (char === "█") { + if (char === "█" && useSubpixelBlocks()) { return ( Date: Fri, 1 May 2026 18:05:06 +0800 Subject: [PATCH 010/174] fix: correct documentation typos (#25260) --- packages/opencode/src/sync/README.md | 6 +++--- packages/web/src/content/docs/providers.mdx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/sync/README.md b/packages/opencode/src/sync/README.md index 546cf3ced..cb7e87564 100644 --- a/packages/opencode/src/sync/README.md +++ b/packages/opencode/src/sync/README.md @@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes ### Event shape -- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus. +- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus. The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. @@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. -The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this). +The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this). -It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: +It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: ```ts // The schema from `Updated` typechecks the object correctly diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 8576ec356..7c395022c 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1767,7 +1767,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon ### STACKIT -STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. +STACKIT AI Model Serving provides fully managed sovereign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. 1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project. From 8c79c58c4d6330016331b698717f50aa21523058 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 07:36:52 -0400 Subject: [PATCH 011/174] refactor: rename workspace adapters (#25272) --- packages/opencode/specs/effect/http-api.md | 4 +- packages/opencode/specs/effect/schema.md | 2 +- .../tui/component/dialog-workspace-create.tsx | 16 +-- .../src/control-plane/adapters/index.ts | 45 ++++++++ .../{adaptors => adapters}/worktree.ts | 4 +- .../src/control-plane/adaptors/index.ts | 45 -------- packages/opencode/src/control-plane/types.ts | 6 +- .../opencode/src/control-plane/workspace.ts | 26 ++--- packages/opencode/src/plugin/index.ts | 10 +- .../src/server/routes/control/workspace.ts | 18 ++-- .../instance/httpapi/groups/workspace.ts | 14 +-- .../instance/httpapi/handlers/workspace.ts | 8 +- .../httpapi/middleware/workspace-routing.ts | 6 +- packages/opencode/src/server/workspace.ts | 6 +- .../{adaptors.test.ts => adapters.test.ts} | 24 ++--- .../test/control-plane/workspace.test.ts | 102 +++++++++--------- ...ptor.test.ts => workspace-adapter.test.ts} | 4 +- .../server/httpapi-instance-context.test.ts | 8 +- .../test/server/httpapi-session.test.ts | 8 +- .../server/httpapi-workspace-routing.test.ts | 20 ++-- .../test/server/httpapi-workspace.test.ts | 28 ++--- packages/plugin/src/index.ts | 4 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 18 ++-- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +-- packages/sdk/openapi.json | 12 +-- 25 files changed, 225 insertions(+), 225 deletions(-) create mode 100644 packages/opencode/src/control-plane/adapters/index.ts rename packages/opencode/src/control-plane/{adaptors => adapters}/worktree.ts (92%) delete mode 100644 packages/opencode/src/control-plane/adaptors/index.ts rename packages/opencode/test/control-plane/{adaptors.test.ts => adapters.test.ts} (68%) rename packages/opencode/test/plugin/{workspace-adaptor.test.ts => workspace-adapter.test.ts} (96%) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 8eda0595d..99b7f1b15 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -198,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `project` | `bridged` | list, current, git init, update | | `file` | `bridged` partial | find text/file/symbol, list/content/status | | `mcp` | `bridged` | status, add, OAuth, connect/disconnect | -| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore | +| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | | top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | | `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | @@ -290,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t ### Workspace Routes -- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors. +- [x] `GET /experimental/workspace/adapter` - list workspace adapters. - [x] `POST /experimental/workspace` - create workspace. - [x] `GET /experimental/workspace` - list workspaces. - [x] `GET /experimental/workspace/status` - workspace status. diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index c4f976922..e755457e6 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -353,7 +353,7 @@ piecewise. - [ ] `src/cli/cmd/tui/event.ts` - [ ] `src/cli/ui.ts` - [ ] `src/command/index.ts` -- [x] `src/control-plane/adaptors/worktree.ts` +- [x] `src/control-plane/adapters/worktree.ts` - [x] `src/control-plane/types.ts` - [x] `src/control-plane/workspace.ts` - [ ] `src/file/index.ts` diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 009bb74d2..0aa61c313 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" -type Adaptor = { +type Adapter = { type: string name: string description: string @@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const sdk = useSDK() const toast = useToast() const [creating, setCreating] = createSignal() - const [adaptors, setAdaptors] = createSignal() + const [adapters, setAdapters] = createSignal() onMount(() => { dialog.setSize("medium") void (async () => { const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adaptor", sdk.url) + const url = new URL("/experimental/workspace/adapter", sdk.url) if (dir) url.searchParams.set("directory", dir) const res = await sdk .fetch(url) - .then((x) => x.json() as Promise) + .then((x) => x.json() as Promise) .catch(() => undefined) if (!res) { toast.show({ - message: "Failed to load workspace adaptors", + message: "Failed to load workspace adapters", variant: "error", }) return } - setAdaptors(res) + setAdapters(res) })() }) @@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = }, ] } - const list = adaptors() + const list = adapters() if (!list) { return [ { title: "Loading workspaces...", value: "loading" as const, - description: "Fetching available workspace adaptors", + description: "Fetching available workspace adapters", }, ] } diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts new file mode 100644 index 000000000..963e2a2ed --- /dev/null +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -0,0 +1,45 @@ +import type { ProjectID } from "@/project/schema" +import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" +import { WorktreeAdapter } from "./worktree" + +const BUILTIN: Record = { + worktree: WorktreeAdapter, +} + +const state = new Map>() + +export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter { + const custom = state.get(projectID)?.get(type) + if (custom) return custom + + const builtin = BUILTIN[type] + if (builtin) return builtin + + throw new Error(`Unknown workspace adapter: ${type}`) +} + +export async function listAdapters(projectID: ProjectID): Promise { + const builtin = await Promise.all( + Object.entries(BUILTIN).map(async ([type, adapter]) => { + return { + type, + name: adapter.name, + description: adapter.description, + } + }), + ) + const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ + type, + name: adapter.name, + description: adapter.description, + })) + return [...builtin, ...custom] +} + +// Plugins can be loaded per-project so we need to scope them. If you +// want to install a global one pass `ProjectID.global` +export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) { + const adapters = state.get(projectID) ?? new Map() + adapters.set(type, adapter) + state.set(projectID, adapters) +} diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts similarity index 92% rename from packages/opencode/src/control-plane/adaptors/worktree.ts rename to packages/opencode/src/control-plane/adapters/worktree.ts index de9618d30..af8f5d8d4 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" +import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, @@ -13,7 +13,7 @@ async function loadWorktree() { return { AppRuntime, Worktree } } -export const WorktreeAdaptor: WorkspaceAdaptor = { +export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", async configure(info) { diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts deleted file mode 100644 index c91f534b5..000000000 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ProjectID } from "@/project/schema" -import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types" -import { WorktreeAdaptor } from "./worktree" - -const BUILTIN: Record = { - worktree: WorktreeAdaptor, -} - -const state = new Map>() - -export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor { - const custom = state.get(projectID)?.get(type) - if (custom) return custom - - const builtin = BUILTIN[type] - if (builtin) return builtin - - throw new Error(`Unknown workspace adaptor: ${type}`) -} - -export async function listAdaptors(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, adaptor]) => { - return { - type, - name: adaptor.name, - description: adaptor.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({ - type, - name: adaptor.name, - description: adaptor.description, - })) - return [...builtin, ...custom] -} - -// Plugins can be loaded per-project so we need to scope them. If you -// want to install a global one pass `ProjectID.global` -export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { - const adaptors = state.get(projectID) ?? new Map() - adaptors.set(type, adaptor) - state.set(projectID, adaptors) -} diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index af16c0490..7f3aad7ed 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> -export const WorkspaceAdaptorEntry = Schema.Struct({ +export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type WorkspaceAdaptorEntry = Schema.Schema.Type +export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = | { @@ -35,7 +35,7 @@ export type Target = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7f9d078bb..7e4b4a6ff 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -16,7 +16,7 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdaptor } from "./adaptors" +import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" @@ -335,8 +335,8 @@ export const layer = Layer.effect( }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) if (target.type === "local") return @@ -419,8 +419,8 @@ export const layer = Layer.effect( const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -458,9 +458,9 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) - const adaptor = getAdaptor(input.projectID, input.type) + const adapter = getAdapter(input.projectID, input.type) const config = yield* Effect.promise(() => - Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })), + Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })), ) const info: Info = { @@ -496,7 +496,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* Effect.promise(() => adaptor.create(config, env)) + yield* Effect.promise(() => adapter.create(config, env)) yield* Effect.all( [ waitEvent({ @@ -531,8 +531,8 @@ export const layer = Layer.effect( workspaceID: input.workspaceID, }) - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) yield* sync.run(Session.Event.Updated, { sessionID: input.sessionID, @@ -726,12 +726,12 @@ export const layer = Layer.effect( const info = fromRow(row) yield* Effect.catch( Effect.gen(function* () { - const adaptor = getAdaptor(info.projectID, row.type) - yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info))) + const adapter = getAdapter(info.projectID, row.type) + yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info))) }), () => Effect.sync(() => { - log.error("adaptor not available when removing workspace", { type: row.type }) + log.error("adapter not available when removing workspace", { type: row.type }) }), ) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index ac37823c3..95af410ff 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,7 +3,7 @@ import type { PluginInput, Plugin as PluginInstance, PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, + WorkspaceAdapter as PluginWorkspaceAdapter, } from "@opencode-ai/plugin" import { Config } from "@/config/config" import { Bus } from "../bus" @@ -24,8 +24,8 @@ import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" +import { registerAdapter } from "@/control-plane/adapters" +import type { WorkspaceAdapter } from "@/control-plane/types" const log = Log.create({ service: "plugin" }) @@ -138,8 +138,8 @@ export const layer = Layer.effect( worktree: ctx.worktree, directory: ctx.directory, experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + register(type: string, adapter: PluginWorkspaceAdapter) { + registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) }, }, get serverUrl(): URL { diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 08f926d40..21a7810ce 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -2,10 +2,10 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { Effect } from "effect" -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" @@ -18,24 +18,24 @@ const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() .get( - "/adaptor", + "/adapter", describeRoute({ - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - operationId: "experimental.workspace.adaptor.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", + operationId: "experimental.workspace.adapter.list", responses: { 200: { - description: "Workspace adaptors", + description: "Workspace adapters", content: { "application/json": { - schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))), + schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), }, }, }, }, }), async (c) => { - return c.json(await listAdaptors(Instance.project.id)) + return c.json(await listAdapters(Instance.project.id)) }, ) .post( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 268e84f2e..112b8a329 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,5 +1,5 @@ import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -16,7 +16,7 @@ export const SessionRestoreResponse = Schema.Struct({ }) export const WorkspacePaths = { - adaptors: `${root}/adaptor`, + adapters: `${root}/adapter`, list: root, status: `${root}/status`, remove: `${root}/:id`, @@ -27,13 +27,13 @@ export const WorkspaceApi = HttpApi.make("workspace") .add( HttpApiGroup.make("workspace") .add( - HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { - success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"), + HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.adaptor.list", - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", + identifier: "experimental.workspace.adapter.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 4e76a76a3..03e8ee74b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,4 +1,4 @@ -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Effect } from "effect" @@ -10,9 +10,9 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac Effect.gen(function* () { const workspace = yield* Workspace.Service - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () { const instance = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(instance.project.id)) + return yield* Effect.promise(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { @@ -51,7 +51,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac }) return handlers - .handle("adaptors", adaptors) + .handle("adapters", adapters) .handle("list", list) .handle("create", create) .handle("status", status) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 30edbc782..f38c91cce 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -1,4 +1,4 @@ -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" @@ -89,8 +89,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe function resolveTarget(workspace: Workspace.Info): Effect.Effect { return Effect.gen(function* () { - const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) + return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) }) } diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 667e610ab..f75713748 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" @@ -91,8 +91,8 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const adaptor = getAdaptor(workspace.projectID, workspace.type) - const target = await adaptor.target(workspace) + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = await adapter.target(workspace) if (target.type === "local") { return WorkspaceContext.provide({ diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adapters.test.ts similarity index 68% rename from packages/opencode/test/control-plane/adaptors.test.ts rename to packages/opencode/test/control-plane/adapters.test.ts index a8e490226..762bb5d57 100644 --- a/packages/opencode/test/control-plane/adaptors.test.ts +++ b/packages/opencode/test/control-plane/adapters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors" +import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" import { ProjectID } from "../../src/project/schema" import type { WorkspaceInfo } from "../../src/control-plane/types" @@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf } } -function adaptor(dir: string) { +function adapter(dir: string) { return { name: dir, description: dir, @@ -33,19 +33,19 @@ function adaptor(dir: string) { } } -describe("control-plane/adaptors", () => { - test("isolates custom adaptors by project", async () => { +describe("control-plane/adapters", () => { + test("isolates custom adapters by project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(one, type, adaptor("/one")) - registerAdaptor(two, type, adaptor("/two")) + registerAdapter(one, type, adapter("/one")) + registerAdapter(two, type, adapter("/two")) - expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({ + expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({ type: "local", directory: "/one", }) - expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({ + expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({ type: "local", directory: "/two", }) @@ -54,16 +54,16 @@ describe("control-plane/adaptors", () => { test("latest install wins within a project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(id, type, adaptor("/one")) + registerAdapter(id, type, adapter("/one")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/one", }) - registerAdaptor(id, type, adaptor("/two")) + registerAdapter(id, type, adapter("/two")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/two", }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 594789b20..8545aef7f 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -23,10 +23,10 @@ import { EventSequenceTable, EventTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { registerAdaptor } from "../../src/control-plane/adaptors" +import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" -import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types" +import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as WorkspaceOld from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" @@ -53,8 +53,8 @@ type RecordedCreate = { from?: WorkspaceInfo } -type RecordedAdaptor = { - adaptor: WorkspaceAdaptor +type RecordedAdapter = { + adapter: WorkspaceAdapter calls: { configure: WorkspaceInfo[] create: RecordedCreate[] @@ -165,13 +165,13 @@ function eventuallyEffect(effect: Effect.Effect, timeout = 1500) { }) } -function recordedAdaptor(input: { +function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise remove?: (info: WorkspaceInfo) => Promise -}): RecordedAdaptor { - const calls: RecordedAdaptor["calls"] = { +}): RecordedAdapter { + const calls: RecordedAdapter["calls"] = { configure: [], create: [], remove: [], @@ -180,7 +180,7 @@ function recordedAdaptor(input: { return { calls, - adaptor: { + adapter: { name: "recorded", description: "recorded", configure(info) { @@ -207,8 +207,8 @@ function recordedAdaptor(input: { } } -function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { - return recordedAdaptor({ +function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { + return recordedAdapter({ configure(info) { return { ...info, directory: dir } }, @@ -223,8 +223,8 @@ function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info }) } -function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { - return recordedAdaptor({ +function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { + return recordedAdapter({ configure(info) { return { ...info, directory: input?.directory ?? info.directory } }, @@ -429,7 +429,7 @@ describe("workspace-old CRUD", () => { const workspaceID = WorkspaceID.ascending("wrk_create_local") const type = unique("create-local") const targetDir = path.join(dir, "created-local") - const recorded = recordedAdaptor({ + const recorded = recordedAdapter({ configure(info) { return { ...info, @@ -446,7 +446,7 @@ describe("workspace-old CRUD", () => { return { type: "local", directory: targetDir } }, }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ id: workspaceID, @@ -489,17 +489,17 @@ describe("workspace-old CRUD", () => { test("create propagates configure failures and does not insert a workspace", async () => { await withInstance(async () => { const type = unique("configure-failure") - registerAdaptor( + registerAdapter( Instance.project.id, type, - recordedAdaptor({ + recordedAdapter({ configure() { throw new Error("configure exploded") }, target() { return { type: "local", directory: "/unused" } }, - }).adaptor, + }).adapter, ) await expect( @@ -509,10 +509,10 @@ describe("workspace-old CRUD", () => { }) }) - test("create leaves the inserted row when adaptor create fails", async () => { + test("create leaves the inserted row when adapter create fails", async () => { await withInstance(async () => { const type = unique("create-failure") - const recorded = recordedAdaptor({ + const recorded = recordedAdapter({ async create() { throw new Error("create exploded") }, @@ -520,7 +520,7 @@ describe("workspace-old CRUD", () => { return { type: "local", directory: "/unused" } }, }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + registerAdapter(Instance.project.id, type, recorded.adapter) await expect( createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), @@ -538,8 +538,8 @@ describe("workspace-old CRUD", () => { await withInstance(async (dir) => { const type = unique("local-error") const missing = path.join(dir, "missing-local-target") - const recorded = localAdaptor(missing, { createDir: false }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = localAdapter(missing, { createDir: false }) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) @@ -576,8 +576,8 @@ describe("workspace-old CRUD", () => { Effect.gen(function* () { const workspace = yield* WorkspaceOld.Service const type = unique("remote-create") - const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) @@ -603,11 +603,11 @@ describe("workspace-old CRUD", () => { }) }) - test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => { + test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => { await withInstance(async (dir) => { const type = unique("remove-local") - const recorded = localAdaptor(path.join(dir, "remove-local")) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = localAdapter(path.join(dir, "remove-local")) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) @@ -628,21 +628,21 @@ describe("workspace-old CRUD", () => { }) }) - test("remove still deletes the row when the adaptor cannot remove resources", async () => { + test("remove still deletes the row when the adapter cannot remove resources", async () => { await withInstance(async () => { const type = unique("remove-throws") const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) - registerAdaptor( + registerAdapter( Instance.project.id, type, - recordedAdaptor({ + recordedAdapter({ async remove() { throw new Error("remove exploded") }, target() { return { type: "local", directory: "/unused" } }, - }).adaptor, + }).adapter, ) insertWorkspace(info) @@ -661,7 +661,7 @@ describe("workspace-old sync state", () => { const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) attachSessionToWorkspace(session.id, info.id) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) startWorkspaceSyncing(Instance.project.id) await delay(25) @@ -682,8 +682,8 @@ describe("workspace-old sync state", () => { await fs.mkdir(withoutSessionDir, { recursive: true }) insertWorkspace(withSession) insertWorkspace(withoutSession) - registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor) - registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor) + registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) + registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, withSession.id, @@ -707,10 +707,10 @@ describe("workspace-old sync state", () => { const type = unique("missing-local") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor( + registerAdapter( Instance.project.id, type, - localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor, + localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter, ) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, @@ -738,7 +738,7 @@ describe("workspace-old sync state", () => { const target = path.join(dir, "dedupe-local") await fs.mkdir(target, { recursive: true }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(target).adapter) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id, @@ -795,7 +795,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-start") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -850,7 +850,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-connect-fail") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -890,7 +890,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-history-fail") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -947,7 +947,7 @@ describe("workspace-old sync state", () => { const type = unique("history-replay") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter) const session = yield* sessionSvc.create({ title: "before history" }) attachSessionToWorkspace(session.id, info.id) historySessionID = session.id @@ -1014,7 +1014,7 @@ describe("workspace-old sync state", () => { const type = unique("sse-forward") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -1095,7 +1095,7 @@ describe("workspace-old sync state", () => { const type = unique("sse-sync") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) const session = yield* sessionSvc.create({ title: "before sse" }) attachSessionToWorkspace(session.id, info.id) sseSessionID = session.id @@ -1232,7 +1232,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-missing-session") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) await expect( restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), @@ -1273,13 +1273,13 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-remote") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor( + registerAdapter( Instance.project.id, type, - remoteAdaptor(`${url}/restore/?ignored=1#hash`, { + remoteAdapter(`${url}/restore/?ignored=1#hash`, { directory: dir, headers: { authorization: "Bearer restore" }, - }).adaptor, + }).adapter, ) const session = yield* sessionSvc.create({ title: "restore remote" }) replaceSessionEvents(session.id, 24) @@ -1353,7 +1353,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-null-dir") const info = workspaceInfo(Instance.project.id, type, { directory: null }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) const session = yield* sessionSvc.create({ title: "null dir" }) replaceSessionEvents(session.id, 0) @@ -1397,7 +1397,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-remote-fail") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) const session = yield* sessionSvc.create({ title: "restore fail" }) replaceSessionEvents(session.id, 11) @@ -1437,7 +1437,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-local") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) const session = yield* sessionSvc.create({ title: "restore local" }) replaceSessionEvents(session.id, 20) @@ -1488,7 +1488,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-real-events") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) const session = yield* sessionSvc.create({ title: "real events" }) for (let i = 0; i < 3; i++) { const msg = yield* sessionSvc.updateMessage({ diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts similarity index 96% rename from packages/opencode/test/plugin/workspace-adaptor.test.ts rename to packages/opencode/test/plugin/workspace-adapter.test.ts index 677c004be..9abf993d8 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -34,7 +34,7 @@ afterAll(() => { }) describe("plugin.workspace", () => { - it.live("plugin can install a workspace adaptor", () => + it.live("plugin can install a workspace adapter", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const type = `plug-${Math.random().toString(36).slice(2)}` @@ -48,7 +48,7 @@ describe("plugin.workspace", () => { "export default async ({ experimental_workspace }) => {", ` experimental_workspace.register(${JSON.stringify(type)}, {`, ' name: "plug",', - ' description: "plugin workspace adaptor",', + ' description: "plugin workspace adapter",', " configure(input) {", ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, " },", diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 28945f021..9dea20dd6 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -7,8 +7,8 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" @@ -49,7 +49,7 @@ const instanceContextTestLayer = instanceRouterMiddleware .combine(workspaceRouterMiddleware) .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -63,7 +63,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.acquireRelease( Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) const workspace = yield* Workspace.Service return yield* workspace.create({ type: input.type, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 11e9d8b18..5f2af06f1 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -3,8 +3,8 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -82,7 +82,7 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string ) } -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -95,7 +95,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) return yield* Workspace.Service.use((svc) => svc.create({ type: input.type, diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 5d92635fb..b0b276841 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -15,9 +15,9 @@ import * as Socket from "effect/unstable/socket/Socket" import Http from "node:http" import { mkdir } from "node:fs/promises" import path from "node:path" -import { registerAdaptor } from "../../src/control-plane/adaptors" +import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { Project } from "../../src/project/project" @@ -82,7 +82,7 @@ const listenAdditionalServer = (handler: TestHandler) => return HttpServer.formatAddress(server.address) }) -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -93,7 +93,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ target: () => ({ type: "local" as const, directory }), }) -const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({ +const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({ name: "Remote Test", description: "Create a remote test workspace", configure: (info) => ({ ...info, name: "remote-test", directory }), @@ -116,10 +116,10 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { return undefined } -const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) => +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) => Effect.acquireRelease( Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, input.adaptor) + registerAdapter(input.projectID, input.type, input.adapter) const workspace = yield* Workspace.Service return yield* workspace.create({ type: input.type, @@ -144,14 +144,14 @@ const createRemoteWorkspace = (input: { createWorkspace({ projectID: input.projectID, type: input.type, - adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers), + adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers), }) const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => createWorkspace({ projectID: input.projectID, type: input.type, - adaptor: localAdaptor(input.directory), + adapter: localAdapter(input.directory), }) const insertRemoteWorkspaceWithoutSync = (input: { @@ -162,7 +162,7 @@ const insertRemoteWorkspaceWithoutSync = (input: { }) => Effect.sync(() => { const id = WorkspaceID.ascending() - registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url)) + registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) return id }) @@ -237,7 +237,7 @@ describe("HttpApi workspace routing middleware", () => { { status: 201, headers: { "x-remote": "yes" } }, ) }) - // The adaptor target tells the middleware where to proxy selected remote + // The adapter target tells the middleware where to proxy selected remote // workspace requests. Appending /probe to this base should produce // `${remoteUrl}/base/probe` on the fake remote server above. const workspace = yield* createRemoteWorkspace({ diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 6a04833e3..e44a5ee3c 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -4,8 +4,8 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" @@ -36,7 +36,7 @@ function request(path: string, directory: string, init: RequestInit = {}) { }) } -function localAdaptor(directory: string): WorkspaceAdaptor { +function localAdapter(directory: string): WorkspaceAdapter { return { name: "Local Test", description: "Create a local test workspace", @@ -60,7 +60,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } -function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor { +function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", description: "Create a remote test workspace", @@ -137,14 +137,14 @@ describe("workspace HttpApi", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const [adaptors, workspaces, status] = yield* Effect.all([ - request(WorkspacePaths.adaptors, dir), + const [adapters, workspaces, status] = yield* Effect.all([ + request(WorkspacePaths.adapters, dir), request(WorkspacePaths.list, dir), request(WorkspacePaths.status, dir), ]) - expect(adaptors.status).toBe(200) - expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({ + expect(adapters.status).toBe(200) + expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", @@ -163,7 +163,7 @@ describe("workspace HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true const dir = yield* tmpdirScoped({ git: true }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor(project.project.id, "local-test", localAdaptor(path.join(dir, ".workspace"))) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", @@ -201,7 +201,7 @@ describe("workspace HttpApi", () => { const dir = yield* tmpdirScoped({ git: true }) const workspaceDir = path.join(dir, ".workspace-local") const project = yield* Project.use.fromDirectory(dir) - registerAdaptor(project.project.id, "local-target", localAdaptor(workspaceDir)) + registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir)) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, @@ -250,10 +250,10 @@ describe("workspace HttpApi", () => { }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor( + registerAdapter( project.project.id, "remote-target", - remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { "x-target-auth": "secret", }), ) @@ -319,10 +319,10 @@ describe("workspace HttpApi", () => { }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor( + registerAdapter( project.project.id, "remote-session-target", - remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), ) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 71a3278cb..2e96dd980 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -45,7 +45,7 @@ export type WorkspaceTarget = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise @@ -60,7 +60,7 @@ export type PluginInput = { directory: string worktree: string experimental_workspace: { - register(type: string, adaptor: WorkspaceAdaptor): void + register(type: string, adapter: WorkspaceAdapter): void } serverUrl: URL $: BunShell diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2da7c865d..67261d749 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -29,7 +29,7 @@ import type { ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, - ExperimentalWorkspaceAdaptorListResponses, + ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceListResponses, @@ -512,11 +512,11 @@ export class App extends HeyApiClient { } } -export class Adaptor extends HeyApiClient { +export class Adapter extends HeyApiClient { /** - * List workspace adaptors + * List workspace adapters * - * List all available workspace adaptors for the current project. + * List all available workspace adapters for the current project. */ public list( parameters?: { @@ -536,8 +536,8 @@ export class Adaptor extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adaptor", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adapter", ...options, ...params, }) @@ -731,9 +731,9 @@ export class Workspace extends HeyApiClient { }) } - private _adaptor?: Adaptor - get adaptor(): Adaptor { - return (this._adaptor ??= new Adaptor({ client: this.client })) + private _adapter?: Adapter + get adapter(): Adapter { + return (this._adapter ??= new Adapter({ client: this.client })) } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9bb1e50aa..b925ec609 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2430,19 +2430,19 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] -export type ExperimentalWorkspaceAdaptorListData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adaptor" + url: "/experimental/workspace/adapter" } -export type ExperimentalWorkspaceAdaptorListResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Workspace adaptors + * Workspace adapters */ 200: Array<{ type: string @@ -2451,8 +2451,8 @@ export type ExperimentalWorkspaceAdaptorListResponses = { }> } -export type ExperimentalWorkspaceAdaptorListResponse = - ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] export type ExperimentalWorkspaceListData = { body?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 22e66c7d1..cfd8277a3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -415,9 +415,9 @@ ] } }, - "/experimental/workspace/adaptor": { + "/experimental/workspace/adapter": { "get": { - "operationId": "experimental.workspace.adaptor.list", + "operationId": "experimental.workspace.adapter.list", "parameters": [ { "in": "query", @@ -434,11 +434,11 @@ } } ], - "summary": "List workspace adaptors", - "description": "List all available workspace adaptors for the current project.", + "summary": "List workspace adapters", + "description": "List all available workspace adapters for the current project.", "responses": { "200": { - "description": "Workspace adaptors", + "description": "Workspace adapters", "content": { "application/json": { "schema": { @@ -466,7 +466,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } From 16ddf5f559d8c52b23c6db7a046c3fda6a1d71f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 07:57:03 -0400 Subject: [PATCH 012/174] fix(session): use finite archived timestamp schema (#25275) --- packages/opencode/src/session/session.ts | 6 +++--- packages/opencode/test/server/httpapi-bridge.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5593efc97..e1d0c527a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -142,9 +142,9 @@ const Share = Schema.Struct({ url: Schema.String, }) -// Legacy HTTP accepted any number here, and persisted data may already contain -// negative values. Keep archive timestamps permissive while other clocks stay non-negative. -export const ArchivedTimestamp = Schema.Number +// Legacy HTTP accepted negative values here. Keep archive timestamps permissive +// while excluding non-finite values that cannot round-trip through JSON. +export const ArchivedTimestamp = Schema.Finite const Time = Schema.Struct({ created: NonNegativeInt, diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 934332673..a01b7330e 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -258,6 +258,18 @@ describe("HttpApi server", () => { }) }) + test("matches SDK-affecting request schema details", () => { + const effect = effectOpenApi() + const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody + const sessionUpdateSchema = + typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate + ? sessionUpdate.content?.["application/json"]?.schema + : undefined + const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined + const time = sessionUpdateProperties?.time + expect(time?.properties?.archived).toEqual({ type: "number" }) + }) + test("documents event routes as server-sent events", () => { const effect = effectOpenApi() From bcae852d28d08598bc013c8fbca9cb8522704881 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 1 May 2026 11:12:25 -0400 Subject: [PATCH 013/174] zen: remove hardcoded safety identifier --- .../app/src/routes/zen/util/handler.ts | 29 ++++++++++++++----- .../zen/util/provider/openai-compatible.ts | 3 +- .../src/routes/zen/util/provider/openai.ts | 5 +--- .../src/routes/zen/util/provider/provider.ts | 1 - packages/console/core/src/model.ts | 1 - 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index c15197b6e..2f75668e6 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -141,7 +141,10 @@ export async function handler( ) validateModelSettings(billingSource, authInfo) updateProviderKey(authInfo, providerInfo) - logger.metric({ provider: providerInfo.id }) + logger.metric({ + provider: providerInfo.id, + "provider.model": providerInfo.model, + }) const startTimestamp = Date.now() const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream) @@ -149,12 +152,23 @@ export async function handler( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), model: providerInfo.model, - ...providerInfo.payloadModifier, - ...Object.fromEntries( - Object.entries(providerInfo.payloadMappings ?? {}) - .map(([k, v]) => [k, input.request.headers.get(v)]) - .filter(([_k, v]) => !!v), - ), + ...(() => { + const replacer = (obj: Record): Record => + Object.fromEntries( + Object.entries(obj).flatMap(([k, v]) => { + if (Array.isArray(v)) return [[k, v]] + if (typeof v === "object") return [[k, replacer(v)]] + if (v === "$ip") return [[k, ip]] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v.startsWith("$header.")) { + const headerValue = input.request.headers.get(v.slice(8)) + return headerValue ? [[k, headerValue]] : [] + } + return [[k, v]] + }), + ) + return replacer(providerInfo.payloadModifier ?? {}) + })(), }), ) logger.debug("REQUEST URL: " + reqUrl) @@ -514,7 +528,6 @@ export async function handler( reqModel, providerModel: modelProvider.model, adjustCacheUsage: providerProps.adjustCacheUsage, - safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined, workspaceID: authInfo?.workspaceID, } if (format === "anthropic") return anthropicHelper(opts) diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 97b0abc64..e6dedb1a4 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -23,7 +23,7 @@ type Usage = { } } -export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({ +export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ format: "oa-compat", modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { @@ -34,7 +34,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif return { ...body, ...(body.stream ? { stream_options: { include_usage: true } } : {}), - ...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}), } }, createBinaryStreamDecoder: () => undefined, diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index bee1e01ec..5d61a903e 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -18,10 +18,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { headers.set("authorization", `Bearer ${apiKey}`) }, - modifyBody: (body: Record) => ({ - ...body, - ...(workspaceID ? { safety_identifier: workspaceID } : {}), - }), + modifyBody: (body: Record) => body, createBinaryStreamDecoder: () => undefined, streamSeparator: "\n\n", createUsageParser: () => { diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index ffb23f54c..86446bfd8 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -37,7 +37,6 @@ export type ProviderHelper = (input: { reqModel: string providerModel: string adjustCacheUsage?: boolean - safetyIdentifier?: string workspaceID?: string }) => { format: ZenData.Format diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 6281382d6..dc3febe05 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -40,7 +40,6 @@ export namespace ZenData { disabled: z.boolean().optional(), storeModel: z.string().optional(), payloadModifier: z.record(z.string(), z.any()).optional(), - safetyIdentifier: z.boolean().optional(), }), ), }) From 29ec07700c43c18c7fdfb46a594c1c8e4a1d8524 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 1 May 2026 11:15:17 -0500 Subject: [PATCH 014/174] fix: bedrock reasoning issue (#25303) --- packages/opencode/src/session/message-v2.ts | 10 +++++++++- packages/opencode/test/session/message-v2.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a017ead1e..5f97074b2 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -938,10 +938,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { + if (differentModel) { + if (part.text.trim().length > 0) + assistantMessage.parts.push({ + type: "text", + text: part.text, + }) + continue + } assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 89bae246a..afd24e7e1 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -469,6 +469,13 @@ describe("session.message-v2.toModelMessage", () => { }, { ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking", + metadata: { openai: { reasoning: "meta" } }, + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a3"), type: "tool", callID: "call-1", tool: "bash", @@ -495,6 +502,7 @@ describe("session.message-v2.toModelMessage", () => { role: "assistant", content: [ { type: "text", text: "done" }, + { type: "text", text: "thinking" }, { type: "tool-call", toolCallId: "call-1", From 2115df57bf40c1f9a2e5d03502852f874fd82b69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:16:45 +0000 Subject: [PATCH 015/174] Update VOUCHED list https://github.com/anomalyco/opencode/issues/25288#issuecomment-4360290197 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 8618701eb..3f9df695a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -32,6 +32,7 @@ rekram1-node -ricardo-m-l -robinmordasiewicz rubdos +-saisharan0103 spamming ai prs shantur simonklee -spider-yamet clawdbot/llm psychosis, spam pinging the team From c2609cbf046a35ce0013b41f5b3f72532d972ad4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 30 Apr 2026 23:57:27 -0400 Subject: [PATCH 016/174] core: allow agents to access global tmp directory without permission prompts Agents can now create temporary files in the global tmp directory without triggering external_directory permission prompts. This enables agents to freely use temporary storage for intermediate files during builds and other operations. --- packages/core/test/global.test.ts | 16 ++++++++++++++++ packages/opencode/test/agent/agent.test.ts | 20 +++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/global.test.ts diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts new file mode 100644 index 000000000..4e13e8842 --- /dev/null +++ b/packages/core/test/global.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Global } from "@opencode-ai/core/global" + +describe("global paths", () => { + test("tmp path is under the system temp directory", () => { + expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode")) + expect(Global.make().tmp).toBe(Global.Path.tmp) + }) + + test("tmp path is created on module load", async () => { + expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) + }) +}) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index ec384709d..1fc118d0d 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -5,6 +5,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" +import { Global } from "@opencode-ai/core/global" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -83,7 +84,7 @@ test("explore agent denies edit and write", async () => { }) }) -test("explore agent asks for external directories and allows Truncate.GLOB", async () => { +test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() await Instance.provide({ @@ -93,6 +94,9 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( + "allow", + ) }, }) }) @@ -515,6 +519,20 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) }) +test("global tmp directory children are allowed for external_directory", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await load(tmp.path, (svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( + "allow", + ) + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }, + }) +}) + test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ From 6252412d94c91c83bb76f98686f4c987903019e9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 20:03:10 +0000 Subject: [PATCH 017/174] chore: generate --- packages/opencode/test/agent/agent.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1fc118d0d..06bb103f0 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -94,9 +94,9 @@ test("explore agent asks for external directories and allows whitelisted externa expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") }, }) }) @@ -525,9 +525,9 @@ test("global tmp directory children are allowed for external_directory", async ( directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") }, }) From 478156456e92c3db04803953127b4a4af2db064c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 1 May 2026 15:49:14 -0500 Subject: [PATCH 018/174] core: fix npm package detection to properly handle cached directories without installed packages (#25354) --- packages/core/src/npm.ts | 8 ++++++-- packages/core/test/npm.test.ts | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 92e404276..8dac8faf0 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -120,13 +120,17 @@ export const layer = Layer.effect( } })() - if (yield* afs.existsSafe(dir)) { + if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { return resolveEntryPoint(name, path.join(dir, "node_modules", name)) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + if (!first) { + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + if (Option.isSome(result.entrypoint)) return result + return yield* new InstallFailedError({ add: [pkg], dir }) + } return resolveEntryPoint(first.name, first.path) }, Effect.scoped) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 3e94a0869..3d0767aaf 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,7 +1,12 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record) => }), ) +const npmLayer = (cache: string) => + Npm.layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), + Layer.provide(NodeFileSystem.layer), + ) + describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") @@ -29,6 +42,28 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.add", () => { + test("reifies when package cache directory exists without the package installed", async () => { + await using tmp = await tmpdir() + await fs.mkdir(path.join(tmp.path, "fixture-provider")) + await writePackage(path.join(tmp.path, "fixture-provider"), { + name: "fixture-provider", + main: "index.js", + }) + await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + + const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` + await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) + + const entry = await Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(spec) + }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) + + expect(Option.isSome(entry.entrypoint)).toBe(true) + }) +}) + describe("Npm.install", () => { test("respects omit from project .npmrc", async () => { await using tmp = await tmpdir() From 51e310c9ce3faa3dab382222000a001db678cfb3 Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Fri, 1 May 2026 16:14:22 -0700 Subject: [PATCH 019/174] fix(read): prevent unsupported image formats from being sending to provider (#21114) Co-authored-by: Aiden Cline --- packages/opencode/src/tool/read.ts | 7 +++++-- packages/opencode/test/tool/read.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ef33a48de..78436489f 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import DESCRIPTION from "./read.txt" import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" -import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,6 +18,7 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const SAMPLE_BYTES = 4096 +const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) // `offset` and `limit` were originally `z.coerce.number()` — the runtime // coercion was useful when the tool was called from a shell but serves no @@ -220,7 +221,9 @@ export const ReadTool = Tool.define( const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) - if (isImageAttachment(mime) || isPdfAttachment(mime)) { + const isImage = SUPPORTED_IMAGE_MIMES.has(mime) + + if (isImage || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index db6678754..c20b08437 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -440,6 +440,24 @@ root_type Monster;` expect(result.output).toContain("table Monster") }), ) + + it.live("falls through unsupported image mime types to text", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const cases = [ + ["image.bmp", "BM text content"], + ["photo.tiff", "II text content"], + ["photo.avif", "avif text content"], + ] as const + + for (const item of cases) { + yield* put(path.join(dir, item[0]), item[1]) + const result = yield* exec(dir, { filePath: path.join(dir, item[0]) }) + expect(result.attachments).toBeUndefined() + expect(result.output).toContain(item[1]) + } + }), + ) }) describe("tool.read loaded instructions", () => { From cec9c6122af88ed76264f9e899a26fb250943df3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:18:06 -0400 Subject: [PATCH 020/174] Move instance loading into Effect service (#25277) --- .../opencode/src/project/instance-context.ts | 10 + .../opencode/src/project/instance-store.ts | 186 +++++++++++++ packages/opencode/src/project/instance.ts | 147 +--------- .../instance/httpapi/handlers/global.ts | 5 +- .../routes/instance/httpapi/lifecycle.ts | 29 +- .../httpapi/middleware/instance-context.ts | 36 ++- .../server/routes/instance/httpapi/server.ts | 2 + .../opencode/test/project/instance.test.ts | 254 ++++++++++++++++++ .../server/httpapi-instance-context.test.ts | 2 + 9 files changed, 502 insertions(+), 169 deletions(-) create mode 100644 packages/opencode/src/project/instance-context.ts create mode 100644 packages/opencode/src/project/instance-store.ts create mode 100644 packages/opencode/test/project/instance.test.ts diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 000000000..22ceb28b3 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,10 @@ +import { LocalContext } from "@/util/local-context" +import type * as Project from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 000000000..327835ea0 --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,186 @@ +import { GlobalBus } from "@/bus/global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { disposeInstance } from "@/effect/instance-registry" +import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" +import { context, type InstanceContext } from "./instance-context" +import * as Project from "./project" + +export interface LoadInput { + directory: string + init?: () => Promise + worktree?: string + project?: Project.Info +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +interface Entry { + readonly deferred: Deferred.Deferred +} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const scope = yield* Scope.Scope + const cache = new Map() + + const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + const init = input.init + if (init) yield* Effect.promise(() => context.provide(ctx, init)) + return ctx + }) + + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (input: { directory: string; project?: string }) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: input.directory, + project: input.project, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: input.directory, + }, + }, + }), + ) + + const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { + yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => disposeInstance(ctx.directory)) + yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) + }) + + const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) { + if (cache.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = cache.get(directory) + if (existing) return yield* restore(Deferred.await(existing.deferred)) + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("creating instance", { directory }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ) + }) + + const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const previous = cache.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("reloading instance", { directory }) + if (previous) { + yield* Deferred.await(previous.deferred).pipe(Effect.ignore) + yield* Effect.promise(() => disposeInstance(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + } + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ) + }) + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + const entry = cache.get(ctx.directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) + }) + + const disposeAllOnce = Effect.fnUntraced(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + [...cache.entries()], + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, + ) + }) + + const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero) + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + return yield* cachedDisposeAll + }) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return Service.of({ + load, + reload, + dispose, + disposeAll, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) + +export const runtime = makeRuntime(Service, defaultLayer) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 623e88623..69cb74fd6 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,77 +1,20 @@ -import { GlobalBus } from "@/bus/global" -import { disposeInstance } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { iife } from "@/util/iife" -import * as Log from "@opencode-ai/core/util/log" -import { LocalContext } from "@/util/local-context" import * as Project from "./project" -import { WorkspaceContext } from "@/control-plane/workspace-context" +import { context, type InstanceContext } from "./instance-context" +import { InstanceStore } from "./instance-store" -export interface InstanceContext { - directory: string - worktree: string - project: Project.Info -} - -const context = LocalContext.create("instance") -const cache = new Map>() -const project = makeRuntime(Project.Service, Project.defaultLayer) - -const disposal = { - all: undefined as Promise | undefined, -} - -function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await project - .runPromise((svc) => svc.fromDirectory(input.directory)) - .then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) -} - -function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task -} +export type { InstanceContext } from "./instance-context" +export type { LoadInput } from "./instance-store" export const Instance = { + load(input: InstanceStore.LoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(input)) + }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = AppFileSystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), + ) }, get current() { return context.use() @@ -117,74 +60,12 @@ export const Instance = { return context.provide(ctx, fn) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return await next + return InstanceStore.runtime.runPromise((store) => store.reload(input)) }, async dispose() { - const directory = Instance.directory - const project = Instance.project - Log.Default.info("disposing instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - - GlobalBus.emit("event", { - directory, - project: project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) + return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) }, async disposeAll() { - if (disposal.all) return disposal.all - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } - - if (cache.get(key) !== value) continue - - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) - - return disposal.all + return InstanceStore.runtime.runPromise((store) => store.disposeAll()) }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index cd1bebec4..bcad2832e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { Installation } from "@/installation" -import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -68,6 +68,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service + const store = yield* InstanceStore.Service const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -86,7 +87,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) + yield* store.disposeAll() GlobalBus.emit("event", { directory: "global", payload: { type: "global.disposed", properties: {} }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 7b263980c..53d54e2a8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,13 +1,13 @@ -import type { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { WorkspaceRef } from "@/effect/instance-ref" -import { Instance, type InstanceContext } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" type MarkedInstance = { ctx: InstanceContext - workspaceID?: WorkspaceID + store: InstanceStore.Interface + bridge: EffectBridge.Shape } // Disposal is requested by an endpoint handler, but must run from the outer @@ -17,20 +17,9 @@ const disposeAfterResponse = new WeakMap() const mark = (ctx: InstanceContext) => Effect.gen(function* () { - return { ctx, workspaceID: yield* WorkspaceRef } + return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() } }) -// Instance.dispose/reload still publish events through legacy ALS helpers. -// Effect request handlers carry these values in services, so bridge them back -// into the legacy contexts only around the lifecycle operation. -const restoreMarked = (marked: MarkedInstance, fn: () => A) => - Effect.promise(() => - WorkspaceContext.provide({ - workspaceID: marked.workspaceID, - fn: () => Instance.restore(marked.ctx, fn), - }), - ) - export const markInstanceForDisposal = (ctx: InstanceContext) => Effect.gen(function* () { const marked = yield* mark(ctx) @@ -43,11 +32,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => ) }) -export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => +export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) => Effect.gen(function* () { const marked = yield* mark(ctx) return yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response), + Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response), ) }) @@ -58,6 +47,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose())) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index c80f1caeb..1d7d84cbc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,9 +1,8 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" import type { InstanceContext } from "@/project/instance" -import { Filesystem } from "@/util/filesystem" +import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -24,22 +23,23 @@ function decode(input: string): string { } } -function makeInstanceContext(directory: string): Effect.Effect { - return Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(directory)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) +function makeInstanceContext( + store: InstanceStore.Interface, + directory: string, +): Effect.Effect { + return store.load({ + directory: decode(directory), + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) } function provideInstanceContext( effect: Effect.Effect, + store: InstanceStore.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(route.directory) + const ctx = yield* makeInstanceContext(store, route.directory) return yield* effect.pipe( Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, route.workspaceID), @@ -47,9 +47,17 @@ function provideInstanceContext( }) } -export const instanceContextLayer = Layer.succeed( +export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + }), ) -export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) +export const instanceRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return (effect) => provideInstanceContext(effect, store) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e6dedfe2c..783f84ec8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -17,6 +17,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -145,6 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, + InstanceStore.defaultLayer, MCP.defaultLayer, Permission.defaultLayer, Project.defaultLayer, diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 000000000..f9fb6dca4 --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Fiber, Layer } from "effect" +import { registerDisposer } from "../../src/effect/instance-registry" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("InstanceStore", () => { + it.live("loads instance context without installing ALS for the caller", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const ctx = yield* store.load({ directory: dir }) + + expect(ctx.directory).toBe(dir) + expect(ctx.worktree).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("runs load init inside the loaded legacy instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initializedDirectory: string | undefined + + yield* store.load({ + directory: dir, + init: async () => { + initializedDirectory = Instance.directory + }, + }) + + expect(initializedDirectory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("caches loaded instance context by directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initialized = 0 + + const first = yield* store.load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + const second = yield* store.load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + + expect(second).toBe(first) + expect(initialized).toBe(1) + }), + ) + + it.live("dedupes concurrent loads while init is in flight", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let initialized = 0 + + const first = yield* store + .load({ + directory: dir, + init: async () => { + initialized++ + started.resolve() + await release.promise + }, + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => started.promise) + + const second = yield* store + .load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + .pipe(Effect.forkScoped) + + expect(initialized).toBe(1) + release.resolve() + + const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(secondCtx).toBe(firstCtx) + expect(initialized).toBe(1) + }), + ) + + it.live("removes failed loads from the cache", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let attempts = 0 + + const failed = yield* store + .load({ + directory: dir, + init: async () => { + attempts++ + throw new Error("init failed") + }, + }) + .pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) + + expect(failed).toBe(true) + + const ctx = yield* store.load({ + directory: dir, + init: async () => { + attempts++ + }, + }) + + expect(ctx.directory).toBe(dir) + expect(attempts).toBe(2) + }), + ) + + it.live("reload replaces the cached context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + const first = yield* store.load({ directory: dir }) + const second = yield* store.reload({ directory: dir }) + const cached = yield* store.load({ directory: dir }) + + expect(second).not.toBe(first) + expect(cached).toBe(second) + }), + ) + + it.live("stale dispose does not delete an in-flight reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const reloading = Promise.withResolvers() + const releaseReload = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const first = yield* store.load({ directory: dir }) + const reload = yield* store + .reload({ + directory: dir, + init: async () => { + reloading.resolve() + await releaseReload.promise + }, + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => reloading.promise) + const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) + releaseReload.resolve() + + const second = yield* Fiber.join(reload) + yield* Fiber.join(staleDispose) + + expect(disposed).toEqual([dir]) + expect(yield* store.load({ directory: dir })).toBe(second) + }), + ) + + it.live("dedupes concurrent disposeAll calls", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposing = Promise.withResolvers() + const releaseDispose = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + disposing.resolve() + await releaseDispose.promise + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir }) + const first = yield* store.disposeAll().pipe(Effect.forkScoped) + yield* Effect.promise(() => disposing.promise) + const second = yield* store.disposeAll().pipe(Effect.forkScoped) + + expect(disposed).toEqual([dir]) + releaseDispose.resolve() + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(disposed).toEqual([dir]) + }), + ) + + it.live("re-arms disposeAll after completion", () => + Effect.gen(function* () { + const dir1 = yield* tmpdirScoped({ git: true }) + const dir2 = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir1 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1]) + + yield* store.load({ directory: dir2 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1, dir2]) + }), + ) + + it.live("keeps Instance.provide as the legacy ALS wrapper", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 9dea20dd6..6098ad9aa 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -12,6 +12,7 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -40,6 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + InstanceStore.defaultLayer, Project.defaultLayer, Workspace.defaultLayer, ), From 0b498dd4483a408dc2e142bde3d7c6173cd824db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:18:52 -0400 Subject: [PATCH 021/174] fix(httpapi): preserve OpenAPI parameter parity (#25291) --- .../src/server/routes/instance/AGENTS.md | 8 +++ .../server/routes/instance/httpapi/public.ts | 54 ++++++++++++++----- .../test/server/httpapi-bridge.test.ts | 18 ++++++- 3 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/AGENTS.md diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md new file mode 100644 index 000000000..c94fa64af --- /dev/null +++ b/packages/opencode/src/server/routes/instance/AGENTS.md @@ -0,0 +1,8 @@ +# Instance Route Parity + +This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. + +- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. +- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. +- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. +- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 17d6e0d06..c9668336a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -39,6 +39,7 @@ type OpenApiSchema = { maximum?: number minimum?: number oneOf?: OpenApiSchema[] + pattern?: string prefixItems?: OpenApiSchema[] properties?: Record required?: string[] @@ -74,9 +75,18 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record +const PathParameterSchemas = { + sessionID: { type: "string", pattern: "^ses.*" }, + messageID: { type: "string", pattern: "^msg.*" }, + partID: { type: "string", pattern: "^prt.*" }, + permissionID: { type: "string", pattern: "^per.*" }, + ptyID: { type: "string", pattern: "^pty.*" }, +} satisfies Record + const LegacyComponentDescriptions = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", @@ -428,6 +438,11 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { /** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { + if (schema.allOf?.length === 1) { + const [constraint] = schema.allOf + delete schema.allOf + return stripOptionalNull({ ...schema, ...constraint }) + } if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { @@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | } function normalizeParameter(param: OpenApiParameter, route: string) { - if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] - if (override) { - param.schema = override + if (!param.schema || typeof param.schema !== "object") return + if (param.in === "path") { + param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + if (param.in === "query") { + const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + if (override) { + param.schema = override + return + } + if (QueryNumberParameters.has(param.name)) { + param.schema = { type: "number" } + return + } + if (QueryBooleanParameters.has(param.name)) { + param.schema = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + } + return } - return } param.schema = stripOptionalNull(param.schema) } +function pathParameterSchema(route: string, name: string) { + if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } + if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } + return undefined +} + export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index a01b7330e..2b8a62cc5 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -119,7 +119,23 @@ type RequestBody = { function parameterKey(param: unknown): string | undefined { if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined if (typeof param.in !== "string" || typeof param.name !== "string") return undefined - return `${param.in}:${param.name}:${"required" in param && param.required === true}` + return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( + "schema" in param ? param.schema : undefined, + )}` +} + +function stableSchema(input: unknown): string { + return JSON.stringify(sortSchema(input)) +} + +function sortSchema(input: unknown): unknown { + if (Array.isArray(input)) return input.map(sortSchema) + if (!input || typeof input !== "object") return input + return Object.fromEntries( + Object.entries(input) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, sortSchema(value)]), + ) } function parameterSchema(input: { From d297c29f2276f8e0d4389c8af38b5aad504d3ee1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 02:19:48 +0000 Subject: [PATCH 022/174] chore: generate --- packages/opencode/src/project/instance.ts | 5 ++--- .../routes/instance/httpapi/middleware/instance-context.ts | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 69cb74fd6..aa4f48c56 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -11,9 +11,8 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(input)) }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide( - await Instance.load({ directory: input.directory, init: input.init }), - async () => input.fn(), + return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => + input.fn(), ) }, get current() { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 1d7d84cbc..4bb15cd3c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -23,10 +23,7 @@ function decode(input: string): string { } } -function makeInstanceContext( - store: InstanceStore.Interface, - directory: string, -): Effect.Effect { +function makeInstanceContext(store: InstanceStore.Interface, directory: string): Effect.Effect { return store.load({ directory: decode(directory), init: () => AppRuntime.runPromise(InstanceBootstrap), From 160928a9a9ca41e09e907a6001a7041f5dee681b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:42:03 -0400 Subject: [PATCH 023/174] Extract InstanceStore.provide helper (#25372) --- packages/opencode/src/project/instance-store.ts | 6 ++++++ .../httpapi/middleware/instance-context.ts | 17 ++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 327835ea0..7abb0bb7e 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,5 +1,6 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -19,6 +20,7 @@ export interface Interface { readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -168,6 +170,9 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) return Service.of({ @@ -175,6 +180,7 @@ export const layer: Layer.Layer = Layer.effect( reload, dispose, disposeAll, + provide, }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 4bb15cd3c..bf0093bd2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,7 +1,6 @@ -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import type { InstanceContext } from "@/project/instance" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -23,23 +22,15 @@ function decode(input: string): string { } } -function makeInstanceContext(store: InstanceStore.Interface, directory: string): Effect.Effect { - return store.load({ - directory: decode(directory), - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) -} - function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(store, route.directory) - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, route.workspaceID), + return yield* store.provide( + { directory: decode(route.directory), init: () => AppRuntime.runPromise(InstanceBootstrap) }, + effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) } From 15719330965567b31129cda0b6618a7af2924f9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 23:06:22 -0400 Subject: [PATCH 024/174] Drop ALS fallbacks from containsPath and workspace routing (#25374) --- packages/opencode/src/config/config.ts | 3 +-- packages/opencode/src/project/instance.ts | 11 ++++---- .../httpapi/middleware/workspace-routing.ts | 11 +------- .../opencode/test/file/path-traversal.test.ts | 26 +++++++++---------- 4 files changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 44841fe6f..bfc3567bf 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -23,7 +23,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { InstanceRef } from "@/effect/instance-ref" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -459,7 +458,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + if (Instance.containsPath(source, ctx)) return "local" return "global" }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index aa4f48c56..af7672872 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -30,16 +30,15 @@ export const Instance = { /** * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. + * Returns true if path is inside ctx.directory OR ctx.worktree. * Paths within the worktree but outside the working directory should not trigger external_directory permission. */ - containsPath(filepath: string, ctx?: InstanceContext) { - const instance = ctx ?? Instance - if (AppFileSystem.contains(instance.directory, filepath)) return true + containsPath(filepath: string, ctx: InstanceContext) { + if (AppFileSystem.contains(ctx.directory, filepath)) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. - if (instance.worktree === "/") return false - return AppFileSystem.contains(instance.worktree, filepath) + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) }, /** * Captures the current instance ALS context and returns a wrapper that diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index f38c91cce..c8762bae6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -2,7 +2,6 @@ import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" -import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/fence" @@ -43,14 +42,6 @@ export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< } >()("@opencode/ExperimentalHttpApiWorkspaceRouting") {} -function currentDirectory(): string { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - function requestURL(request: HttpServerRequest.HttpServerRequest): URL { return new URL(request.url, "http://localhost") } @@ -65,7 +56,7 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp } function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { - return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory() + return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd() } function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index a52af7023..2d306f60b 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -128,8 +128,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -143,11 +143,11 @@ describe("Instance.containsPath", () => { directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path)).toBe(true) + expect(Instance.containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -158,8 +158,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other-project")).toBe(false) + expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(Instance.containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -170,7 +170,7 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false) + expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -182,8 +182,8 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) + expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -195,9 +195,9 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other")).toBe(false) + expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(Instance.containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) From f33aec1139afa5e9741cc19e9f2d1b60558d4861 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 00:02:52 -0400 Subject: [PATCH 025/174] Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376) --- packages/opencode/src/cli/bootstrap.ts | 2 - packages/opencode/src/cli/cmd/tui/worker.ts | 2 - packages/opencode/src/config/config.ts | 3 +- packages/opencode/src/file/index.ts | 6 +- packages/opencode/src/lsp/lsp.ts | 9 +- packages/opencode/src/project/bootstrap.ts | 92 +++++++++++++------ .../opencode/src/project/instance-context.ts | 14 +++ .../opencode/src/project/instance-store.ts | 87 ++++++++++-------- packages/opencode/src/project/instance.ts | 53 +++++++---- .../instance/httpapi/handlers/project.ts | 2 - .../httpapi/middleware/instance-context.ts | 10 +- .../server/routes/instance/httpapi/server.ts | 10 ++ .../src/server/routes/instance/middleware.ts | 2 - .../src/server/routes/instance/project.ts | 2 - packages/opencode/src/server/workspace.ts | 2 - packages/opencode/src/tool/bash.ts | 6 +- .../opencode/src/tool/external-directory.ts | 4 +- packages/opencode/src/worktree/index.ts | 2 - .../opencode/test/file/path-traversal.test.ts | 29 +++--- .../opencode/test/project/instance.test.ts | 37 ++++---- .../server/httpapi-instance-context.test.ts | 2 + 21 files changed, 223 insertions(+), 153 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 2604e703e..3190fda62 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,11 +1,9 @@ import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "../project/bootstrap" import { Instance } from "../project/instance" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { try { const result = await cb() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index adb7453a7..8b62c5038 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,7 +2,6 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,7 +76,6 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { await upgrade().catch(() => {}) }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bfc3567bf..9e9a6e381 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -23,6 +23,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -458,7 +459,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (Instance.containsPath(source, ctx)) return "local" + if (containsPath(source, ctx)) return "local" return "global" }) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4a474881c..4dd6a3ae7 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort" import ignore from "ignore" import path from "path" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -507,7 +507,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full, ctx)) { + if (!containsPath(full, ctx)) { throw new Error("Access denied: path escapes project directory") } @@ -587,7 +587,7 @@ export const layer = Layer.effect( } const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory - if (!Instance.containsPath(resolved, ctx)) { + if (!containsPath(resolved, ctx)) { throw new Error("Access denied: path escapes project directory") } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5fcff772e..5110eccbf 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -12,7 +12,7 @@ import { Process } from "@/util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" @@ -221,12 +221,7 @@ export const layer = Layer.effect( const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context - if ( - !AppFileSystem.contains(ctx.directory, file) && - (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file)) - ) { - return [] as LSPClient.Info[] - } + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ae52ac550..9f77de2d4 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -8,37 +8,71 @@ import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" -import * as Log from "@opencode-ai/core/util/log" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import * as Effect from "effect/Effect" +import { Context, Effect, Layer } from "effect" import { Config } from "@/config/config" -export const InstanceBootstrap = Effect.gen(function* () { - const ctx = yield* InstanceState.context - Log.Default.info("bootstrapping", { directory: ctx.directory }) - // everything depends on config so eager load it for nice traces - yield* Config.Service.use((svc) => svc.get()) - // Plugin can mutate config so it has to be initialized before anything else. - yield* Plugin.Service.use((svc) => svc.init()) - yield* Effect.all( - [ - LSP.Service, - ShareNext.Service, - Format.Service, - File.Service, - FileWatcher.Service, - Vcs.Service, - Snapshot.Service, - ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), - ).pipe(Effect.withSpan("InstanceBootstrap.init")) +export interface Interface { + readonly run: Effect.Effect +} - const projectID = ctx.project.id - yield* Bus.Service.use((svc) => - svc.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }), - ) -}).pipe(Effect.withSpan("InstanceBootstrap")) +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + // Yield each bootstrap dep at layer init so `run` itself has R = never. + // This breaks the circular declaration loop through Config → Instance → InstanceStore + // (instance-store.ts only yields this Service tag, never the impl-side services). + const bus = yield* Bus.Service + const config = yield* Config.Service + const file = yield* File.Service + const fileWatcher = yield* FileWatcher.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const plugin = yield* Plugin.Service + const shareNext = yield* ShareNext.Service + const snapshot = yield* Snapshot.Service + const vcs = yield* Vcs.Service + + const run = Effect.gen(function* () { + const ctx = yield* InstanceState.context + yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + // everything depends on config so eager load it for nice traces + yield* config.get() + // Plugin can mutate config so it has to be initialized before anything else. + yield* plugin.init() + yield* Effect.all( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + ).pipe(Effect.withSpan("InstanceBootstrap.init")) + + const projectID = ctx.project.id + yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { + if (payload.properties.name === Command.Default.INIT) { + Project.setInitialized(projectID) + } + }) + }).pipe(Effect.withSpan("InstanceBootstrap")) + + return Service.of({ run }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide([ + Bus.layer, + Config.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Plugin.defaultLayer, + Project.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, + Vcs.defaultLayer, + ]), +) + +export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts index 22ceb28b3..b281f492d 100644 --- a/packages/opencode/src/project/instance-context.ts +++ b/packages/opencode/src/project/instance-context.ts @@ -1,4 +1,5 @@ import { LocalContext } from "@/util/local-context" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import type * as Project from "./project" export interface InstanceContext { @@ -8,3 +9,16 @@ export interface InstanceContext { } export const context = LocalContext.create("instance") + +/** + * Check if a path is within the project boundary. + * Returns true if path is inside ctx.directory OR ctx.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ +export function containsPath(filepath: string, ctx: InstanceContext): boolean { + if (AppFileSystem.contains(ctx.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 7abb0bb7e..74df60ada 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -5,22 +5,29 @@ import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" -import { context, type InstanceContext } from "./instance-context" +import { type InstanceContext } from "./instance-context" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - init?: () => Promise + /** + * Additional setup to run after the default InstanceBootstrap. + * Mainly used by tests for env-var setup or file writes that need the instance ALS context. + */ + init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect + readonly provide: ( + input: LoadInput, + effect: Effect.Effect, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -36,25 +43,25 @@ export const layer: Layer.Layer = Layer.effect( const scope = yield* Scope.Scope const cache = new Map() - const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : yield* project.fromDirectory(input.directory).pipe( - Effect.map((result) => ({ + const boot = (input: LoadInput & { directory: string }) => + Effect.gen(function* () { + const ctx: InstanceContext = + input.project && input.worktree + ? { directory: input.directory, - worktree: result.sandbox, - project: result.project, - })), - ) - const init = input.init - if (init) yield* Effect.promise(() => context.provide(ctx, init)) - return ctx - }) + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) const removeEntry = (directory: string, entry: Entry) => Effect.sync(() => { @@ -63,11 +70,12 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) { - const exit = yield* Effect.exit(boot({ ...input, directory })) - if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) - yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) - }) + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) const emitDisposed = (input: { directory: string; project?: string }) => Effect.sync(() => @@ -98,9 +106,9 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) - return yield* Effect.uninterruptibleMask((restore) => + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const existing = cache.get(directory) if (existing) return yield* restore(Deferred.await(existing.deferred)) @@ -113,12 +121,12 @@ export const layer: Layer.Layer = Layer.effect( }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) }), - ) - }) + ).pipe(Effect.withSpan("InstanceStore.load")) + } - const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) - return yield* Effect.uninterruptibleMask((restore) => + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const previous = cache.get(directory) const entry: Entry = { deferred: Deferred.makeUnsafe() } @@ -134,8 +142,8 @@ export const layer: Layer.Layer = Layer.effect( }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) }), - ) - }) + ).pipe(Effect.withSpan("InstanceStore.reload")) + } const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { const entry = cache.get(ctx.directory) @@ -170,7 +178,10 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) - const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = ( + input: LoadInput, + effect: Effect.Effect, + ): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index af7672872..549df4b75 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,4 +1,5 @@ -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect } from "effect" +import { InstanceRef } from "@/effect/instance-ref" import * as Project from "./project" import { context, type InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" @@ -6,13 +7,37 @@ import { InstanceStore } from "./instance-store" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" +type LegacyLoadInput = { + directory: string + init?: () => Promise + project?: Project.Info + worktree?: string +} + +// Promise-style legacy inits often read Instance.directory etc. from the ALS context. +// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep +// legacy inits working without forcing every test to convert, bind ALS around the +// Promise call here using the instance ctx that the store provides via InstanceRef. +const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => { + const { init, ...rest } = input + if (!init) return rest + return { + ...rest, + init: Effect.gen(function* () { + const ctx = yield* InstanceRef + yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init())) + }), + } +} + export const Instance = { - load(input: InstanceStore.LoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(input)) + load(input: LegacyLoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) }, - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), ) }, get current() { @@ -28,18 +53,6 @@ export const Instance = { return context.use().project }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside ctx.directory OR ctx.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string, ctx: InstanceContext) { - if (AppFileSystem.contains(ctx.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (ctx.worktree === "/") return false - return AppFileSystem.contains(ctx.worktree, filepath) - }, /** * Captures the current instance ALS context and returns a wrapper that * restores it when called. Use this for callbacks that fire outside the @@ -57,8 +70,8 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - return InstanceStore.runtime.runPromise((store) => store.reload(input)) + async reload(input: LegacyLoadInput) { + return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input))) }, async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index ae2761ac3..3c1dd350d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -1,6 +1,5 @@ import { AppRuntime } from "@/effect/app-runtime" import * as InstanceState from "@/effect/instance-state" -import { InstanceBootstrap } from "@/project/bootstrap" import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Effect } from "effect" @@ -29,7 +28,6 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", directory: ctx.directory, worktree: ctx.directory, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return next }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index bf0093bd2..0e82da31b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,5 +1,4 @@ import { WorkspaceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" @@ -25,11 +24,12 @@ function decode(input: string): string { function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, + bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext return yield* store.provide( - { directory: decode(route.directory), init: () => AppRuntime.runPromise(InstanceBootstrap) }, + { directory: decode(route.directory), init: bootstrap.run }, effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) @@ -39,13 +39,15 @@ export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { const store = yield* InstanceStore.Service - return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + const bootstrap = yield* InstanceBootstrap.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const store = yield* InstanceStore.Service - return (effect) => provideInstanceContext(effect, store) + const bootstrap = yield* InstanceBootstrap.Service + return (effect) => provideInstanceContext(effect, store, bootstrap) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 783f84ec8..3ac0298c6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -11,13 +11,16 @@ import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" +import { FileWatcher } from "@/file/watcher" import { Ripgrep } from "@/file/ripgrep" import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" +import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -32,7 +35,9 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" +import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" +import { Snapshot } from "@/snapshot" import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" @@ -143,12 +148,15 @@ export function createRoutes(corsOptions?: CorsOptions) { Command.defaultLayer, Config.defaultLayer, File.defaultLayer, + FileWatcher.defaultLayer, Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, MCP.defaultLayer, Permission.defaultLayer, + Plugin.defaultLayer, Project.defaultLayer, ProviderAuth.defaultLayer, Provider.defaultLayer, @@ -163,6 +171,8 @@ export function createRoutes(corsOptions?: CorsOptions) { SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, SyncEvent.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 19918b8b4..622d6296f 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -24,7 +23,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index b9f86b183..14c8c87b0 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -7,7 +7,6 @@ import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" @@ -86,7 +85,6 @@ export const ProjectRoutes = lazy(() => directory: dir, worktree: dir, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return c.json(next) }, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index f75713748..06930d07c 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,7 +5,6 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" @@ -100,7 +99,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware fn: () => Instance.provide({ directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index fe3e45d66..bf0008250 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,7 +6,7 @@ import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" -import { Instance, type InstanceContext } from "../project/instance" +import { containsPath, type InstanceContext } from "../project/instance-context" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -386,7 +386,7 @@ export const BashTool = Tool.define( for (const arg of pathArgs(command, ps)) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved, instance)) continue + if (!resolved || containsPath(resolved, instance)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) } @@ -612,7 +612,7 @@ export const BashTool = Tool.define( Effect.sync(() => tree.delete()), ) const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 0dd9a1af3..23d416b53 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import type * as Tool from "./tool" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import { AppFileSystem } from "@opencode-ai/core/filesystem" type Kind = "file" | "directory" @@ -24,7 +24,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec const ins = yield* InstanceState.context const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target - if (Instance.containsPath(full, ins)) return + if (containsPath(full, ins)) return const kind = options?.kind ?? "file" const dir = kind === "directory" ? full : path.dirname(full) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6a7ccb961..2e9b6736f 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -2,7 +2,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -255,7 +254,6 @@ export const layer: Layer.Layer< const booted = yield* Effect.promise(() => Instance.provide({ directory: info.directory, - init: () => BootstrapRuntime.runPromise(InstanceBootstrap), fn: () => undefined, }) .then(() => true) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 2d306f60b..3a5ce2323 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -121,15 +122,15 @@ describe("File.list path traversal protection", () => { }) }) -describe("Instance.containsPath", () => { +describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -143,11 +144,11 @@ describe("Instance.containsPath", () => { directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path, Instance.current)).toBe(true) + expect(containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -158,8 +159,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(Instance.containsPath("/tmp/other-project", Instance.current)).toBe(false) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -170,7 +171,7 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -182,8 +183,8 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -195,9 +196,9 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(Instance.containsPath("/tmp/other", Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f9fb6dca4..2e3da29a7 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Fiber, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" @@ -26,7 +27,7 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init inside the loaded legacy instance context", () => + it.live("runs load init with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service @@ -34,9 +35,9 @@ describe("InstanceStore", () => { yield* store.load({ directory: dir, - init: async () => { - initializedDirectory = Instance.directory - }, + init: Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory + }), }) expect(initializedDirectory).toBe(dir) @@ -52,15 +53,15 @@ describe("InstanceStore", () => { const first = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) const second = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) expect(second).toBe(first) @@ -79,11 +80,11 @@ describe("InstanceStore", () => { const first = yield* store .load({ directory: dir, - init: async () => { + init: Effect.promise(async () => { initialized++ started.resolve() await release.promise - }, + }), }) .pipe(Effect.forkScoped) @@ -92,9 +93,9 @@ describe("InstanceStore", () => { const second = yield* store .load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) .pipe(Effect.forkScoped) @@ -116,10 +117,10 @@ describe("InstanceStore", () => { const failed = yield* store .load({ directory: dir, - init: async () => { + init: Effect.sync(() => { attempts++ throw new Error("init failed") - }, + }), }) .pipe( Effect.as(false), @@ -130,9 +131,9 @@ describe("InstanceStore", () => { const ctx = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { attempts++ - }, + }), }) expect(ctx.directory).toBe(dir) @@ -170,10 +171,10 @@ describe("InstanceStore", () => { const reload = yield* store .reload({ directory: dir, - init: async () => { + init: Effect.promise(async () => { reloading.resolve() await releaseReload.promise - }, + }), }) .pipe(Effect.forkScoped) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 6098ad9aa..15d3facd3 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,6 +11,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -41,6 +42,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, Project.defaultLayer, Workspace.defaultLayer, From becf57ee6a704e62d7075630852e9c17dfbb110a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 04:03:59 +0000 Subject: [PATCH 026/174] chore: generate --- .../opencode/src/project/instance-store.ts | 5 +- packages/opencode/src/project/instance.ts | 5 +- packages/sdk/js/src/v2/gen/types.gen.ts | 128 +++---- packages/sdk/openapi.json | 360 +++++++++--------- 4 files changed, 247 insertions(+), 251 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 74df60ada..e96c421a7 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -178,10 +178,7 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) - const provide = ( - input: LoadInput, - effect: Effect.Effect, - ): Effect.Effect => + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 549df4b75..5c7f10263 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -35,9 +35,8 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide( - await Instance.load({ directory: input.directory, init: input.init }), - async () => input.fn(), + return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => + input.fn(), ) }, get current() { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b925ec609..e46f8e04f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -33,13 +33,6 @@ export type EventProjectUpdated = { properties: Project } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -54,18 +47,10 @@ export type EventGlobalDisposed = { } } -export type EventFileEdited = { - type: "file.edited" +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" + directory: string } } @@ -230,6 +215,53 @@ export type EventInstallationUpdateAvailable = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -452,38 +484,6 @@ export type EventVcsBranchUpdated = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1112,11 +1112,9 @@ export type GlobalEvent = { workspace?: string payload: | EventProjectUpdated - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated + | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1126,6 +1124,12 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1141,10 +1145,6 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2055,11 +2055,9 @@ export type File = { export type Event = | EventProjectUpdated - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated + | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2069,6 +2067,12 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2084,10 +2088,6 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index cfd8277a3..930fd8c92 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7674,25 +7674,6 @@ }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.instance.disposed" - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] - } - }, - "required": ["type", "properties"] - }, "Event.server.connected": { "type": "object", "properties": { @@ -7721,44 +7702,21 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { + "Event.server.instance.disposed": { "type": "object", "properties": { "type": { "type": "string", - "const": "file.edited" + "const": "server.instance.disposed" }, "properties": { "type": "object", "properties": { - "file": { + "directory": { "type": "string" } }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] + "required": ["directory"] } }, "required": ["type", "properties"] @@ -8225,6 +8183,144 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "QuestionOption": { "type": "object", "properties": { @@ -8743,102 +8839,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10960,9 +10960,6 @@ { "$ref": "#/components/schemas/Event.project.updated" }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -10970,10 +10967,7 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -11002,6 +10996,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11047,18 +11059,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,9 +13253,6 @@ { "$ref": "#/components/schemas/Event.project.updated" }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -13263,10 +13260,7 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13295,6 +13289,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13340,18 +13352,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From d99dde6306882799d105439473e2ec07803fc8a5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 09:07:59 -0400 Subject: [PATCH 027/174] Migrate test inits from Promise to Effect (#25377) --- packages/opencode/src/effect/run-service.ts | 25 ++- packages/opencode/src/project/instance.ts | 40 +--- .../opencode/test/effect/run-service.test.ts | 40 ++++ .../opencode/test/project/instance.test.ts | 18 ++ .../test/provider/amazon-bedrock.test.ts | 40 ++-- .../opencode/test/provider/provider.test.ts | 196 +++++++++--------- 6 files changed, 199 insertions(+), 160 deletions(-) diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 28f1068c3..1f3802e80 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util/local-context" @@ -24,15 +24,20 @@ export function attachWith(effect: Effect.Effect, refs: Refs): } export function attach(effect: Effect.Effect): Effect.Effect { - try { - return attachWith(effect, { - instance: Instance.current, - workspace: WorkspaceContext.workspaceID, - }) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - return effect + const workspace = WorkspaceContext.workspaceID + const instance = (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) + const fiber = Fiber.getCurrent() + return attachWith(effect, { + instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), + }) } export function makeRuntime(service: Context.Service, layer: Layer.Layer) { diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 5c7f10263..662b61bb0 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,42 +1,18 @@ import { Effect } from "effect" -import { InstanceRef } from "@/effect/instance-ref" -import * as Project from "./project" import { context, type InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" -type LegacyLoadInput = { - directory: string - init?: () => Promise - project?: Project.Info - worktree?: string -} - -// Promise-style legacy inits often read Instance.directory etc. from the ALS context. -// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep -// legacy inits working without forcing every test to convert, bind ALS around the -// Promise call here using the instance ctx that the store provides via InstanceRef. -const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => { - const { init, ...rest } = input - if (!init) return rest - return { - ...rest, - init: Effect.gen(function* () { - const ctx = yield* InstanceRef - yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init())) - }), - } -} - export const Instance = { - load(input: LegacyLoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) + load(input: InstanceStore.LoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(input)) }, - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), ) }, get current() { @@ -69,8 +45,8 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: LegacyLoadInput) { - return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input))) + async reload(input: InstanceStore.LoadInput) { + return InstanceStore.runtime.runPromise((store) => store.reload(input)) }, async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index e9baf8853..16538bb8a 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -1,9 +1,12 @@ import { expect } from "bun:test" import { Effect, Layer, Context } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" +import { ProjectID } from "../../src/project/schema" import { it } from "../lib/effect" class Shared extends Context.Service()("@test/Shared") {} +const testDirectory = "/tmp/opencode-test" it.live("makeRuntime shares dependent layers through the shared memo map", () => Effect.gen(function* () { @@ -47,3 +50,40 @@ it.live("makeRuntime shares dependent layers through the shared memo map", () => expect(n).toBe(1) }), ) + +it.live("makeRuntime inherits InstanceRef from the current fiber", () => + Effect.gen(function* () { + class NeedsInstance extends Context.Service< + NeedsInstance, + { readonly directory: () => Effect.Effect } + >()("@test/NeedsInstance") {} + + const runtime = makeRuntime( + NeedsInstance, + Layer.succeed( + NeedsInstance, + NeedsInstance.of({ + directory: () => + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + }), + ), + ) + + const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory())) + + expect(actual).toBe(testDirectory) + }).pipe( + Effect.provideService(InstanceRef, { + directory: testDirectory, + worktree: testDirectory, + project: { + id: ProjectID.global, + worktree: testDirectory, + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), + ), +) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 2e3da29a7..b223bf91d 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -252,4 +252,22 @@ describe("InstanceStore", () => { expect(() => Instance.current).toThrow() }), ) + + it.live("does not install legacy ALS around Effect init", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + init: Effect.sync(() => { + expect(() => Instance.current).toThrow() + }), + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + }), + ) }) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 88c67aa6d..43b23dafa 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -45,10 +45,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -70,10 +70,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -125,11 +125,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -171,10 +171,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -203,9 +203,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -236,12 +236,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -279,9 +279,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -316,9 +316,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -352,9 +352,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -388,9 +388,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d0aa299e7..edbf4d664 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -82,9 +82,9 @@ test("provider loaded from env variable", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -137,9 +137,9 @@ test("disabled_providers excludes provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() @@ -161,10 +161,10 @@ test("enabled_providers restricts to only listed providers", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -191,9 +191,9 @@ test("model whitelist filters models for provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -222,9 +222,9 @@ test("model blacklist excludes specific models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -257,9 +257,9 @@ test("custom model alias via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -394,9 +394,9 @@ test("env variable takes precedence, config merges options", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "env-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -420,9 +420,9 @@ test("getModel returns model for valid provider/model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() @@ -447,9 +447,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, @@ -500,9 +500,9 @@ test("defaultModel returns first available model when no config set", async () = }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await defaultModel() expect(model.providerID).toBeDefined() @@ -525,9 +525,9 @@ test("defaultModel respects config model setting", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") @@ -640,9 +640,9 @@ test("model options are merged from existing model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -669,9 +669,9 @@ test("provider removed when all models filtered out", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() @@ -692,9 +692,9 @@ test("closest finds model by partial match", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() @@ -747,9 +747,9 @@ test("getModel uses realIdByKey for aliased models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -862,9 +862,9 @@ test("model inherits properties from existing database model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -890,9 +890,9 @@ test("disabled_providers prevents loading even with env var", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() @@ -914,10 +914,10 @@ test("enabled_providers with empty array allows no providers", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) @@ -944,9 +944,9 @@ test("whitelist and blacklist can be combined", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1053,9 +1053,9 @@ test("getSmallModel returns appropriate small model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() @@ -1078,9 +1078,9 @@ test("getSmallModel respects config small_model override", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() @@ -1126,10 +1126,10 @@ test("multiple providers can be configured simultaneously", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1205,9 +1205,9 @@ test("model alias name defaults to alias key when id differs", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") @@ -1245,9 +1245,9 @@ test("provider with multiple env var options only includes apiKey when single en }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("MULTI_ENV_KEY_1", "test-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() @@ -1287,9 +1287,9 @@ test("provider with single env var includes apiKey automatically", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("SINGLE_ENV_KEY", "my-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() @@ -1324,9 +1324,9 @@ test("model cost overrides existing cost values", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -1403,11 +1403,11 @@ test("disabled_providers and enabled_providers interaction", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed @@ -1561,10 +1561,10 @@ test("provider env fallback - second env var used if first missing", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Provider should load because fallback env var is set @@ -1586,9 +1586,9 @@ test("getModel returns consistent results", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) @@ -1647,9 +1647,9 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet @@ -1675,9 +1675,9 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic @@ -1723,9 +1723,9 @@ test("getProvider returns provider info", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() @@ -1747,9 +1747,9 @@ test("closest returns undefined when no partial match found", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() @@ -1770,9 +1770,9 @@ test("closest checks multiple query terms in order", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) @@ -1842,9 +1842,9 @@ test("provider options are deeply merged", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Custom options should be merged @@ -1880,9 +1880,9 @@ test("custom model inherits npm package from models.dev provider config", async }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] @@ -1915,9 +1915,9 @@ test("custom model inherits api.url from models.dev provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENROUTER_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2048,9 +2048,9 @@ test("model variants are generated for reasoning models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Claude sonnet 4 has reasoning capability @@ -2086,9 +2086,9 @@ test("model variants can be disabled via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2129,9 +2129,9 @@ test("model variants can be customized via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2168,9 +2168,9 @@ test("disabled key is stripped from variant config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2206,9 +2206,9 @@ test("all variants can be disabled via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2244,9 +2244,9 @@ test("variant config merges with generated variants", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2282,9 +2282,9 @@ test("variants filtered in second pass for database models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] @@ -2386,9 +2386,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() @@ -2431,9 +2431,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2457,11 +2457,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() @@ -2489,11 +2489,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() @@ -2592,10 +2592,10 @@ test("plugin config enabled and disabled providers are honored", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() From a849812e9f2ea3089cea45673ec10ecc80d93136 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 13:09:14 +0000 Subject: [PATCH 028/174] chore: generate --- packages/opencode/src/project/instance.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 662b61bb0..afc07ad26 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -10,9 +10,8 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(input)) }, async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - return context.provide( - await Instance.load({ directory: input.directory, init: input.init }), - async () => input.fn(), + return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => + input.fn(), ) }, get current() { From 075f876e6fbaf3e02223e1add69d8b8e2901d5af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 09:35:39 -0400 Subject: [PATCH 029/174] fix(httpapi): re-land workspace create payload accepts missing extra (#25412) --- .../instance/httpapi/groups/workspace.ts | 5 +- .../instance/httpapi/handlers/workspace.ts | 1 + .../test/server/httpapi-workspace.test.ts | 53 ++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 112b8a329..08e9e044b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -9,7 +9,10 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const CreatePayload = Schema.Struct({ + ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), + extra: Schema.optional(Workspace.CreateInput.fields.extra), +}) export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) export const SessionRestoreResponse = Schema.Struct({ total: NonNegativeInt, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 03e8ee74b..570f355e5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -24,6 +24,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace .create({ ...ctx.payload, + extra: ctx.payload.extra ?? null, projectID: instance.project.id, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index e44a5ee3c..48dcd885b 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -27,9 +27,9 @@ const it = testEffect( Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), ) -function request(path: string, directory: string, init: RequestInit = {}) { +function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) @@ -195,6 +195,55 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates workspace with the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + extra: null, + }) + }), + ) + + it.live("documents legacy Hono accepting the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request( + WorkspacePaths.list, + dir, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }, + false, + ) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + extra: null, + }) + }), + ) + it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true From 5242a1c6b462cf8dea1f9f9a4ddf7190341c558a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:49:44 -0400 Subject: [PATCH 030/174] fix(httpapi): install Instance ALS for adapter Promise bridge (#25417) --- .../opencode/src/control-plane/workspace.ts | 17 +++++++++-------- packages/opencode/src/effect/bridge.ts | 19 +++++++++++++++++++ .../httpapi/middleware/workspace-routing.ts | 7 +++---- .../test/server/httpapi-workspace.test.ts | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7e4b4a6ff..485cb2e92 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -25,6 +25,7 @@ import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" +import { EffectBridge } from "@/effect/bridge" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" @@ -336,7 +337,7 @@ export const layer = Layer.effect( const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") return @@ -420,7 +421,7 @@ export const layer = Layer.effect( if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -459,8 +460,8 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) - const config = yield* Effect.promise(() => - Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })), + const config = yield* EffectBridge.fromPromise(() => + adapter.configure({ ...input, id, name: Slug.create(), directory: null }), ) const info: Info = { @@ -496,7 +497,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* Effect.promise(() => adapter.create(config, env)) + yield* EffectBridge.fromPromise(() => adapter.create(config, env)) yield* Effect.all( [ waitEvent({ @@ -532,7 +533,7 @@ export const layer = Layer.effect( }) const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) yield* sync.run(Session.Event.Updated, { sessionID: input.sessionID, @@ -724,10 +725,10 @@ export const layer = Layer.effect( yield* stopSync(id) const info = fromRow(row) - yield* Effect.catch( + yield* Effect.catchCause( Effect.gen(function* () { const adapter = getAdapter(info.projectID, row.type) - yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info))) + yield* EffectBridge.fromPromise(() => adapter.remove(info)) }), () => Effect.sync(() => { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 3c310129f..16d8f9366 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -21,6 +21,25 @@ function restore(instance: InstanceContext | undefined, workspace: WorkspaceI return fn() } +/** + * Bridge from Effect into a Promise-returning JS callback while installing + * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for + * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do + * not propagate across async/await boundaries inside `Effect.promise(() => + * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, + * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses + * into JS that may itself spawn new Effect runtimes (workspace adapters, + * legacy plugins, etc.). + * + * Mirrors `Effect.promise` but restores legacy ALS first. + */ +export const fromPromise = (fn: () => Promise | T): Effect.Effect => + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspace = yield* WorkspaceRef + return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + }) + export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index c8762bae6..4a07aaf11 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -2,6 +2,7 @@ import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" +import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/fence" @@ -79,10 +80,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe } function resolveTarget(workspace: Workspace.Info): Effect.Effect { - return Effect.gen(function* () { - const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) - }) + const adapter = getAdapter(workspace.projectID, workspace.type) + return EffectBridge.fromPromise(() => adapter.target(workspace)) } function proxyRemote( diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 48dcd885b..7fc1ec761 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -217,6 +217,24 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates a real git worktree workspace via the builtin adapter", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + expect(workspace).toMatchObject({ type: "worktree" }) + }), + ) + it.live("documents legacy Hono accepting the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true From 4c4860fb24603ce2e1044bc9d2c98953ce2d78af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:56:15 -0400 Subject: [PATCH 031/174] Replace Instance.disposeAll/load with fixture helper (#25418) --- packages/opencode/src/cli/bootstrap.ts | 4 ++-- packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++- packages/opencode/src/config/config.ts | 12 +++++++++--- packages/opencode/src/effect/app-runtime.ts | 2 ++ packages/opencode/src/project/instance.ts | 11 +++-------- packages/opencode/src/server/routes/global.ts | 4 ++-- .../opencode/src/server/routes/instance/index.ts | 3 ++- .../opencode/src/server/routes/instance/project.ts | 6 +----- packages/opencode/test/agent/agent.test.ts | 4 ++-- packages/opencode/test/bus/bus-effect.test.ts | 4 ++-- packages/opencode/test/bus/bus-integration.test.ts | 6 +++--- packages/opencode/test/bus/bus.test.ts | 8 ++++---- packages/opencode/test/config/config.test.ts | 4 ++-- .../opencode/test/control-plane/workspace.test.ts | 4 ++-- packages/opencode/test/effect/instance-state.test.ts | 6 +++--- packages/opencode/test/file/index.test.ts | 6 +++--- packages/opencode/test/file/watcher.test.ts | 4 ++-- packages/opencode/test/fixture/db.ts | 4 ++-- packages/opencode/test/fixture/fixture.ts | 8 ++++++++ packages/opencode/test/permission-task.test.ts | 4 ++-- packages/opencode/test/permission/next.test.ts | 4 ++-- packages/opencode/test/plugin/loader-shared.test.ts | 4 ++-- .../opencode/test/plugin/workspace-adapter.test.ts | 4 ++-- packages/opencode/test/project/instance.test.ts | 4 ++-- packages/opencode/test/project/vcs.test.ts | 6 +++--- packages/opencode/test/project/worktree.test.ts | 4 ++-- packages/opencode/test/provider/provider.test.ts | 4 ++-- packages/opencode/test/question/question.test.ts | 4 ++-- packages/opencode/test/server/httpapi-bridge.test.ts | 4 ++-- packages/opencode/test/server/httpapi-config.test.ts | 4 ++-- packages/opencode/test/server/httpapi-event.test.ts | 4 ++-- .../test/server/httpapi-experimental.test.ts | 4 ++-- packages/opencode/test/server/httpapi-file.test.ts | 4 ++-- .../test/server/httpapi-instance-context.test.ts | 4 ++-- .../test/server/httpapi-instance.legacy.test.ts | 4 ++-- .../opencode/test/server/httpapi-instance.test.ts | 4 ++-- .../opencode/test/server/httpapi-json-parity.test.ts | 4 ++-- packages/opencode/test/server/httpapi-mcp.test.ts | 4 ++-- .../opencode/test/server/httpapi-provider.test.ts | 4 ++-- packages/opencode/test/server/httpapi-pty.test.ts | 4 ++-- .../test/server/httpapi-raw-route-auth.test.ts | 4 ++-- packages/opencode/test/server/httpapi-sdk.test.ts | 6 +++--- .../opencode/test/server/httpapi-session.test.ts | 4 ++-- packages/opencode/test/server/httpapi-sync.test.ts | 4 ++-- packages/opencode/test/server/httpapi-tui.test.ts | 4 ++-- .../opencode/test/server/httpapi-workspace.test.ts | 4 ++-- .../opencode/test/server/project-init-git.test.ts | 6 +++--- .../opencode/test/server/session-actions.test.ts | 4 ++-- packages/opencode/test/server/session-list.test.ts | 4 ++-- .../opencode/test/server/session-messages.test.ts | 4 ++-- packages/opencode/test/server/session-select.test.ts | 4 ++-- packages/opencode/test/snapshot/snapshot.test.ts | 4 ++-- packages/opencode/test/tool/edit.test.ts | 4 ++-- packages/opencode/test/tool/lsp.test.ts | 4 ++-- packages/opencode/test/tool/read.test.ts | 4 ++-- packages/opencode/test/tool/registry.test.ts | 4 ++-- packages/opencode/test/tool/skill.test.ts | 4 ++-- packages/opencode/test/tool/task.test.ts | 4 ++-- packages/opencode/test/tool/write.test.ts | 4 ++-- 59 files changed, 139 insertions(+), 130 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 3190fda62..aa6aef6a2 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,5 +1,5 @@ -import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "../project/instance" +import { InstanceStore } from "../project/instance-store" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ @@ -9,7 +9,7 @@ export async function bootstrap(directory: string, cb: () => Promise) { const result = await cb() return result } finally { - await Instance.dispose() + await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 8b62c5038..41ca99a71 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,6 +2,7 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -87,7 +88,7 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") - await Instance.disposeAll() + await InstanceStore.runtime.runPromise((s) => s.disposeAll()) if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9e9a6e381..4dcab3e8d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -11,7 +11,9 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" -import { Instance, type InstanceContext } from "../project/instance" +import { type InstanceContext } from "../project/instance" +import { InstanceStore } from "../project/instance-store" +import { InstanceRef } from "@/effect/instance-ref" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" @@ -736,12 +738,16 @@ export const layer = Layer.effect( yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) - if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose()) + if (options?.dispose !== false) { + const ctx = yield* InstanceRef + if (ctx) yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.dispose(ctx))) + } }) const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { yield* invalidateGlobal - const task = Instance.disposeAll() + const task = InstanceStore.runtime + .runPromise((s) => s.disposeAll()) .catch(() => undefined) .finally(() => GlobalBus.emit("event", { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 06969ff9d..f3376ad85 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -39,6 +39,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -90,6 +91,7 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, + InstanceStore.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index afc07ad26..44ba39763 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -6,13 +6,11 @@ export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" export const Instance = { - load(input: InstanceStore.LoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(input)) - }, async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + const ctx = await InstanceStore.runtime.runPromise((store) => + store.load({ directory: input.directory, init: input.init }), ) + return context.provide(ctx, async () => input.fn()) }, get current() { return context.use() @@ -50,7 +48,4 @@ export const Instance = { async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) }, - async disposeAll() { - return InstanceStore.runtime.runPromise((store) => store.disposeAll()) - }, } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index e78df61c2..97fee3bfc 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -8,7 +8,7 @@ import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { Instance } from "../../project/instance" +import { InstanceStore } from "../../project/instance-store" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" @@ -200,7 +200,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await Instance.disposeAll() + await InstanceStore.runtime.runPromise((s) => s.disposeAll()) GlobalBus.emit("event", { directory: "global", payload: { diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index fa11e3e90..6ee9b4fad 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -62,7 +63,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await Instance.dispose() + await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 14c8c87b0..7db2bbdda 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -81,11 +81,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - }) + await Instance.reload({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 06bb103f0..44ed0692a 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,7 +1,7 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" @@ -18,7 +18,7 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("returns default native agents when no config", async () => { diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 0daf8fe6a..101d3be72 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -4,7 +4,7 @@ import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -151,7 +151,7 @@ describe("Bus (Effect-native)", () => { }).pipe(provideInstance(dir)) // Dispose from OUTSIDE the instance scope - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) expect(types).toContain("test.effect.ping") diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 280834457..7e2138ea8 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) @@ -12,7 +12,7 @@ function withInstance(directory: string, fn: () => Promise) { } describe("Bus integration: acquireRelease subscriber pattern", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) test("subscriber via callback facade receives events and cleans up on unsub", async () => { await using tmp = await tmpdir() @@ -78,7 +78,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => { await Bun.sleep(10) }) - await Instance.disposeAll() + await disposeAllInstances() await Bun.sleep(50) expect(received).toEqual([1]) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index cdacdd517..b24b79b33 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), @@ -15,7 +15,7 @@ function withInstance(directory: string, fn: () => Promise) { } describe("Bus", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("publish + subscribe", () => { test("subscriber is live immediately after subscribe returns", async () => { @@ -208,8 +208,8 @@ describe("Bus", () => { await Bun.sleep(10) }) - // Instance.disposeAll triggers the finalizer which publishes InstanceDisposed - await Instance.disposeAll() + // disposeAllInstances triggers the finalizer which publishes InstanceDisposed + await disposeAllInstances() await Bun.sleep(50) expect(received).toContain("test.ping") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c3ae249e5..5b2e91e37 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -12,7 +12,7 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -108,7 +108,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await Instance.disposeAll() + await disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 8545aef7f..ddd10f2e0 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -21,7 +21,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" import { EventSequenceTable, EventTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" @@ -93,7 +93,7 @@ beforeEach(() => { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag restoreEnv() await resetDatabase() diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 54b2b42c8..02945ac53 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -4,7 +4,7 @@ import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { Instance } from "../../src/project/instance" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -19,7 +19,7 @@ const tmpdirGitScoped = Effect.gen(function* () { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) it.live("InstanceState caches values per directory", () => @@ -94,7 +94,7 @@ it.live("InstanceState invalidates on disposeAll", () => yield* access(state, one) yield* access(state, two) - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) expect(seen.sort()).toEqual([one, two].sort()) }), diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 091626be8..bf5e7a175 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -6,10 +6,10 @@ import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const init = () => run(File.Service.use((svc) => svc.init())) @@ -936,7 +936,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.disposeAll() + await disposeAllInstances() await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2c2663b0e..e183f673f 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" @@ -147,7 +147,7 @@ function ready(directory: string) { describeWatcher("FileWatcher", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("publishes root create, update, and delete events", async () => { diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 4e83d0b90..07b42d994 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,9 +1,9 @@ import { rm } from "fs/promises" -import { Instance } from "../../src/project/instance" import { Database } from "@/storage/db" +import { disposeAllInstances } from "./fixture" export async function resetDatabase() { - await Instance.disposeAll().catch(() => undefined) + await disposeAllInstances().catch(() => undefined) Database.close() await rm(Database.Path, { force: true }).catch(() => undefined) await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 91605e15a..a861285a1 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,8 +9,16 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" +// Test helper for tearing down all loaded instances. Used in afterEach hooks. +// Replaces direct Instance.disposeAll() calls so the legacy promise method can be removed. +// IMPORTANT: must use InstanceStore.runtime, not AppRuntime or a test-layer Service — +// Instance.provide loads instances into InstanceStore.runtime's Service cache, and that +// Service is built per-runtime (not shared via memoMap across Effect.runPromise boundaries). +export const disposeAllInstances = () => InstanceStore.runtime.runPromise((s) => s.disposeAll()) + // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { return p.replace(/\0/g, "") diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 5ce7eee93..d4f9192c7 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,13 +2,13 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" -import { tmpdir } from "./fixture/fixture" +import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("Permission.evaluate for permission.task", () => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 0064185f4..850ad2ded 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,7 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -15,7 +15,7 @@ const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, Cross const it = testEffect(env) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const rejectAll = (message?: string) => diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 211a93a60..e24cd0507 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -24,7 +24,7 @@ afterAll(() => { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function load(dir: string) { diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 9abf993d8..249087808 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { pathToFileURL } from "url" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -20,7 +20,7 @@ const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) afterAll(() => { diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index b223bf91d..852c58ef4 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -5,13 +5,13 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("InstanceStore", () => { diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index a2a5cff60..0d0e46fe4 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" @@ -85,7 +85,7 @@ function nextBranchUpdate(directory: string, timeout = 10_000) { describeVcs("Vcs", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("branch() returns current branch name", async () => { @@ -158,7 +158,7 @@ describeVcs("Vcs", () => { describe("Vcs diff", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("defaultBranch() falls back to main", async () => { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 44a25a8e6..fac82fad3 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -6,7 +6,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { Worktree } from "../../src/worktree" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -37,7 +37,7 @@ async function waitReady() { } describe("Worktree", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("makeWorktreeInfo", () => { it.live("returns info with name, branch, and directory", () => diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index edbf4d664..924f42888 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2,7 +2,7 @@ import { test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" @@ -2557,7 +2557,7 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - await Instance.disposeAll() + await disposeAllInstances() const second = await Instance.provide({ directory: tmp.path, diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index d44f41f1a..14cf1aefa 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { QuestionID } from "../../src/question/schema" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -17,7 +17,7 @@ const reply = (input: { requestID: QuestionID; answers: ReadonlyArray AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 2b8a62cc5..352fb2e2f 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -12,7 +12,7 @@ import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -208,7 +208,7 @@ afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 9469a66fd..7d269b6be 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -37,7 +37,7 @@ async function waitDisposed(directory: string) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 915d79784..d7e48240a 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -29,7 +29,7 @@ async function readFirstChunk(response: Response) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index a4b0b6619..0185af2df 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -10,7 +10,7 @@ import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -50,7 +50,7 @@ async function waitReady(directory: string) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index b7425007e..81246eb0f 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -6,7 +6,7 @@ import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -28,7 +28,7 @@ function request(route: string, directory: string, query?: Record { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 15d3facd3..ece01cf32 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -19,7 +19,7 @@ import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/rou import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -30,7 +30,7 @@ const testStateLayer = Layer.effectDiscard( yield* Effect.addFinalizer(() => Effect.promise(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }), ) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 4f9ccc512..22a56ba8a 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -6,7 +6,7 @@ import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -37,7 +37,7 @@ async function waitDisposed(directory: string) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 3d9245cd6..61b1af613 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -7,13 +7,13 @@ import * as Socket from "effect/unstable/socket/Socket" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" // Flip the experimental HttpApi flag so backend selection telemetry on the // production routes reports the right backend, and reset the database around // the test so per-instance state does not leak between runs. resetDatabase() -// already calls Instance.disposeAll(), so we don't repeat it. +// already calls disposeAllInstances(), so we don't repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index 0465b1cf6..656541be7 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -15,7 +15,7 @@ import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -89,7 +89,7 @@ function expectJsonParity(input: { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index e34886652..d81d749f1 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -8,7 +8,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -76,7 +76,7 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 5e8ff01a0..3ff389300 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -98,7 +98,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index e4d22427c..2b6284a31 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -7,7 +7,7 @@ import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" import { Config, Effect, Layer, Queue, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" @@ -63,7 +63,7 @@ const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index af373d933..fd82e7863 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -8,7 +8,7 @@ import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { PtyID } from "../../src/pty/schema" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) @@ -49,7 +49,7 @@ async function cancelBody(response: Response) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 596ca4a5c..771fb5701 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -15,7 +15,7 @@ import { Session as SessionNs } from "@/session/session" import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { @@ -169,7 +169,7 @@ function sessionTitles(value: unknown) { function resetState() { return Effect.promise(async () => { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) } @@ -260,7 +260,7 @@ afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 5f2af06f1..02d590f91 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -20,7 +20,7 @@ import { SessionTable } from "@/session/session.sql" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -138,7 +138,7 @@ function withTmp( afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index f51a71457..d022c3797 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -7,7 +7,7 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -27,7 +27,7 @@ afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 3e844fad0..1fd3ce2b3 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -11,7 +11,7 @@ import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -45,7 +45,7 @@ async function expectTrue(path: string, headers: Record, body?: afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 7fc1ec761..193c2971a 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -12,7 +12,7 @@ import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" @@ -128,7 +128,7 @@ afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 86c52096e..0177cde82 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -8,7 +8,7 @@ import { Server } from "../../src/server/server" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -69,7 +69,7 @@ describe("project.initGit endpoint", () => { ), ).toBeTruthy() } finally { - await Instance.disposeAll() + await disposeAllInstances() reloadSpy.mockRestore() GlobalBus.off("event", fn) } @@ -114,7 +114,7 @@ describe("project.initGit endpoint", () => { worktree: tmp.path, }) } finally { - await Instance.disposeAll() + await disposeAllInstances() reloadSpy.mockRestore() GlobalBus.off("event", fn) } diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 843986ba8..43f188e74 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -25,7 +25,7 @@ const svc = { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() }) describe("session action routes", () => { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index e2f92c20f..7d479a73b 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" @@ -30,7 +30,7 @@ const svc = { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() }) describe("session.list", () => { diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e64d28bb4..e70847baf 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -6,7 +6,7 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -31,7 +31,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function withoutWatcher(fn: () => Promise) { diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 278fba94d..b3230d4b8 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -5,7 +5,7 @@ import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -24,7 +24,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tui.selectSession endpoint", () => { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b85d570dc..c3216e1c5 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -6,7 +6,7 @@ import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -14,7 +14,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function bootstrap() { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 01dc74bb2..2c381ad04 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -26,7 +26,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const runtime = ManagedRuntime.make( diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 8a3189cab..27623375c 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -11,11 +11,11 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { LspTool } from "../../src/tool/lsp" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c20b08437..3fa61401e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,13 +13,13 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 8e03177f8..0cd3ec4d1 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -5,7 +5,7 @@ import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const node = CrossSpawnSpawner.defaultLayer @@ -13,7 +13,7 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tool.registry", () => { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 175c1526d..7473d2d56 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -8,7 +8,7 @@ import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -23,7 +23,7 @@ const baseCtx: Omit = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const node = CrossSpawnSpawner.defaultLayer diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 147541f3d..a8d62bb68 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -12,11 +12,11 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ref = { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index cc9f87100..4931d2a54 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -28,7 +28,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const it = testEffect( From 78b3000031d3224d46da667dc631a04e7647d0f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:56:27 -0400 Subject: [PATCH 032/174] fix(tui): keep shell-mode prompt editable (#25419) --- .../cli/cmd/tui/component/prompt/index.tsx | 17 +++------ .../cli/cmd/tui/component/prompt/traits.ts | 31 +++++++++++++++ .../test/cli/cmd/tui/prompt-traits.test.ts | 38 +++++++++++++++++++ 3 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts create mode 100644 packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 1f93a4394..79034a01b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -17,6 +17,7 @@ import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" +import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" @@ -557,17 +558,11 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - const capture = - store.mode === "normal" - ? auto()?.visible - ? (["escape", "navigate", "submit", "tab"] as const) - : (["tab"] as const) - : undefined - input.traits = { - capture, - suspend: !!props.disabled || store.mode === "shell", - status: store.mode === "shell" ? "SHELL" : undefined, - } + input.traits = computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }) }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts new file mode 100644 index 000000000..e47a1aeba --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -0,0 +1,31 @@ +import type { EditorTraits } from "@opentui/core" + +export type PromptMode = "normal" | "shell" + +export interface PromptTraitsInput { + mode: PromptMode + disabled: boolean + autocompleteVisible: boolean +} + +/** + * Compute the textarea editor traits for the prompt. + * + * `traits.suspend` gates the textarea's keybinding actions (backspace, + * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active + * editing mode — only `disabled` should suspend the textarea, otherwise + * users can type in shell mode but cannot delete or move the cursor. + */ +export function computePromptTraits(input: PromptTraitsInput): EditorTraits { + const capture = + input.mode === "normal" + ? input.autocompleteVisible + ? (["escape", "navigate", "submit", "tab"] as const) + : (["tab"] as const) + : undefined + return { + capture, + suspend: input.disabled, + status: input.mode === "shell" ? "SHELL" : undefined, + } +} diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts new file mode 100644 index 000000000..34a16aedd --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/prompt/traits" + +describe("computePromptTraits", () => { + test("normal mode without autocomplete only captures tab", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toEqual(["tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("normal mode with autocomplete captures navigation keys", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("shell mode does not suspend the textarea", () => { + // Suspending the textarea would gate every keybinding action + // (backspace, delete-word-backward, arrow movement, etc.) — see + // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is + // an active editing mode, so suspend must stay off. + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.suspend).toBe(false) + }) + + test("shell mode disables capture and labels the prompt", () => { + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toBeUndefined() + expect(traits.status).toBe("SHELL") + }) + + test("disabled suspends regardless of mode", () => { + expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + }) +}) From 6a7634673460994c090c103097d9ff365a58cd17 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 2 May 2026 17:01:53 +0200 Subject: [PATCH 033/174] upgrade opentui to 0.2.2 (#25420) --- bun.lock | 56 ++++++++----------- package.json | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 2 + .../src/cli/cmd/tui/context/theme.tsx | 8 ++- packages/plugin/package.json | 4 +- 5 files changed, 36 insertions(+), 38 deletions(-) diff --git a/bun.lock b/bun.lock index fcd8e9443..a6dc1df84 100644 --- a/bun.lock +++ b/bun.lock @@ -511,8 +511,8 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.0", - "@opentui/solid": ">=0.2.0", + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2", }, "optionalPeers": [ "@opentui/core", @@ -690,8 +690,8 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.0", - "@opentui/solid": "0.2.0", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1618,21 +1618,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="], + "@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], - "@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="], + "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2768,21 +2768,21 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="], + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], - "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -4204,7 +4204,7 @@ "pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="], - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], @@ -5640,6 +5640,8 @@ "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6122,8 +6124,6 @@ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], - "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6132,6 +6132,8 @@ "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -6798,7 +6800,7 @@ "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], - "opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -7158,16 +7160,6 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], - "opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], diff --git a/package.json b/package.json index 9a0113030..b15fbb254 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.0", - "@opentui/solid": "0.2.0", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a26b8cfdf..7117ae7d1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -133,6 +133,8 @@ export function tui(input: { } const renderer = await createCliRenderer(rendererConfig(input.config)) + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" await render(() => { diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 5c26d461e..306c03825 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -416,12 +416,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const values = createMemo(() => { const active = store.themes[store.active] - if (active) return resolveTheme(active, store.mode) + if (active) { + return resolveTheme(active, store.mode) + } const saved = kv.get("theme") if (typeof saved === "string") { const theme = store.themes[saved] - if (theme) return resolveTheme(theme, store.mode) + if (theme) { + return resolveTheme(theme, store.mode) + } } return resolveTheme(store.themes.opencode, store.mode) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 75aba38d1..6efce0c57 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.0", - "@opentui/solid": ">=0.2.0" + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" }, "peerDependenciesMeta": { "@opentui/core": { From 31ed4602e14d44d840a579bca871be955ff49391 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 15:16:12 +0000 Subject: [PATCH 034/174] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index b9ba578ac..bea97a0cb 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=", - "aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=", - "aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=", - "x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY=" + "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", + "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", + "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", + "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" } } From b09b7d28b801a4c4f3f1c691345a35a0c62aafd6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 11:21:40 -0400 Subject: [PATCH 035/174] refactor(instance-store): consolidate dispose helpers (#25424) --- packages/opencode/src/cli/bootstrap.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/config/config.ts | 11 ++++++----- packages/opencode/src/project/instance-store.ts | 13 ++++++++++--- packages/opencode/src/project/instance.ts | 11 +++++++++-- packages/opencode/src/server/routes/global.ts | 2 +- .../opencode/src/server/routes/instance/index.ts | 2 +- .../opencode/src/server/routes/instance/project.ts | 6 +++++- packages/opencode/test/fixture/fixture.ts | 10 +++------- 9 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index aa6aef6a2..a0dd9fe2a 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -9,7 +9,7 @@ export async function bootstrap(directory: string, cb: () => Promise) { const result = await cb() return result } finally { - await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) + await InstanceStore.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 41ca99a71..0f0fd693d 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -88,7 +88,7 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") - await InstanceStore.runtime.runPromise((s) => s.disposeAll()) + await InstanceStore.disposeAllInstances() if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4dcab3e8d..46a31cf1c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -13,7 +13,6 @@ import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { type InstanceContext } from "../project/instance" import { InstanceStore } from "../project/instance-store" -import { InstanceRef } from "@/effect/instance-ref" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" @@ -739,15 +738,17 @@ export const layer = Layer.effect( .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) if (options?.dispose !== false) { - const ctx = yield* InstanceRef - if (ctx) yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.dispose(ctx))) + // Fail loudly if no instance is bound — silently skipping would + // mask "config update without an active instance" bugs. The throw + // comes from `Instance.current` inside `InstanceState.context`. + const ctx = yield* InstanceState.context + yield* Effect.promise(() => InstanceStore.disposeInstance(ctx)) } }) const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { yield* invalidateGlobal - const task = InstanceStore.runtime - .runPromise((s) => s.disposeAll()) + const task = InstanceStore.disposeAllInstances() .catch(() => undefined) .finally(() => GlobalBus.emit("event", { diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index e96c421a7..00075be64 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,7 +1,7 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" -import { disposeInstance } from "@/effect/instance-registry" +import { disposeInstance as runDisposers } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" @@ -94,7 +94,7 @@ export const layer: Layer.Layer = Layer.effect( const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) - yield* Effect.promise(() => disposeInstance(ctx.directory)) + yield* Effect.promise(() => runDisposers(ctx.directory)) yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) }) @@ -135,7 +135,7 @@ export const layer: Layer.Layer = Layer.effect( yield* Effect.logInfo("reloading instance", { directory }) if (previous) { yield* Deferred.await(previous.deferred).pipe(Effect.ignore) - yield* Effect.promise(() => disposeInstance(directory)) + yield* Effect.promise(() => runDisposers(directory)) yield* emitDisposed({ directory, project: input.project?.id }) } yield* completeLoad(directory, input, entry) @@ -197,4 +197,11 @@ export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) export const runtime = makeRuntime(Service, defaultLayer) +// Promise-returning helpers for callers without an Effect runtime in scope. +// They route through `runtime` (not a yielded Service from a fresh runtime) +// so they share the cache that `Instance.provide` populates. +export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) +export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) +export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) + export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 44ba39763..22c2779ce 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -42,10 +42,17 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, + // followup: `reload` survives because `test/server/project-init-git.test.ts` + // spies on this exact method. Once that test asserts on `InstanceStore.reloadInstance` + // (or moves to an Effect runtime), this wrapper can drop. async reload(input: InstanceStore.LoadInput) { - return InstanceStore.runtime.runPromise((store) => store.reload(input)) + return InstanceStore.reloadInstance(input) }, + // followup: `dispose` survives for legacy fixtures that read `Instance.current` + // out of ALS (e.g. `test/fixture/fixture.ts` `provideTmpdirInstance`, + // `test/question/question.test.ts` cancellation tests). Convert those to call + // `InstanceStore.disposeInstance(ctx)` directly once `Instance.provide` is gone. async dispose() { - return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) + return InstanceStore.disposeInstance(Instance.current) }, } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 97fee3bfc..f40a58453 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -200,7 +200,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await InstanceStore.runtime.runPromise((s) => s.disposeAll()) + await InstanceStore.disposeAllInstances() GlobalBus.emit("event", { directory: "global", payload: { diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 6ee9b4fad..530c02345 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -63,7 +63,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) + await InstanceStore.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 7db2bbdda..14c8c87b0 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -81,7 +81,11 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ directory: dir, worktree: dir, project: next }) + await Instance.reload({ + directory: dir, + worktree: dir, + project: next, + }) return c.json(next) }, ) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index a861285a1..23dd61d88 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,15 +9,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" -// Test helper for tearing down all loaded instances. Used in afterEach hooks. -// Replaces direct Instance.disposeAll() calls so the legacy promise method can be removed. -// IMPORTANT: must use InstanceStore.runtime, not AppRuntime or a test-layer Service — -// Instance.provide loads instances into InstanceStore.runtime's Service cache, and that -// Service is built per-runtime (not shared via memoMap across Effect.runPromise boundaries). -export const disposeAllInstances = () => InstanceStore.runtime.runPromise((s) => s.disposeAll()) +// Re-export for test ergonomics. The implementation lives next to the runtime +// it consumes; see `InstanceStore.disposeAllInstances` for the rationale. +export { disposeAllInstances } from "../../src/project/instance-store" // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { From 7371db5cc6da57deb9e6fe776298f14410e5614d Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 2 May 2026 15:34:12 +0000 Subject: [PATCH 036/174] sync release versions for v1.14.32 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index a6dc1df84..2efb1208c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.31", + "version": "1.14.32", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.31", + "version": "1.14.32", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 2decf1fce..7196ddd4f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.31", + "version": "1.14.32", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index afb903377..45eb7d0b7 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3ef4ad2e3..e94be9498 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.31", + "version": "1.14.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 92b62a1bf..c8590d6aa 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.31", + "version": "1.14.32", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index b29e0d887..f72d7f100 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 5cbf063d3..b1e8aa635 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.31", + "version": "1.14.32", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 16eaad158..5089278bf 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b2d2c975c..f16bf9368 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7d93297d0..b4c487f2f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.31", + "version": "1.14.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 340a747d5..ecc7e8f6b 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.31" +version = "1.14.32" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index d7536425c..52dfab2ad 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.31", + "version": "1.14.32", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ea91bef74..706986e42 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.31", + "version": "1.14.32", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 6efce0c57..2a96f1b8f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3da6b1b8a..8729c96a5 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 01c0ab245..32d830bba 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index c350b1e30..59039f05b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 093d4d91a..ab8031cf9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.31", + "version": "1.14.32", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f107f9fa5..185bc9339 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.31", + "version": "1.14.32", "publisher": "sst-dev", "repository": { "type": "git", From 3b9155714d1023182b75730ff4acd0a0c6a7cbf7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 11:44:16 -0400 Subject: [PATCH 037/174] Delete Instance.dispose and Instance.reload (#25427) --- packages/opencode/src/project/instance.ts | 13 ----------- .../src/server/routes/instance/project.ts | 7 ++---- .../test/effect/instance-state.test.ts | 5 ++-- packages/opencode/test/fixture/fixture.ts | 3 ++- packages/opencode/test/mcp/lifecycle.test.ts | 3 ++- .../opencode/test/permission/next.test.ts | 7 +++--- .../opencode/test/project/worktree.test.ts | 5 ++-- .../opencode/test/question/question.test.ts | 5 ++-- .../opencode/test/server/httpapi-mcp.test.ts | 3 ++- .../test/server/httpapi-provider.test.ts | 3 ++- .../test/server/project-init-git.test.ts | 23 ++++++------------- 11 files changed, 30 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 22c2779ce..5b2bcf6b3 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -42,17 +42,4 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - // followup: `reload` survives because `test/server/project-init-git.test.ts` - // spies on this exact method. Once that test asserts on `InstanceStore.reloadInstance` - // (or moves to an Effect runtime), this wrapper can drop. - async reload(input: InstanceStore.LoadInput) { - return InstanceStore.reloadInstance(input) - }, - // followup: `dispose` survives for legacy fixtures that read `Instance.current` - // out of ALS (e.g. `test/fixture/fixture.ts` `provideTmpdirInstance`, - // `test/question/question.test.ts` cancellation tests). Convert those to call - // `InstanceStore.disposeInstance(ctx)` directly once `Instance.provide` is gone. - async dispose() { - return InstanceStore.disposeInstance(Instance.current) - }, } diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 14c8c87b0..04cc432d0 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,6 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" @@ -81,11 +82,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - }) + await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 02945ac53..0a8972ca4 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,6 +3,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -69,7 +70,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -269,7 +270,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => const [, b] = yield* Effect.all( [ - Effect.promise(() => Instance.reload({ directory: one })), + Effect.promise(() => InstanceStore.reloadInstance({ directory: one })), Test.use((svc) => svc.get()).pipe(provideInstance(two)), ], { concurrency: "unbounded" }, diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 23dd61d88..1b193e382 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -8,6 +8,7 @@ import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" @@ -149,7 +150,7 @@ export function provideTmpdirInstance( ? Effect.promise(() => Instance.provide({ directory: path, - fn: () => Instance.dispose(), + fn: () => InstanceStore.disposeInstance(Instance.current), }), ).pipe(Effect.ignore) : Effect.void, diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 1b459481f..59fa54cea 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,4 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { InstanceStore } from "../../src/project/instance-store" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -197,7 +198,7 @@ function withInstance( fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 850ad2ded..5a0c47402 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,6 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -998,7 +999,7 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() })) + yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1021,7 +1022,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1115,7 +1116,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index fac82fad3..3593c3ba0 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -136,7 +137,7 @@ describe("Worktree", () => { expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -156,7 +157,7 @@ describe("Worktree", () => { expect(info.branch).toBe("opencode/test-workspace") yield* Effect.promise(() => ready) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 14cf1aefa..83968a6f8 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,6 +1,7 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" @@ -421,7 +422,7 @@ test("pending question rejects on instance dispose", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) }, }) @@ -456,7 +457,7 @@ test("pending question rejects on instance reload", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await Instance.reload({ directory: tmp.path }) + await InstanceStore.reloadInstance({ directory: tmp.path }) }, }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index d81d749f1..32f6343ad 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,6 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -57,7 +58,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) }), ) yield* Effect.addFinalizer(() => - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 3ff389300..5714f719a 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,6 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -89,7 +90,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 0177cde82..48e28aa5a 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,9 +1,8 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { GlobalBus } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" @@ -16,6 +15,9 @@ afterEach(async () => { await resetDatabase() }) +const disposedEvents = (seen: { directory?: string; payload: { type: string } }[], dir: string) => + seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length + describe("project.initGit endpoint", () => { test("initializes git and reloads immediately", async () => { await using tmp = await tmpdir() @@ -24,8 +26,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -42,10 +42,8 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect(reloadSpy).toHaveBeenCalledTimes(1) - expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe( - true, - ) + // Reload behavior: bus emits exactly one server.instance.disposed for the directory. + expect(disposedEvents(seen, tmp.path)).toBe(1) expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false) const current = await app.request("/project/current", { @@ -70,7 +68,6 @@ describe("project.initGit endpoint", () => { ).toBeTruthy() } finally { await disposeAllInstances() - reloadSpy.mockRestore() GlobalBus.off("event", fn) } }) @@ -82,8 +79,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -98,10 +93,7 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect( - seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length, - ).toBe(0) - expect(reloadSpy).toHaveBeenCalledTimes(0) + expect(disposedEvents(seen, tmp.path)).toBe(0) const current = await app.request("/project/current", { headers: { @@ -115,7 +107,6 @@ describe("project.initGit endpoint", () => { }) } finally { await disposeAllInstances() - reloadSpy.mockRestore() GlobalBus.off("event", fn) } }) From 96061222d28c64ce6891755c2d91d0783ff42283 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 15:45:21 +0000 Subject: [PATCH 038/174] chore: generate --- packages/opencode/test/permission/next.test.ts | 4 +++- packages/opencode/test/project/worktree.test.ts | 12 ++++++++++-- packages/opencode/test/server/httpapi-mcp.test.ts | 4 +++- .../opencode/test/server/httpapi-provider.test.ts | 4 +++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 5a0c47402..c615e55e5 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -999,7 +999,9 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) })) + yield* Effect.promise(() => + Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }), + ) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 3593c3ba0..806c47615 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -137,7 +137,11 @@ describe("Worktree", () => { expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) + yield* Effect.promise(() => + InstanceStore.runtime.runPromise((s) => + s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), + ), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -157,7 +161,11 @@ describe("Worktree", () => { expect(info.branch).toBe("opencode/test-workspace") yield* Effect.promise(() => ready) - yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) + yield* Effect.promise(() => + InstanceStore.runtime.runPromise((s) => + s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), + ), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 32f6343ad..6f2b4cee3 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -58,7 +58,9 @@ function withMcpProject(self: (dir: string) => Effect.Effect) }), ) yield* Effect.addFinalizer(() => - Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), + Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 5714f719a..b4cec9115 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -90,7 +90,9 @@ function withProviderProject(self: (dir: string) => Effect.Effect - Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), + Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) From 1ea6e6cd4b6d041d1ac6c26a4faf3da93ede9408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 2 May 2026 17:49:51 +0200 Subject: [PATCH 039/174] fix(nix): remove stale packages/shared filter (#24930) --- nix/node_modules.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/node_modules.nix b/nix/node_modules.nix index ba97405df..e10e85d2f 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -55,7 +55,6 @@ stdenvNoCC.mkDerivation { --filter './packages/opencode' \ --filter './packages/desktop' \ --filter './packages/app' \ - --filter './packages/shared' \ --frozen-lockfile \ --ignore-scripts \ --no-progress From 0d0ec7dc4663cd0319351443fed4d981001724c6 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 2 May 2026 18:07:22 +0200 Subject: [PATCH 040/174] docs: CLI docs for current commands and flags (#25399) --- packages/web/src/content/docs/ar/cli.mdx | 165 ++++++------ packages/web/src/content/docs/bs/cli.mdx | 165 ++++++------ packages/web/src/content/docs/cli.mdx | 282 ++++++++++++++------ packages/web/src/content/docs/da/cli.mdx | 165 ++++++------ packages/web/src/content/docs/de/cli.mdx | 165 ++++++------ packages/web/src/content/docs/es/cli.mdx | 165 ++++++------ packages/web/src/content/docs/fr/cli.mdx | 165 ++++++------ packages/web/src/content/docs/it/cli.mdx | 165 ++++++------ packages/web/src/content/docs/ja/cli.mdx | 165 ++++++------ packages/web/src/content/docs/ko/cli.mdx | 165 ++++++------ packages/web/src/content/docs/nb/cli.mdx | 165 ++++++------ packages/web/src/content/docs/pl/cli.mdx | 165 ++++++------ packages/web/src/content/docs/pt-br/cli.mdx | 165 ++++++------ packages/web/src/content/docs/ru/cli.mdx | 165 ++++++------ packages/web/src/content/docs/th/cli.mdx | 167 ++++++------ packages/web/src/content/docs/tr/cli.mdx | 165 ++++++------ packages/web/src/content/docs/zh-cn/cli.mdx | 165 ++++++------ packages/web/src/content/docs/zh-tw/cli.mdx | 165 ++++++------ 18 files changed, 1676 insertions(+), 1413 deletions(-) diff --git a/packages/web/src/content/docs/ar/cli.mdx b/packages/web/src/content/docs/ar/cli.mdx index ab2c12fb2..8a9729436 100644 --- a/packages/web/src/content/docs/ar/cli.mdx +++ b/packages/web/src/content/docs/ar/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### الخيارات -| الخيار | المختصر | الوصف | -| ------------ | ------- | ----------------------------------------------------------------- | -| `--continue` | `-c` | متابعة الجلسة الأخيرة | -| `--session` | `-s` | معرّف الجلسة للمتابعة | -| `--fork` | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | -| `--prompt` | | الموجّه المراد استخدامه | -| `--model` | `-m` | النموذج المراد استخدامه بصيغة provider/model | -| `--agent` | | الوكيل المراد استخدامه | -| `--port` | | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | | اسم المضيف الذي يتم الاستماع عليه | +| الخيار | المختصر | الوصف | +| ---------------------------------------- | ------- | ----------------------------------------------------------------- | +| {"--continue"} | `-c` | متابعة الجلسة الأخيرة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | +| {"--prompt"} | | الموجّه المراد استخدامه | +| {"--model"} | `-m` | النموذج المراد استخدامه بصيغة provider/model | +| {"--agent"} | | الوكيل المراد استخدامه | +| {"--port"} | | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | | اسم المضيف الذي يتم الاستماع عليه | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### الرايات -| الراية | المختصر | الوصف | -| ----------- | ------- | ----------------------------------- | -| `--dir` | | دليل العمل الذي ستبدأ منه واجهة TUI | -| `--session` | `-s` | معرّف الجلسة للمتابعة | +| الراية | المختصر | الوصف | +| ---------------------------------------- | ------- | --------------------------------------------------------------------------------- | +| {"--dir"} | | دليل العمل الذي ستبدأ منه واجهة TUI | +| {"--continue"} | `-c` | متابعة آخر جلسة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (استخدمه مع `--continue` أو `--session`) | +| {"--password"} | `-p` | كلمة مرور المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | اسم مستخدم المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_USERNAME` أو `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### الرايات -| الراية | الوصف | -| --------- | ------------------------------------ | -| `--event` | حدث GitHub مُحاكى لتشغيل الوكيل عليه | -| `--token` | رمز وصول شخصي لـ GitHub | +| الراية | الوصف | +| ------------------------------------- | ------------------------------------ | +| {"--event"} | حدث GitHub مُحاكى لتشغيل الوكيل عليه | +| {"--token"} | رمز وصول شخصي لـ GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### الرايات -| الراية | الوصف | -| ----------- | ------------------------------------------------------------- | -| `--refresh` | تحديث ذاكرة التخزين المؤقت للنماذج من models.dev | -| `--verbose` | استخدام مخرجات أكثر تفصيلا للنماذج (تشمل بيانات مثل التكاليف) | +| الراية | الوصف | +| --------------------------------------- | ------------------------------------------------------------- | +| {"--refresh"} | تحديث ذاكرة التخزين المؤقت للنماذج من models.dev | +| {"--verbose"} | استخدام مخرجات أكثر تفصيلا للنماذج (تشمل بيانات مثل التكاليف) | استخدم الراية `--refresh` لتحديث قائمة النماذج المخزنة مؤقتا. يفيد ذلك عند إضافة نماذج جديدة إلى مزود وتريد رؤيتها في OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### الرايات -| الراية | المختصر | الوصف | -| ------------ | ------- | ----------------------------------------------------------------- | -| `--command` | | الأمر المراد تشغيله؛ استخدم الرسالة كوسائط | -| `--continue` | `-c` | متابعة الجلسة الأخيرة | -| `--session` | `-s` | معرّف الجلسة للمتابعة | -| `--fork` | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | -| `--share` | | مشاركة الجلسة | -| `--model` | `-m` | النموذج المراد استخدامه بصيغة provider/model | -| `--agent` | | الوكيل المراد استخدامه | -| `--file` | `-f` | ملف/ملفات لإرفاقها بالرسالة | -| `--format` | | التنسيق: default (منسق) أو json (أحداث JSON خام) | -| `--title` | | عنوان للجلسة (يستخدم موجهًا مقتطعًا إن لم تُحدَّد قيمة) | -| `--attach` | | الإرفاق بخادم opencode قيد التشغيل (مثل http://localhost:4096) | -| `--port` | | منفذ الخادم المحلي (الافتراضي منفذ عشوائي) | +| الراية | المختصر | الوصف | +| ---------------------------------------- | ------- | --------------------------------------------------------------------------------- | +| {"--command"} | | الأمر المراد تشغيله؛ استخدم الرسالة كوسائط | +| {"--continue"} | `-c` | متابعة الجلسة الأخيرة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | +| {"--share"} | | مشاركة الجلسة | +| {"--model"} | `-m` | النموذج المراد استخدامه بصيغة provider/model | +| {"--agent"} | | الوكيل المراد استخدامه | +| {"--file"} | `-f` | ملف/ملفات لإرفاقها بالرسالة | +| {"--format"} | | التنسيق: default (منسق) أو json (أحداث JSON خام) | +| {"--title"} | | عنوان للجلسة (يستخدم موجهًا مقتطعًا إن لم تُحدَّد قيمة) | +| {"--attach"} | | الإرفاق بخادم opencode قيد التشغيل (مثل http://localhost:4096) | +| {"--password"} | `-p` | كلمة مرور المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | اسم مستخدم المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_USERNAME` أو `opencode`) | +| {"--dir"} | | دليل التشغيل، أو المسار على الخادم البعيد عند الإرفاق | +| {"--variant"} | | متغير النموذج (جهد الاستدلال الخاص بالمزود) | +| {"--thinking"} | | عرض كتل التفكير | +| {"--port"} | | منفذ الخادم المحلي (الافتراضي منفذ عشوائي) | --- @@ -364,12 +373,12 @@ opencode serve #### الرايات -| الراية | الوصف | -| ------------ | ----------------------------------------- | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | -| `--mdns` | تفعيل اكتشاف mDNS | -| `--cors` | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | +| الراية | الوصف | +| ---------------------------------------- | ----------------------------------------- | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | +| {"--mdns"} | تفعيل اكتشاف mDNS | +| {"--cors"} | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### الرايات -| الراية | المختصر | الوصف | -| ------------- | ------- | ------------------------------------- | -| `--max-count` | `-n` | حصر النتائج في أحدث N جلسات | -| `--format` | | تنسيق المخرجات: table أو json (table) | +| الراية | المختصر | الوصف | +| ----------------------------------------- | ------- | ------------------------------------- | +| {"--max-count"} | `-n` | حصر النتائج في أحدث N جلسات | +| {"--format"} | | تنسيق المخرجات: table أو json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### الرايات -| الراية | الوصف | -| ----------- | ------------------------------------------------------------------------- | -| `--days` | عرض الإحصاءات لآخر N يومًا (الافتراضي: كل الوقت) | -| `--tools` | عدد الأدوات المطلوب عرضها (الافتراضي: الكل) | -| `--models` | عرض تفصيل استخدام النماذج (مخفي افتراضيا). مرّر رقمًا لعرض أعلى N | -| `--project` | التصفية حسب المشروع (الافتراضي: كل المشاريع، سلسلة فارغة: المشروع الحالي) | +| الراية | الوصف | +| --------------------------------------- | ------------------------------------------------------------------------- | +| {"--days"} | عرض الإحصاءات لآخر N يومًا (الافتراضي: كل الوقت) | +| {"--tools"} | عدد الأدوات المطلوب عرضها (الافتراضي: الكل) | +| {"--models"} | عرض تفصيل استخدام النماذج (مخفي افتراضيا). مرّر رقمًا لعرض أعلى N | +| {"--project"} | التصفية حسب المشروع (الافتراضي: كل المشاريع، سلسلة فارغة: المشروع الحالي) | --- @@ -460,12 +469,12 @@ opencode web #### الرايات -| الراية | الوصف | -| ------------ | ----------------------------------------- | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | -| `--mdns` | تفعيل اكتشاف mDNS | -| `--cors` | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | +| الراية | الوصف | +| ---------------------------------------- | ----------------------------------------- | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | +| {"--mdns"} | تفعيل اكتشاف mDNS | +| {"--cors"} | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | --- @@ -481,11 +490,11 @@ opencode acp #### الرايات -| الراية | الوصف | -| ------------ | --------------------------------- | -| `--cwd` | دليل العمل | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | +| الراية | الوصف | +| ---------------------------------------- | --------------------------------- | +| {"--cwd"} | دليل العمل | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | --- @@ -499,12 +508,12 @@ opencode uninstall #### الرايات -| الراية | المختصر | الوصف | -| --------------- | ------- | ----------------------------------- | -| `--keep-config` | `-c` | الإبقاء على ملفات التهيئة | -| `--keep-data` | `-d` | الإبقاء على بيانات الجلسات واللقطات | -| `--dry-run` | | عرض ما سيتم حذفه دون تنفيذ الحذف | -| `--force` | `-f` | تخطي مطالبات التأكيد | +| الراية | المختصر | الوصف | +| ------------------------------------------- | ------- | ----------------------------------- | +| {"--keep-config"} | `-c` | الإبقاء على ملفات التهيئة | +| {"--keep-data"} | `-d` | الإبقاء على بيانات الجلسات واللقطات | +| {"--dry-run"} | | عرض ما سيتم حذفه دون تنفيذ الحذف | +| {"--force"} | `-f` | تخطي مطالبات التأكيد | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### الرايات -| الراية | المختصر | الوصف | -| ---------- | ------- | ----------------------------------------------------------- | -| `--method` | `-m` | طريقة التثبيت المستخدمة: curl أو npm أو pnpm أو bun أو brew | +| الراية | المختصر | الوصف | +| -------------------------------------- | ------- | ----------------------------------------------------------- | +| {"--method"} | `-m` | طريقة التثبيت المستخدمة: curl أو npm أو pnpm أو bun أو brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 يدعم سطر أوامر opencode الخيارات العامة التالية. -| الراية | المختصر | الوصف | -| -------------- | ------- | -------------------------------------- | -| `--help` | `-h` | عرض المساعدة | -| `--version` | `-v` | طباعة رقم الإصدار | -| `--print-logs` | | طباعة السجلات إلى stderr | -| `--log-level` | | مستوى السجل (DEBUG, INFO, WARN, ERROR) | +| الراية | المختصر | الوصف | +| ------------------------------------------ | ------- | -------------------------------------- | +| {"--help"} | `-h` | عرض المساعدة | +| {"--version"} | `-v` | طباعة رقم الإصدار | +| {"--print-logs"} | | طباعة السجلات إلى stderr | +| {"--log-level"} | | مستوى السجل (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx index 118b81ba4..c7944e7cf 100644 --- a/packages/web/src/content/docs/bs/cli.mdx +++ b/packages/web/src/content/docs/bs/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opcije -| Opcija | Kratko | Opis | -| ------------ | ------ | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Nastavite posljednju sesiju | -| `--session` | `-s` | ID sesije za nastavak | -| `--fork` | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | -| `--prompt` | | Prompt za upotrebu | -| `--model` | `-m` | Model za korištenje u obliku provider/model | -| `--agent` | | Agent za korištenje | -| `--port` | | Port na kojem treba slušati | -| `--hostname` | | Hostname na kojem treba slušati | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--prompt"} | | Prompt za upotrebu | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--port"} | | Port na kojem treba slušati | +| {"--hostname"} | | Hostname na kojem treba slušati | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opcije -| Opcija | Kratko | Opis | -| ----------- | ------ | ------------------------------------ | -| `--dir` | | Radni direktorij za pokretanje TUI-a | -| `--session` | `-s` | ID sesije za nastavak | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | Radni direktorij za pokretanje TUI-a | +| {"--continue"} | `-c` | Nastavi posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkuj sesiju prilikom nastavka (koristite sa `--continue` ili `--session`) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opcije -| Opcija | Opis | -| --------- | -------------------------------------- | -| `--event` | GitHub mock event za pokretanje agenta | -| `--token` | GitHub Personal Access Token | +| Opcija | Opis | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event za pokretanje agenta | +| {"--token"} | GitHub Personal Access Token | --- @@ -293,10 +297,10 @@ opencode models anthropic #### Opcije -| Opcija | Opis | -| ----------- | ------------------------------------------------------------------------ | -| `--refresh` | Osvježite keš modela sa models.dev | -| `--verbose` | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | +| Opcija | Opis | +| --------------------------------------- | ------------------------------------------------------------------------ | +| {"--refresh"} | Osvježite keš modela sa models.dev | +| {"--verbose"} | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | Koristite `--refresh` zastavicu da ažurirate keširanu listu modela. Ovo je korisno kada su novi modeli dodani provajderu i želite da ih vidite u OpenCode. @@ -332,20 +336,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Opcije -| Opcija | Kratko | Opis | -| ------------ | ------ | ------------------------------------------------------------------------ | -| `--command` | | Naredba za pokretanje, koristite poruku za argumente | -| `--continue` | `-c` | Nastavite posljednju sesiju | -| `--session` | `-s` | ID sesije za nastavak | -| `--fork` | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | -| `--share` | | Podijelite sesiju | -| `--model` | `-m` | Model za korištenje u obliku provider/model | -| `--agent` | | Agent za korištenje | -| `--file` | `-f` | Fajlovi koje treba priložiti poruci | -| `--format` | | Format: default (formatiran) ili json (sirovi JSON događaji) | -| `--title` | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | -| `--attach` | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | -| `--port` | | Port za lokalni server (zadano na nasumični port) | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Naredba za pokretanje, koristite poruku za argumente | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--share"} | | Podijelite sesiju | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--file"} | `-f` | Fajlovi koje treba priložiti poruci | +| {"--format"} | | Format: default (formatiran) ili json (sirovi JSON događaji) | +| {"--title"} | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | +| {"--attach"} | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | +| {"--dir"} | | Direktorij za pokretanje, ili putanja na udaljenom serveru pri spajanju | +| {"--variant"} | | Varijanta modela (napor zaključivanja specifičan za provajdera) | +| {"--thinking"} | | Prikaži blokove razmišljanja | +| {"--port"} | | Port za lokalni server (zadano na nasumični port) | --- @@ -361,12 +370,12 @@ Ovo pokreće HTTP server koji pruža API pristup funkcionalnosti OpenCode-a bez #### Opcije -| Opcija | Opis | -| ------------ | ----------------------------------------------------- | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem treba slušati | -| `--mdns` | Omogući mDNS otkrivanje | -| `--cors` | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | --- @@ -390,10 +399,10 @@ opencode session list ##### Opcije -| Opcija | Kratko | Opis | -| ------------- | ------ | -------------------------------------- | -| `--max-count` | `-n` | Ograničenje na N najnovijih sesija | -| `--format` | | Izlazni format: table ili json (table) | +| Opcija | Kratko | Opis | +| ----------------------------------------- | ------ | -------------------------------------- | +| {"--max-count"} | `-n` | Ograničenje na N najnovijih sesija | +| {"--format"} | | Izlazni format: table ili json (table) | --- @@ -407,12 +416,12 @@ opencode stats #### Opcije -| Opcija | Opis | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `--days` | Prikaži statistiku za zadnjih N dana (sva vremena) | -| `--tools` | Broj alata za prikaz (svi) | -| `--models` | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | -| `--project` | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | +| Opcija | Opis | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Prikaži statistiku za zadnjih N dana (sva vremena) | +| {"--tools"} | Broj alata za prikaz (svi) | +| {"--models"} | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | +| {"--project"} | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | --- @@ -457,12 +466,12 @@ Ovo pokreće HTTP server i otvara web pretraživač za pristup OpenCode-u preko #### Opcije -| Opcija | Opis | -| ------------ | ----------------------------------------------------- | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem treba slušati | -| `--mdns` | Omogući mDNS otkrivanje | -| `--cors` | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | --- @@ -478,11 +487,11 @@ Ova naredba pokreće ACP server koji komunicira preko stdin/stdout koristeći nd #### Opcije -| Opcija | Opis | -| ------------ | --------------------------- | -| `--cwd` | Radni direktorij | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem slušati | +| Opcija | Opis | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Radni direktorij | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem slušati | --- @@ -496,12 +505,12 @@ opencode uninstall #### Opcije -| Opcija | Kratko | Opis | -| --------------- | ------ | --------------------------------------------- | -| `--keep-config` | `-c` | Sačuvajte konfiguracijske datoteke | -| `--keep-data` | `-d` | Sačuvajte podatke i snimke sesije | -| `--dry-run` | | Pokažite šta bi bilo uklonjeno bez uklanjanja | -| `--force` | `-f` | Preskoči upite za potvrdu | +| Opcija | Kratko | Opis | +| ------------------------------------------- | ------ | --------------------------------------------- | +| {"--keep-config"} | `-c` | Sačuvajte konfiguracijske datoteke | +| {"--keep-data"} | `-d` | Sačuvajte podatke i snimke sesije | +| {"--dry-run"} | | Pokažite šta bi bilo uklonjeno bez uklanjanja | +| {"--force"} | `-f` | Preskoči upite za potvrdu | --- @@ -527,9 +536,9 @@ opencode upgrade v0.1.48 #### Opcije -| Opcija | Kratko | Opis | -| ---------- | ------ | ------------------------------------------------------- | -| `--method` | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | +| Opcija | Kratko | Opis | +| -------------------------------------- | ------ | ------------------------------------------------------- | +| {"--method"} | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | --- @@ -537,12 +546,12 @@ opencode upgrade v0.1.48 OpenCode CLI prihvata sljedeće globalne zastavice. -| Opcija | Kratko | Opis | -| -------------- | ------ | ----------------------------------------- | -| `--help` | `-h` | Prikaži pomoć | -| `--version` | `-v` | Ispiši broj verzije | -| `--print-logs` | | Ispis logova u stderr | -| `--log-level` | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | +| Opcija | Kratko | Opis | +| ------------------------------------------ | ------ | ----------------------------------------- | +| {"--help"} | `-h` | Prikaži pomoć | +| {"--version"} | `-v` | Ispiši broj verzije | +| {"--print-logs"} | | Ispis logova u stderr | +| {"--log-level"} | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 7249f4dc9..8ecb6a6eb 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -29,16 +29,19 @@ opencode [project] #### Flags -| Flag | Short | Description | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) | -| `--prompt` | | Prompt to use | -| `--model` | `-m` | Model to use in the form of provider/model | -| `--agent` | | Agent to use | -| `--port` | | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Short | Description | +| ------------------------------------------- | ----- | ----------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--prompt"} | | Prompt to use | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--port"} | | Port to listen on | +| {"--hostname"} | | Hostname to listen on | +| {"--mdns"} | | Enable mDNS discovery | +| {"--mdns-domain"} | | Custom mDNS domain name | +| {"--cors"} | | Additional browser origin(s) to allow CORS | --- @@ -78,10 +81,14 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ----------- | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--dir"} | | Working directory to start TUI in | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | --- @@ -97,13 +104,13 @@ This command will guide you through creating a new agent with a custom system pr #### Flags -| Flag | Description | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--path` | Directory to write the agent file to (defaults to global or `.opencode/agent` based on the prompt) | -| `--description` | What the agent should do | -| `--mode` | Agent mode: `all`, `primary`, or `subagent` | -| `--permissions` | Comma-separated list of permissions to allow (default: all). Available: `bash`, `read`, `edit`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, `websearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | -| `--model`, `-m` | Model to use, in `provider/model` format | +| Flag | Short | Description | +| ------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| {"--path"} | | Directory to write the agent file to (defaults to global or `.opencode/agent` based on the prompt) | +| {"--description"} | | What the agent should do | +| {"--mode"} | | Agent mode: `all`, `primary`, or `subagent` | +| {"--permissions"} | | Comma-separated list of permissions to allow (default: all). Available: `bash`, `read`, `edit`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, `websearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | +| {"--model"} | `-m` | Model to use, in `provider/model` format | Passing all of `--path`, `--description`, `--mode`, and `--permissions` runs the command non-interactively. @@ -139,6 +146,13 @@ opencode auth login When OpenCode starts up it loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project. +##### Flags + +| Flag | Short | Description | +| ---------------------------------------- | ----- | ---------------------------------------------------- | +| {"--provider"} | `-p` | Provider ID or name to log in to | +| {"--method"} | `-m` | Login method label to use, skipping method selection | + --- #### list @@ -199,10 +213,10 @@ opencode github run ##### Flags -| Flag | Description | -| --------- | -------------------------------------- | -| `--event` | GitHub mock event to run the agent for | -| `--token` | GitHub personal access token | +| Flag | Description | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event to run the agent for | +| {"--token"} | GitHub personal access token | --- @@ -308,10 +322,10 @@ opencode models anthropic #### Flags -| Flag | Description | -| ----------- | ------------------------------------------------------------ | -| `--refresh` | Refresh the models cache from models.dev | -| `--verbose` | Use more verbose model output (includes metadata like costs) | +| Flag | Description | +| --------------------------------------- | ------------------------------------------------------------ | +| {"--refresh"} | Refresh the models cache from models.dev | +| {"--verbose"} | Use more verbose model output (includes metadata like costs) | Use the `--refresh` flag to update the cached model list. This is useful when new models have been added to a provider and you want to see them in OpenCode. @@ -347,21 +361,26 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flags -| Flag | Short | Description | -| -------------------------------- | ----- | ----------------------------------------------------------------------- | -| `--command` | | The command to run, use message for args | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) | -| `--share` | | Share the session | -| `--model` | `-m` | Model to use in the form of provider/model | -| `--agent` | | Agent to use | -| `--file` | `-f` | File(s) to attach to message | -| `--format` | | Format: default (formatted) or json (raw JSON events) | -| `--title` | | Title for the session (uses truncated prompt if no value provided) | -| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) | -| `--port` | | Port for the local server (defaults to random port) | -| `--dangerously-skip-permissions` | | Auto-approve permissions that are not explicitly denied (dangerous!) | +| Flag | Short | Description | +| ------------------------------------------------------------ | ----- | -------------------------------------------------------------------------- | +| {"--command"} | | The command to run, use message for args | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--share"} | | Share the session | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--file"} | `-f` | File(s) to attach to message | +| {"--format"} | | Format: default (formatted) or json (raw JSON events) | +| {"--title"} | | Title for the session (uses truncated prompt if no value provided) | +| {"--attach"} | | Attach to a running opencode server (e.g., http://localhost:4096) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | +| {"--dir"} | | Directory to run in, or path on the remote server when attaching | +| {"--port"} | | Port for the local server (defaults to random port) | +| {"--variant"} | | Model variant (provider-specific reasoning effort) | +| {"--thinking"} | | Show thinking blocks | +| {"--dangerously-skip-permissions"} | | Auto-approve permissions that are not explicitly denied (dangerous!) | --- @@ -377,12 +396,13 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | --- @@ -406,10 +426,20 @@ opencode session list ##### Flags -| Flag | Short | Description | -| ------------- | ----- | ------------------------------------ | -| `--max-count` | `-n` | Limit to N most recent sessions | -| `--format` | | Output format: table or json (table) | +| Flag | Short | Description | +| ----------------------------------------- | ----- | ------------------------------------ | +| {"--max-count"} | `-n` | Limit to N most recent sessions | +| {"--format"} | | Output format: table or json (table) | + +--- + +#### delete + +Delete an OpenCode session. + +```bash +opencode session delete +``` --- @@ -423,12 +453,12 @@ opencode stats #### Flags -| Flag | Description | -| ----------- | --------------------------------------------------------------------------- | -| `--days` | Show stats for the last N days (all time) | -| `--tools` | Number of tools to show (all) | -| `--models` | Show model usage breakdown (hidden by default). Pass a number to show top N | -| `--project` | Filter by project (all projects, empty string: current project) | +| Flag | Description | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--days"} | Show stats for the last N days (all time) | +| {"--tools"} | Number of tools to show (all) | +| {"--models"} | Show model usage breakdown (hidden by default). Pass a number to show top N | +| {"--project"} | Filter by project (all projects, empty string: current project) | --- @@ -442,6 +472,12 @@ opencode export [sessionID] If you don't provide a session ID, you'll be prompted to select from available sessions. +#### Flags + +| Flag | Description | +| ---------------------------------------- | ------------------------------------- | +| {"--sanitize"} | Redact sensitive transcript/file data | + --- ### import @@ -473,12 +509,13 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | --- @@ -494,11 +531,83 @@ This command starts an ACP server that communicates via stdin/stdout using nd-JS #### Flags -| Flag | Description | -| ------------ | --------------------- | -| `--cwd` | Working directory | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--cwd"} | Working directory | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | + +--- + +### plugin + +Install a plugin and update your config. + +```bash +opencode plugin +``` + +Or use the alias. + +```bash +opencode plug +``` + +#### Flags + +| Flag | Short | Description | +| -------------------------------------- | ----- | ------------------------------- | +| {"--global"} | `-g` | Install in global config | +| {"--force"} | `-f` | Replace existing plugin version | + +--- + +### pr + +Fetch and checkout a GitHub PR branch, then run OpenCode. + +```bash +opencode pr +``` + +--- + +### db + +Database tools. + +```bash +opencode db [query] +``` + +#### Flags + +| Flag | Description | +| -------------------------------------- | ------------------------------ | +| {"--format"} | Output format: `json` or `tsv` | + +--- + +#### path + +Print the database path. + +```bash +opencode db path +``` + +--- + +### debug + +Debugging and troubleshooting tools. + +```bash +opencode debug [command] +``` --- @@ -512,12 +621,12 @@ opencode uninstall #### Flags -| Flag | Short | Description | -| --------------- | ----- | ------------------------------------------- | -| `--keep-config` | `-c` | Keep configuration files | -| `--keep-data` | `-d` | Keep session data and snapshots | -| `--dry-run` | | Show what would be removed without removing | -| `--force` | `-f` | Skip confirmation prompts | +| Flag | Short | Description | +| ------------------------------------------- | ----- | ------------------------------------------- | +| {"--keep-config"} | `-c` | Keep configuration files | +| {"--keep-data"} | `-d` | Keep session data and snapshots | +| {"--dry-run"} | | Show what would be removed without removing | +| {"--force"} | `-f` | Skip confirmation prompts | --- @@ -543,9 +652,9 @@ opencode upgrade v0.1.48 #### Flags -| Flag | Short | Description | -| ---------- | ----- | ----------------------------------------------------------------- | -| `--method` | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | +| Flag | Short | Description | +| -------------------------------------- | ----- | ----------------------------------------------------------------- | +| {"--method"} | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | --- @@ -553,12 +662,13 @@ opencode upgrade v0.1.48 The opencode CLI takes the following global flags. -| Flag | Short | Description | -| -------------- | ----- | ------------------------------------ | -| `--help` | `-h` | Display help | -| `--version` | `-v` | Print version number | -| `--print-logs` | | Print logs to stderr | -| `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) | +| Flag | Short | Description | +| ------------------------------------------ | ----- | ------------------------------------ | +| {"--help"} | `-h` | Display help | +| {"--version"} | `-v` | Print version number | +| {"--print-logs"} | | Print logs to stderr | +| {"--log-level"} | | Log level (DEBUG, INFO, WARN, ERROR) | +| {"--pure"} | | Run without external plugins | --- diff --git a/packages/web/src/content/docs/da/cli.mdx b/packages/web/src/content/docs/da/cli.mdx index 45c4f08e3..02b1b4987 100644 --- a/packages/web/src/content/docs/da/cli.mdx +++ b/packages/web/src/content/docs/da/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flag -| Flag | Kort | Beskrivelse | -| ------------ | ---- | ---------------------------------------------------------------------------- | -| `--continue` | `-c` | Fortsæt sidste session | -| `--session` | `-s` | Sessions-id for at fortsætte | -| `--fork` | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | -| `--prompt` | | Spørg om at bruge | -| `--model` | `-m` | Model til brug i form af provider/model | -| `--agent` | | Agent hos bruge | -| `--port` | | Port at lytte på | -| `--hostname` | | Værtsnavn at lytte på | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Fortsæt sidste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--prompt"} | | Spørg om at bruge | +| {"--model"} | `-m` | Model til brug i form af provider/model | +| {"--agent"} | | Agent hos bruge | +| {"--port"} | | Port at lytte på | +| {"--hostname"} | | Værtsnavn at lytte på | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flag -| Flag | Kort | Beskrivelse | -| ----------- | ---- | -------------------------------- | -| `--dir` | | Arbejdsmappe til at starte TUI i | -| `--session` | `-s` | Sessions-id for at fortsætte | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| {"--dir"} | | Arbejdsmappe til at starte TUI i | +| {"--continue"} | `-c` | Fortsæt den seneste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--password"} | `-p` | Adgangskode til basic auth (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brugernavn til basic auth (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flag -| Flag | Beskrivelse | -| --------- | ---------------------------------------------- | -| `--event` | GitHub mock begivenhed for at køre agenten for | -| `--token` | GitHub personlig adgangstoken | +| Flag | Beskrivelse | +| ------------------------------------- | ---------------------------------------------- | +| {"--event"} | GitHub mock begivenhed for at køre agenten for | +| {"--token"} | GitHub personlig adgangstoken | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flag -| Flag | Beskrivelse | -| ----------- | ----------------------------------------------------------------------- | -| `--refresh` | Opdater modelcachen fra models.dev | -| `--verbose` | Brug mere detaljeret modeloutput (inkluderer metadata som omkostninger) | +| Flag | Beskrivelse | +| --------------------------------------- | ----------------------------------------------------------------------- | +| {"--refresh"} | Opdater modelcachen fra models.dev | +| {"--verbose"} | Brug mere detaljeret modeloutput (inkluderer metadata som omkostninger) | Brug flaget `--refresh` til at opdatere den cachelagrede modelliste. Dette er nyttigt, når nye modeller er blevet tilføjet til en udbyder, og du vil se dem i OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flag -| Flag | Kort | Beskrivelse | -| ------------ | ---- | ----------------------------------------------------------------------------------- | -| `--command` | | Kommandoen til at køre, brug besked til args | -| `--continue` | `-c` | Fortsæt sidste session | -| `--session` | `-s` | Sessions-id for at fortsætte | -| `--fork` | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | -| `--share` | | Del sessionen | -| `--model` | `-m` | Model til brug i form af provider/model | -| `--agent` | | Agent til brug | -| `--file` | `-f` | Fil(er), der skal vedhæftes til meddelelsen | -| `--format` | | Format: standard (formateret) eller json (rå JSON hændelser) | -| `--title` | | Titel for sessionen (bruger trunkeret prompt, hvis der ikke er angivet nogen værdi) | -| `--attach` | | Tilslut til en kørende opencode-server (f.eks. http://localhost:4096) | -| `--port` | | Port til den lokale server (standard til vilkårlig port) | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| {"--command"} | | Kommandoen til at køre, brug besked til args | +| {"--continue"} | `-c` | Fortsæt sidste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--share"} | | Del sessionen | +| {"--model"} | `-m` | Model til brug i form af provider/model | +| {"--agent"} | | Agent til brug | +| {"--file"} | `-f` | Fil(er), der skal vedhæftes til meddelelsen | +| {"--format"} | | Format: standard (formateret) eller json (rå JSON hændelser) | +| {"--title"} | | Titel for sessionen (bruger trunkeret prompt, hvis der ikke er angivet nogen værdi) | +| {"--attach"} | | Tilslut til en kørende opencode-server (f.eks. http://localhost:4096) | +| {"--password"} | `-p` | Adgangskode til basic auth (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brugernavn til basic auth (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | +| {"--dir"} | | Mappe at køre i, eller sti på fjernserveren ved tilkobling | +| {"--variant"} | | Modelvariant (udbyder-specifik ræsonneringsindsats) | +| {"--thinking"} | | Vis tænkeblokke | +| {"--port"} | | Port til den lokale server (standard til vilkårlig port) | --- @@ -364,12 +373,12 @@ Dette starter en HTTP-server, der giver API-adgang til opencode-funktionalitet u #### Flag -| Flag | Beskrivelse | -| ------------ | ------------------------------------------------ | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | -| `--mdns` | Aktiver mDNS-opdagelse | -| `--cors` | Yderligere browseroprindelse til at tillade CORS | +| Flag | Beskrivelse | +| ---------------------------------------- | ------------------------------------------------ | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | +| {"--mdns"} | Aktiver mDNS-opdagelse | +| {"--cors"} | Yderligere browseroprindelse til at tillade CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flag -| Flag | Kort | Beskrivelse | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | Begræns til N seneste sessioner | -| `--format` | | Outputformat: tabel eller json (tabel) | +| Flag | Kort | Beskrivelse | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | Begræns til N seneste sessioner | +| {"--format"} | | Outputformat: tabel eller json (tabel) | --- @@ -410,12 +419,12 @@ opencode stats #### Flag -| Flag | Beskrivelse | -| ----------- | --------------------------------------------------------------------------- | -| `--days` | Vis statistik for de sidste N dage (hele tiden) | -| `--tools` | Antal værktøjer, der skal vises (alle) | -| `--models` | Vis modelbrugsopdeling (skjult som standard). Send et tal for at vise top N | -| `--project` | Filtre efter projekt (alle projekter, tom streng: nuværende projekt) | +| Flag | Beskrivelse | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--days"} | Vis statistik for de sidste N dage (hele tiden) | +| {"--tools"} | Antal værktøjer, der skal vises (alle) | +| {"--models"} | Vis modelbrugsopdeling (skjult som standard). Send et tal for at vise top N | +| {"--project"} | Filtre efter projekt (alle projekter, tom streng: nuværende projekt) | --- @@ -460,12 +469,12 @@ Dette starter en HTTP-server og åbner en webbrowser for at få adgang til OpenC #### Flag -| Flag | Beskrivelse | -| ------------ | ------------------------------------------------ | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | -| `--mdns` | Aktiver mDNS-opdagelse | -| `--cors` | Yderligere browseroprindelse til at tillade CORS | +| Flag | Beskrivelse | +| ---------------------------------------- | ------------------------------------------------ | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | +| {"--mdns"} | Aktiver mDNS-opdagelse | +| {"--cors"} | Yderligere browseroprindelse til at tillade CORS | --- @@ -481,11 +490,11 @@ Denne kommando starter en ACP-server, der kommunikerer via stdin/stdout ved hjæ #### Flag -| Flag | Beskrivelse | -| ------------ | --------------------- | -| `--cwd` | Arbejdsmappe | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | +| Flag | Beskrivelse | +| ---------------------------------------- | --------------------- | +| {"--cwd"} | Arbejdsmappe | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flag -| Flag | Kort | Beskrivelse | -| --------------- | ---- | ------------------------------------------------ | -| `--keep-config` | `-c` | Se konfigurationsfiler | -| `--keep-data` | `-d` | Gem sessionsdata og snapshots | -| `--dry-run` | | Vis, hvad der ville blive fjernet uden at fjerne | -| `--force` | `-f` | Spring bekræftelsesspørgsmål over | +| Flag | Kort | Beskrivelse | +| ------------------------------------------- | ---- | ------------------------------------------------ | +| {"--keep-config"} | `-c` | Se konfigurationsfiler | +| {"--keep-data"} | `-d` | Gem sessionsdata og snapshots | +| {"--dry-run"} | | Vis, hvad der ville blive fjernet uden at fjerne | +| {"--force"} | `-f` | Spring bekræftelsesspørgsmål over | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### upgrade -| Flag | Kort | Beskrivelse | -| ---------- | ---- | ---------------------------------------------------------------- | -| `--method` | `-m` | Installationsmetoden, der blev brugt; curl, npm, pnpm, bun, brew | +| Flag | Kort | Beskrivelse | +| -------------------------------------- | ---- | ---------------------------------------------------------------- | +| {"--method"} | `-m` | Installationsmetoden, der blev brugt; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI tager følgende globale flag. -| Flag | Kort | Beskrivelse | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | Vis hjælp | -| `--version` | `-v` | Udskriftsversionsnummer | -| `--print-logs` | | Udskriv logfiler til stderr | -| `--log-level` | | Logniveau (DEBUG, INFO, WARN, ERROR) | +| Flag | Kort | Beskrivelse | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | Vis hjælp | +| {"--version"} | `-v` | Udskriftsversionsnummer | +| {"--print-logs"} | | Udskriv logfiler til stderr | +| {"--log-level"} | | Logniveau (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/de/cli.mdx b/packages/web/src/content/docs/de/cli.mdx index 43a1189d6..94e9c88fa 100644 --- a/packages/web/src/content/docs/de/cli.mdx +++ b/packages/web/src/content/docs/de/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Optionen -| Flag | Kurz | Beschreibung | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--continue` | `-c` | Setzen Sie die letzte Sitzung fort | -| `--session` | `-s` | Session-ID zum Fortfahren | -| `--fork` | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session`) | -| `--prompt` | | Prompt zur Verwendung | -| `--model` | `-m` | Zu verwendendes Modell in der Form provider/model | -| `--agent` | | Zu verwendender Agent | -| `--port` | | Port zum Abhören | -| `--hostname` | | Hostname zum Abhören | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------- | +| {"--continue"} | `-c` | Setzen Sie die letzte Sitzung fort | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session`) | +| {"--prompt"} | | Prompt zur Verwendung | +| {"--model"} | `-m` | Zu verwendendes Modell in der Form provider/model | +| {"--agent"} | | Zu verwendender Agent | +| {"--port"} | | Port zum Abhören | +| {"--hostname"} | | Hostname zum Abhören | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Optionen -| Flag | Kurz | Beschreibung | -| ----------- | ---- | ----------------------------------------- | -| `--dir` | | Arbeitsverzeichnis zum Starten von TUI in | -| `--session` | `-s` | Session-ID zum Fortfahren | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------------------- | +| {"--dir"} | | Arbeitsverzeichnis zum Starten von TUI in | +| {"--continue"} | `-c` | Letzte Sitzung fortsetzen | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen abzweigen (mit `--continue` oder `--session` verwenden) | +| {"--password"} | `-p` | Basic-Auth-Passwort (standardmäßig `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic-Auth-Benutzername (standardmäßig `OPENCODE_SERVER_USERNAME` oder `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Optionen -| Flag | Beschreibung | -| --------- | --------------------------------------------------- | -| `--event` | GitHub Scheinereignis zum Ausführen des Agenten für | -| `--token` | GitHub persönliches Zugriffstoken | +| Flag | Beschreibung | +| ------------------------------------- | --------------------------------------------------- | +| {"--event"} | GitHub Scheinereignis zum Ausführen des Agenten für | +| {"--token"} | GitHub persönliches Zugriffstoken | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Optionen -| Flag | Beschreibung | -| ----------- | ------------------------------------------------------------------------------------- | -| `--refresh` | Aktualisieren Sie den Modellcache von models.dev | -| `--verbose` | Verwenden Sie eine ausführlichere Modellausgabe (einschließlich Metadaten wie Kosten) | +| Flag | Beschreibung | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--refresh"} | Aktualisieren Sie den Modellcache von models.dev | +| {"--verbose"} | Verwenden Sie eine ausführlichere Modellausgabe (einschließlich Metadaten wie Kosten) | Verwenden Sie das Flag `--refresh`, um die zwischengespeicherte Modellliste zu aktualisieren. Dies ist nützlich, wenn einem Anbieter neue Modelle hinzugefügt wurden und Sie diese in OpenCode sehen möchten. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Optionen -| Flag | Kurz | Beschreibung | -| ------------ | ---- | --------------------------------------------------------------------------------------------------- | -| `--command` | | Der auszuführende Befehl, Argumente als Nachricht verwenden | -| `--continue` | `-c` | Setzen Sie die letzte Sitzung fort | -| `--session` | `-s` | Session-ID zum Fortfahren | -| `--fork` | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session` verwenden) | -| `--share` | | Teilen Sie die Sitzung | -| `--model` | `-m` | Zu verwendendes Modell in der Form provider/model | -| `--agent` | | Zu verwendender Agent | -| `--file` | `-f` | Datei(en) zum Anhängen an die Nachricht | -| `--format` | | Format: default (formatiert) oder json (rohe JSON-Ereignisse) | -| `--title` | | Titel für die Sitzung (verwendet eine verkürzte Eingabeaufforderung, wenn kein Wert angegeben wird) | -| `--attach` | | An einen laufenden OpenCode-Server anschließen (z.B. http://localhost:4096) | -| `--port` | | Port für den lokalen Server (standardmäßig zufälliger Port) | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------------- | +| {"--command"} | | Der auszuführende Befehl, Argumente als Nachricht verwenden | +| {"--continue"} | `-c` | Setzen Sie die letzte Sitzung fort | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session` verwenden) | +| {"--share"} | | Teilen Sie die Sitzung | +| {"--model"} | `-m` | Zu verwendendes Modell in der Form provider/model | +| {"--agent"} | | Zu verwendender Agent | +| {"--file"} | `-f` | Datei(en) zum Anhängen an die Nachricht | +| {"--format"} | | Format: default (formatiert) oder json (rohe JSON-Ereignisse) | +| {"--title"} | | Titel für die Sitzung (verwendet eine verkürzte Eingabeaufforderung, wenn kein Wert angegeben wird) | +| {"--attach"} | | An einen laufenden OpenCode-Server anschließen (z.B. http://localhost:4096) | +| {"--password"} | `-p` | Basic-Auth-Passwort (standardmäßig `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic-Auth-Benutzername (standardmäßig `OPENCODE_SERVER_USERNAME` oder `opencode`) | +| {"--dir"} | | Verzeichnis für die Ausführung, oder Pfad auf dem Remote-Server beim Anhängen | +| {"--variant"} | | Modellvariante (anbieterspezifischer Reasoning-Aufwand) | +| {"--thinking"} | | Denkblöcke anzeigen | +| {"--port"} | | Port für den lokalen Server (standardmäßig zufälliger Port) | --- @@ -364,12 +373,12 @@ Dadurch wird ein HTTP-Server gestartet, der API-Zugriff auf OpenCode-Funktionali #### Optionen -| Flag | Beschreibung | -| ------------ | ----------------------------------------------- | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | -| `--mdns` | mDNS-Discovery aktivieren | -| `--cors` | Zusätzliche Browser-Ursprünge für CORS zulassen | +| Flag | Beschreibung | +| ---------------------------------------- | ----------------------------------------------- | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | +| {"--mdns"} | mDNS-Discovery aktivieren | +| {"--cors"} | Zusätzliche Browser-Ursprünge für CORS zulassen | --- @@ -393,10 +402,10 @@ opencode session list ##### Optionen -| Flag | Kurz | Beschreibung | -| ------------- | ---- | ---------------------------------------- | -| `--max-count` | `-n` | Beschränken auf die N neuesten Sitzungen | -| `--format` | | Ausgabeformat: table oder json (table) | +| Flag | Kurz | Beschreibung | +| ----------------------------------------- | ---- | ---------------------------------------- | +| {"--max-count"} | `-n` | Beschränken auf die N neuesten Sitzungen | +| {"--format"} | | Ausgabeformat: table oder json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### Optionen -| Flag | Beschreibung | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `--days` | Statistiken für die letzten N Tage anzeigen (alle Zeiten) | -| `--tools` | Anzahl der angebotenen Werkzeuge (alle) | -| `--models` | Aufschlüsselung der Modellnutzung anzeigen (standardmäßig ausgeblendet). Übergeben Sie eine Zahl, um die obersten N anzuzeigen | -| `--project` | Nach Projekt filtern (alle Projekte, leere Zeichenfolge: aktuelles Projekt) | +| Flag | Beschreibung | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| {"--days"} | Statistiken für die letzten N Tage anzeigen (alle Zeiten) | +| {"--tools"} | Anzahl der angebotenen Werkzeuge (alle) | +| {"--models"} | Aufschlüsselung der Modellnutzung anzeigen (standardmäßig ausgeblendet). Übergeben Sie eine Zahl, um die obersten N anzuzeigen | +| {"--project"} | Nach Projekt filtern (alle Projekte, leere Zeichenfolge: aktuelles Projekt) | --- @@ -460,12 +469,12 @@ Dadurch wird ein HTTP-Server gestartet und ein Webbrowser geöffnet, um über ei #### Optionen -| Flag | Beschreibung | -| ------------ | ----------------------------------------------- | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | -| `--mdns` | mDNS-Discovery aktivieren | -| `--cors` | Zusätzliche Browser-Ursprünge für CORS zulassen | +| Flag | Beschreibung | +| ---------------------------------------- | ----------------------------------------------- | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | +| {"--mdns"} | mDNS-Discovery aktivieren | +| {"--cors"} | Zusätzliche Browser-Ursprünge für CORS zulassen | --- @@ -481,11 +490,11 @@ Dieser Befehl startet einen ACP-Server, der über stdin/stdout unter Verwendung #### Optionen -| Flag | Beschreibung | -| ------------ | -------------------- | -| `--cwd` | Arbeitsverzeichnis | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | +| Flag | Beschreibung | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | Arbeitsverzeichnis | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | --- @@ -499,12 +508,12 @@ opencode uninstall #### Optionen -| Flag | Kurz | Beschreibung | -| --------------- | ---- | --------------------------------------------------- | -| `--keep-config` | `-c` | Konfigurationsdateien behalten | -| `--keep-data` | `-d` | Sitzungsdaten und Snapshots aufbewahren | -| `--dry-run` | | Zeigt, was entfernt werden würde, ohne zu entfernen | -| `--force` | `-f` | Bestätigungsaufforderungen überspringen | +| Flag | Kurz | Beschreibung | +| ------------------------------------------- | ---- | --------------------------------------------------- | +| {"--keep-config"} | `-c` | Konfigurationsdateien behalten | +| {"--keep-data"} | `-d` | Sitzungsdaten und Snapshots aufbewahren | +| {"--dry-run"} | | Zeigt, was entfernt werden würde, ohne zu entfernen | +| {"--force"} | `-f` | Bestätigungsaufforderungen überspringen | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Optionen -| Flag | Kurz | Beschreibung | -| ---------- | ---- | ------------------------------------------------------------------- | -| `--method` | `-m` | Die zu verwendende Installationsmethode; curl, npm, pnpm, bun, brew | +| Flag | Kurz | Beschreibung | +| -------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--method"} | `-m` | Die zu verwendende Installationsmethode; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 Der OpenCode CLI akzeptiert die folgenden globalen Flags. -| Flag | Kurz | Beschreibung | -| -------------- | ---- | ----------------------------------------- | -| `--help` | `-h` | Hilfe anzeigen | -| `--version` | `-v` | Versionsnummer drucken | -| `--print-logs` | | Protokolle nach stderr drucken | -| `--log-level` | | Protokollebene (DEBUG, INFO, WARN, ERROR) | +| Flag | Kurz | Beschreibung | +| ------------------------------------------ | ---- | ----------------------------------------- | +| {"--help"} | `-h` | Hilfe anzeigen | +| {"--version"} | `-v` | Versionsnummer drucken | +| {"--print-logs"} | | Protokolle nach stderr drucken | +| {"--log-level"} | | Protokollebene (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/es/cli.mdx b/packages/web/src/content/docs/es/cli.mdx index 5c86474a6..6b66e7b5f 100644 --- a/packages/web/src/content/docs/es/cli.mdx +++ b/packages/web/src/content/docs/es/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opciones -| Opción | Corta | Descripción | -| ------------ | ----- | --------------------------------------------------------------------- | -| `--continue` | `-c` | Continuar la última sesión | -| `--session` | `-s` | ID de sesión para continuar | -| `--fork` | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | -| `--prompt` | | Aviso de uso | -| `--model` | `-m` | Modelo a utilizar en forma de proveedor/modelo | -| `--agent` | | Agente a utilizar | -| `--port` | | Puerto para escuchar | -| `--hostname` | | Nombre de host para escuchar | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | --------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--prompt"} | | Aviso de uso | +| {"--model"} | `-m` | Modelo a utilizar en forma de proveedor/modelo | +| {"--agent"} | | Agente a utilizar | +| {"--port"} | | Puerto para escuchar | +| {"--hostname"} | | Nombre de host para escuchar | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opciones -| Opción | Corta | Descripción | -| ----------- | ----- | ----------------------------------------- | -| `--dir` | | Directorio de trabajo para iniciar TUI en | -| `--session` | `-s` | ID de sesión para continuar | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------- | +| {"--dir"} | | Directorio de trabajo para iniciar TUI en | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--password"} | `-p` | Contraseña de autenticación básica (predeterminada: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuario de autenticación básica (predeterminado: `OPENCODE_SERVER_USERNAME` u `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opciones -| Opción | Descripción | -| --------- | ---------------------------------------------- | -| `--event` | GitHub evento simulado para ejecutar el agente | -| `--token` | GitHub token de acceso personal | +| Opción | Descripción | +| ------------------------------------- | ---------------------------------------------- | +| {"--event"} | GitHub evento simulado para ejecutar el agente | +| {"--token"} | GitHub token de acceso personal | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Opciones -| Opción | Descripción | -| ----------- | --------------------------------------------------------------------------- | -| `--refresh` | Actualizar la caché de modelos desde models.dev | -| `--verbose` | Utilice una salida del modelo más detallada (incluye metadatos como costos) | +| Opción | Descripción | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--refresh"} | Actualizar la caché de modelos desde models.dev | +| {"--verbose"} | Utilice una salida del modelo más detallada (incluye metadatos como costos) | Utilice el indicador `--refresh` para actualizar la lista de modelos almacenados en caché. Esto es útil cuando se han agregado nuevos modelos a un proveedor y desea verlos en OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Opciones -| Opción | Corta | Descripción | -| ------------ | ----- | ----------------------------------------------------------------------------------- | -| `--command` | | El comando a ejecutar, use mensaje para args | -| `--continue` | `-c` | Continuar la última sesión | -| `--session` | `-s` | ID de sesión para continuar | -| `--fork` | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | -| `--share` | | Comparte la sesión | -| `--model` | `-m` | Modelo a utilizar en forma de proveedor/modelo | -| `--agent` | | Agente a utilizar | -| `--file` | `-f` | Archivo(s) para adjuntar al mensaje | -| `--format` | | Formato: predeterminado (formateado) o json (eventos JSON sin formato) | -| `--title` | | Título de la sesión (utiliza un mensaje truncado si no se proporciona ningún valor) | -| `--attach` | | Adjuntar a un servidor opencode en ejecución (por ejemplo, http://localhost:4096) | -| `--port` | | Puerto para el servidor local (el puerto predeterminado es aleatorio) | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------- | +| {"--command"} | | El comando a ejecutar, use mensaje para args | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--share"} | | Comparte la sesión | +| {"--model"} | `-m` | Modelo a utilizar en forma de proveedor/modelo | +| {"--agent"} | | Agente a utilizar | +| {"--file"} | `-f` | Archivo(s) para adjuntar al mensaje | +| {"--format"} | | Formato: predeterminado (formateado) o json (eventos JSON sin formato) | +| {"--title"} | | Título de la sesión (utiliza un mensaje truncado si no se proporciona ningún valor) | +| {"--attach"} | | Adjuntar a un servidor opencode en ejecución (por ejemplo, http://localhost:4096) | +| {"--password"} | `-p` | Contraseña de autenticación básica (predeterminada: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuario de autenticación básica (predeterminado: `OPENCODE_SERVER_USERNAME` u `opencode`) | +| {"--dir"} | | Directorio de ejecución, o ruta en el servidor remoto al adjuntar | +| {"--variant"} | | Variante del modelo (esfuerzo de razonamiento específico del proveedor) | +| {"--thinking"} | | Mostrar bloques de pensamiento | +| {"--port"} | | Puerto para el servidor local (el puerto predeterminado es aleatorio) | --- @@ -364,12 +373,12 @@ Esto inicia un servidor HTTP que proporciona acceso API a la funcionalidad openc #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------------------------------- | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | -| `--mdns` | Habilitar el descubrimiento de mDNS | -| `--cors` | Orígenes de navegador adicionales para permitir CORS | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------------------------------- | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | +| {"--mdns"} | Habilitar el descubrimiento de mDNS | +| {"--cors"} | Orígenes de navegador adicionales para permitir CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Opciones -| Opción | Corta | Descripción | -| ------------- | ----- | --------------------------------------- | -| `--max-count` | `-n` | Limitar a N sesiones más recientes | -| `--format` | | Formato de salida: tabla o json (tabla) | +| Opción | Corta | Descripción | +| ----------------------------------------- | ----- | --------------------------------------- | +| {"--max-count"} | `-n` | Limitar a N sesiones más recientes | +| {"--format"} | | Formato de salida: tabla o json (tabla) | --- @@ -410,12 +419,12 @@ opencode stats #### Opciones -| Opción | Descripción | -| ----------- | ------------------------------------------------------------------------------------------------------------------------ | -| `--days` | Mostrar estadísticas de los últimos N días (todo el tiempo) | -| `--tools` | Número de herramientas para mostrar (todas) | -| `--models` | Mostrar el desglose del uso del modelo (oculto de forma predeterminada). Pase un número para mostrar la parte superior N | -| `--project` | Filtrar por proyecto (todos los proyectos, cadena vacía: proyecto actual) | +| Opción | Descripción | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| {"--days"} | Mostrar estadísticas de los últimos N días (todo el tiempo) | +| {"--tools"} | Número de herramientas para mostrar (todas) | +| {"--models"} | Mostrar el desglose del uso del modelo (oculto de forma predeterminada). Pase un número para mostrar la parte superior N | +| {"--project"} | Filtrar por proyecto (todos los proyectos, cadena vacía: proyecto actual) | --- @@ -460,12 +469,12 @@ Esto inicia un servidor HTTP y abre un navegador web para acceder a OpenCode a t #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------------------------------- | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | -| `--mdns` | Habilitar el descubrimiento de mDNS | -| `--cors` | Orígenes de navegador adicionales para permitir CORS | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------------------------------- | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | +| {"--mdns"} | Habilitar el descubrimiento de mDNS | +| {"--cors"} | Orígenes de navegador adicionales para permitir CORS | --- @@ -481,11 +490,11 @@ Este comando inicia un servidor ACP que se comunica a través de stdin/stdout us #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------- | -| `--cwd` | Directorio de trabajo | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------- | +| {"--cwd"} | Directorio de trabajo | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | --- @@ -499,12 +508,12 @@ opencode uninstall #### Opciones -| Opción | Corta | Descripción | -| --------------- | ----- | ----------------------------------------- | -| `--keep-config` | `-c` | Mantener archivos de configuración | -| `--keep-data` | `-d` | Conservar datos de sesión e instantáneas | -| `--dry-run` | | Mostrar lo que se eliminaría sin eliminar | -| `--force` | `-f` | Saltar mensajes de confirmación | +| Opción | Corta | Descripción | +| ------------------------------------------- | ----- | ----------------------------------------- | +| {"--keep-config"} | `-c` | Mantener archivos de configuración | +| {"--keep-data"} | `-d` | Conservar datos de sesión e instantáneas | +| {"--dry-run"} | | Mostrar lo que se eliminaría sin eliminar | +| {"--force"} | `-f` | Saltar mensajes de confirmación | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Opciones -| Opción | Corta | Descripción | -| ---------- | ----- | ------------------------------------------------------------------- | -| `--method` | `-m` | El método de instalación que se utilizó; curl, npm, pnpm, bun, brew | +| Opción | Corta | Descripción | +| -------------------------------------- | ----- | ------------------------------------------------------------------- | +| {"--method"} | `-m` | El método de instalación que se utilizó; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI de OpenCode toma las siguientes banderas globales. -| Opción | Corta | Descripción | -| -------------- | ----- | -------------------------------------------- | -| `--help` | `-h` | Mostrar ayuda | -| `--version` | `-v` | Número de versión de impresión | -| `--print-logs` | | Imprimir registros en stderr | -| `--log-level` | | Nivel de registro (DEBUG, INFO, WARN, ERROR) | +| Opción | Corta | Descripción | +| ------------------------------------------ | ----- | -------------------------------------------- | +| {"--help"} | `-h` | Mostrar ayuda | +| {"--version"} | `-v` | Número de versión de impresión | +| {"--print-logs"} | | Imprimir registros en stderr | +| {"--log-level"} | | Nivel de registro (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/fr/cli.mdx b/packages/web/src/content/docs/fr/cli.mdx index cffa748ad..c5455654a 100644 --- a/packages/web/src/content/docs/fr/cli.mdx +++ b/packages/web/src/content/docs/fr/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Options -| Option | Court | Description | -| ------------ | ----- | ----------------------------------------------------------------------------- | -| `--continue` | `-c` | Continuer la dernière session | -| `--session` | `-s` | ID de session pour continuer | -| `--fork` | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | -| `--prompt` | | Prompt à utiliser | -| `--model` | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | -| `--agent` | | Agent à utiliser | -| `--port` | | Port d'écoute | -| `--hostname` | | Nom d'hôte d'écoute | +| Option | Court | Description | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | +| {"--prompt"} | | Prompt à utiliser | +| {"--model"} | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | +| {"--agent"} | | Agent à utiliser | +| {"--port"} | | Port d'écoute | +| {"--hostname"} | | Nom d'hôte d'écoute | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Options -| Option | Court | Description | -| ----------- | ----- | ---------------------------------------------- | -| `--dir` | | Répertoire de travail dans lequel démarrer TUI | -| `--session` | `-s` | ID de session pour continuer | +| Option | Court | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Répertoire de travail dans lequel démarrer TUI | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Dupliquer la session lors de la reprise (à utiliser avec `--continue` ou `--session`) | +| {"--password"} | `-p` | Mot de passe d'authentification de base (par défaut `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nom d'utilisateur d'authentification de base (par défaut `OPENCODE_SERVER_USERNAME` ou `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Options -| Option | Description | -| --------- | ---------------------------------------------------- | -| `--event` | Événement simulé GitHub pour lequel exécuter l'agent | -| `--token` | Jeton d'accès personnel GitHub | +| Option | Description | +| ------------------------------------- | ---------------------------------------------------- | +| {"--event"} | Événement simulé GitHub pour lequel exécuter l'agent | +| {"--token"} | Jeton d'accès personnel GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Options -| Option | Description | -| ----------- | ------------------------------------------------------------------------------------------ | -| `--refresh` | Actualisez le cache des modèles à partir de models.dev | -| `--verbose` | Utiliser une sortie de modèle plus détaillée (inclut des métadonnées telles que les coûts) | +| Option | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| {"--refresh"} | Actualisez le cache des modèles à partir de models.dev | +| {"--verbose"} | Utiliser une sortie de modèle plus détaillée (inclut des métadonnées telles que les coûts) | Utilisez l'option `--refresh` pour mettre à jour la liste des modèles mis en cache. Ceci est utile lorsque de nouveaux modèles ont été ajoutés à un fournisseur et que vous souhaitez les voir dans OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Options -| Option | Court | Description | -| ------------ | ----- | ---------------------------------------------------------------------------------------------- | -| `--command` | | La commande à exécuter, utilisez le message pour les arguments | -| `--continue` | `-c` | Continuer la dernière session | -| `--session` | `-s` | ID de session pour continuer | -| `--fork` | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | -| `--share` | | Partager la session | -| `--model` | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | -| `--agent` | | Agent à utiliser | -| `--file` | `-f` | Fichier(s) à joindre au message | -| `--format` | | Format : par défaut (formaté) ou json (événements JSON bruts) | -| `--title` | | Titre de la session (utilise un prompt tronqué si aucune valeur n'est fournie) | -| `--attach` | | Connectez-vous à un serveur opencode en cours d'exécution (par exemple, http://localhost:4096) | -| `--port` | | Port du serveur local (port aléatoire par défaut) | +| Option | Court | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------------------------------- | +| {"--command"} | | La commande à exécuter, utilisez le message pour les arguments | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | +| {"--share"} | | Partager la session | +| {"--model"} | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | +| {"--agent"} | | Agent à utiliser | +| {"--file"} | `-f` | Fichier(s) à joindre au message | +| {"--format"} | | Format : par défaut (formaté) ou json (événements JSON bruts) | +| {"--title"} | | Titre de la session (utilise un prompt tronqué si aucune valeur n'est fournie) | +| {"--attach"} | | Connectez-vous à un serveur opencode en cours d'exécution (par exemple, http://localhost:4096) | +| {"--password"} | `-p` | Mot de passe d'authentification de base (par défaut `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nom d'utilisateur d'authentification de base (par défaut `OPENCODE_SERVER_USERNAME` ou `opencode`) | +| {"--dir"} | | Répertoire d'exécution, ou chemin sur le serveur distant lors de l'attachement | +| {"--variant"} | | Variante du modèle (effort de raisonnement spécifique au fournisseur) | +| {"--thinking"} | | Afficher les blocs de réflexion | +| {"--port"} | | Port du serveur local (port aléatoire par défaut) | --- @@ -364,12 +373,12 @@ Cela démarre un serveur HTTP qui fournit un accès API aux fonctionnalités d'O #### Options -| Option | Description | -| ------------ | ---------------------------------------------------------- | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | -| `--mdns` | Activer la découverte mDNS | -| `--cors` | Origines de navigateur supplémentaires pour autoriser CORS | +| Option | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | +| {"--mdns"} | Activer la découverte mDNS | +| {"--cors"} | Origines de navigateur supplémentaires pour autoriser CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Options -| Option | Court | Description | -| ------------- | ----- | -------------------------------------------- | -| `--max-count` | `-n` | Limiter aux N sessions les plus récentes | -| `--format` | | Format de sortie : tableau ou json (tableau) | +| Option | Court | Description | +| ----------------------------------------- | ----- | -------------------------------------------- | +| {"--max-count"} | `-n` | Limiter aux N sessions les plus récentes | +| {"--format"} | | Format de sortie : tableau ou json (tableau) | --- @@ -410,12 +419,12 @@ opencode stats #### Options -| Option | Description | -| ----------- | --------------------------------------------------------------------------------------------------------------------- | -| `--days` | Afficher les statistiques des N derniers jours (depuis le début) | -| `--tools` | Nombre d'outils à afficher (tous) | -| `--models` | Afficher la répartition de l'utilisation du modèle (masqué par défaut). Passez un numéro pour afficher les N premiers | -| `--project` | Filtrer par projet (tous les projets, chaîne vide : projet actuel) | +| Option | Description | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| {"--days"} | Afficher les statistiques des N derniers jours (depuis le début) | +| {"--tools"} | Nombre d'outils à afficher (tous) | +| {"--models"} | Afficher la répartition de l'utilisation du modèle (masqué par défaut). Passez un numéro pour afficher les N premiers | +| {"--project"} | Filtrer par projet (tous les projets, chaîne vide : projet actuel) | --- @@ -460,12 +469,12 @@ Cela démarre un serveur HTTP et ouvre un navigateur Web pour accéder à OpenCo #### Options -| Option | Description | -| ------------ | ---------------------------------------------------------- | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | -| `--mdns` | Activer la découverte mDNS | -| `--cors` | Origines de navigateur supplémentaires pour autoriser CORS | +| Option | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | +| {"--mdns"} | Activer la découverte mDNS | +| {"--cors"} | Origines de navigateur supplémentaires pour autoriser CORS | --- @@ -481,11 +490,11 @@ Cette commande démarre un serveur ACP qui communique via stdin/stdout en utilis #### Options -| Option | Description | -| ------------ | --------------------- | -| `--cwd` | Répertoire de travail | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | +| Option | Description | +| ---------------------------------------- | --------------------- | +| {"--cwd"} | Répertoire de travail | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | --- @@ -499,12 +508,12 @@ opencode uninstall #### Options -| Option | Court | Description | -| --------------- | ----- | --------------------------------------------------- | -| `--keep-config` | `-c` | Conserver les fichiers de configuration | -| `--keep-data` | `-d` | Conserver les données de session et les instantanés | -| `--dry-run` | | Afficher ce qui serait supprimé sans supprimer | -| `--force` | `-f` | Ignorer les invites de confirmation | +| Option | Court | Description | +| ------------------------------------------- | ----- | --------------------------------------------------- | +| {"--keep-config"} | `-c` | Conserver les fichiers de configuration | +| {"--keep-data"} | `-d` | Conserver les données de session et les instantanés | +| {"--dry-run"} | | Afficher ce qui serait supprimé sans supprimer | +| {"--force"} | `-f` | Ignorer les invites de confirmation | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Options -| Option | Court | Description | -| ---------- | ----- | --------------------------------------------------------------- | -| `--method` | `-m` | La méthode d'installation utilisée ; curl, npm, pnpm, bun, brew | +| Option | Court | Description | +| -------------------------------------- | ----- | --------------------------------------------------------------- | +| {"--method"} | `-m` | La méthode d'installation utilisée ; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI opencode prend les flags globaux suivants. -| Option | Court | Description | -| -------------- | ----- | ---------------------------------------- | -| `--help` | `-h` | Afficher l'aide | -| `--version` | `-v` | Afficher le numéro de version | -| `--print-logs` | | Afficher les logs sur stderr | -| `--log-level` | | Niveau de log (DEBUG, INFO, WARN, ERROR) | +| Option | Court | Description | +| ------------------------------------------ | ----- | ---------------------------------------- | +| {"--help"} | `-h` | Afficher l'aide | +| {"--version"} | `-v` | Afficher le numéro de version | +| {"--print-logs"} | | Afficher les logs sur stderr | +| {"--log-level"} | | Niveau de log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/it/cli.mdx b/packages/web/src/content/docs/it/cli.mdx index 973eb0d98..6fd3d5676 100644 --- a/packages/web/src/content/docs/it/cli.mdx +++ b/packages/web/src/content/docs/it/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flag -| Flag | Breve | Descrizione | -| ------------ | ----- | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Continua l'ultima sessione | -| `--session` | `-s` | ID sessione da continuare | -| `--fork` | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | -| `--prompt` | | Prompt da usare | -| `--model` | `-m` | Modello nel formato provider/model | -| `--agent` | | Agente da usare | -| `--port` | | Porta su cui mettersi in ascolto | -| `--hostname` | | Hostname su cui mettersi in ascolto | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | +| {"--prompt"} | | Prompt da usare | +| {"--model"} | `-m` | Modello nel formato provider/model | +| {"--agent"} | | Agente da usare | +| {"--port"} | | Porta su cui mettersi in ascolto | +| {"--hostname"} | | Hostname su cui mettersi in ascolto | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flag -| Flag | Breve | Descrizione | -| ----------- | ----- | --------------------------------------- | -| `--dir` | | Working directory in cui avviare la TUI | -| `--session` | `-s` | ID sessione da continuare | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------- | +| {"--dir"} | | Working directory in cui avviare la TUI | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Crea un fork della sessione durante la continuazione (usa con `--continue` o `--session`) | +| {"--password"} | `-p` | Password per l'autenticazione di base (predefinita: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nome utente per l'autenticazione di base (predefinito: `OPENCODE_SERVER_USERNAME` o `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flag -| Flag | Descrizione | -| --------- | -------------------------------------------- | -| `--event` | Evento GitHub mock per cui eseguire l'agente | -| `--token` | GitHub personal access token | +| Flag | Descrizione | +| ------------------------------------- | -------------------------------------------- | +| {"--event"} | Evento GitHub mock per cui eseguire l'agente | +| {"--token"} | GitHub personal access token | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flag -| Flag | Descrizione | -| ----------- | -------------------------------------------------- | -| `--refresh` | Aggiorna la cache modelli da models.dev | -| `--verbose` | Output più verboso (include metadati come i costi) | +| Flag | Descrizione | +| --------------------------------------- | -------------------------------------------------- | +| {"--refresh"} | Aggiorna la cache modelli da models.dev | +| {"--verbose"} | Output più verboso (include metadati come i costi) | Usa `--refresh` per aggiornare l'elenco modelli in cache. È utile quando nuovi modelli vengono aggiunti a un provider e vuoi vederli in OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flag -| Flag | Breve | Descrizione | -| ------------ | ----- | ------------------------------------------------------------------------ | -| `--command` | | Il comando da eseguire; usa message per gli argomenti | -| `--continue` | `-c` | Continua l'ultima sessione | -| `--session` | `-s` | ID sessione da continuare | -| `--fork` | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | -| `--share` | | Condividi la sessione | -| `--model` | `-m` | Modello nel formato provider/model | -| `--agent` | | Agente da usare | -| `--file` | `-f` | File da allegare al messaggio | -| `--format` | | Formato: default (formattato) o json (eventi JSON grezzi) | -| `--title` | | Titolo sessione (usa prompt troncato se non viene fornito un valore) | -| `--attach` | | Attach a un server opencode in esecuzione (es. http://localhost:4096) | -| `--port` | | Porta per il server locale (di default una porta casuale) | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------- | +| {"--command"} | | Il comando da eseguire; usa message per gli argomenti | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | +| {"--share"} | | Condividi la sessione | +| {"--model"} | `-m` | Modello nel formato provider/model | +| {"--agent"} | | Agente da usare | +| {"--file"} | `-f` | File da allegare al messaggio | +| {"--format"} | | Formato: default (formattato) o json (eventi JSON grezzi) | +| {"--title"} | | Titolo sessione (usa prompt troncato se non viene fornito un valore) | +| {"--attach"} | | Attach a un server opencode in esecuzione (es. http://localhost:4096) | +| {"--password"} | `-p` | Password per l'autenticazione di base (predefinita: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nome utente per l'autenticazione di base (predefinito: `OPENCODE_SERVER_USERNAME` o `opencode`) | +| {"--dir"} | | Directory di esecuzione, o percorso sul server remoto durante il collegamento | +| {"--variant"} | | Variante del modello (sforzo di ragionamento specifico del provider) | +| {"--thinking"} | | Mostra blocchi di pensiero | +| {"--port"} | | Porta per il server locale (di default una porta casuale) | --- @@ -364,12 +373,12 @@ Avvia un server HTTP che espone accesso API alle funzionalità di opencode senza #### Flag -| Flag | Descrizione | -| ------------ | ---------------------------------------------- | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | -| `--mdns` | Abilita discovery mDNS | -| `--cors` | Origin browser addizionali per consentire CORS | +| Flag | Descrizione | +| ---------------------------------------- | ---------------------------------------------- | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | +| {"--mdns"} | Abilita discovery mDNS | +| {"--cors"} | Origin browser addizionali per consentire CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flag -| Flag | Breve | Descrizione | -| ------------- | ----- | ------------------------------------ | -| `--max-count` | `-n` | Limita alle N sessioni più recenti | -| `--format` | | Formato output: table o json (table) | +| Flag | Breve | Descrizione | +| ----------------------------------------- | ----- | ------------------------------------ | +| {"--max-count"} | `-n` | Limita alle N sessioni più recenti | +| {"--format"} | | Formato output: table o json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### Flag -| Flag | Descrizione | -| ----------- | ------------------------------------------------------------------------------------- | -| `--days` | Mostra statistiche per gli ultimi N giorni (all time) | -| `--tools` | Numero di strumenti da mostrare (all) | -| `--models` | Mostra breakdown di utilizzo modelli (nascosto di default). Passa un numero per top N | -| `--project` | Filtra per progetto (tutti i progetti; stringa vuota: progetto corrente) | +| Flag | Descrizione | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--days"} | Mostra statistiche per gli ultimi N giorni (all time) | +| {"--tools"} | Numero di strumenti da mostrare (all) | +| {"--models"} | Mostra breakdown di utilizzo modelli (nascosto di default). Passa un numero per top N | +| {"--project"} | Filtra per progetto (tutti i progetti; stringa vuota: progetto corrente) | --- @@ -460,12 +469,12 @@ Avvia un server HTTP e apre un browser per accedere a OpenCode tramite interfacc #### Flag -| Flag | Descrizione | -| ------------ | ---------------------------------------------- | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | -| `--mdns` | Abilita discovery mDNS | -| `--cors` | Origin browser addizionali per consentire CORS | +| Flag | Descrizione | +| ---------------------------------------- | ---------------------------------------------- | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | +| {"--mdns"} | Abilita discovery mDNS | +| {"--cors"} | Origin browser addizionali per consentire CORS | --- @@ -481,11 +490,11 @@ Questo comando avvia un server ACP che comunica via stdin/stdout usando nd-JSON. #### Flag -| Flag | Descrizione | -| ------------ | ----------------------------------- | -| `--cwd` | Directory di lavoro | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | +| Flag | Descrizione | +| ---------------------------------------- | ----------------------------------- | +| {"--cwd"} | Directory di lavoro | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flag -| Flag | Breve | Descrizione | -| --------------- | ----- | -------------------------------------------- | -| `--keep-config` | `-c` | Mantieni i file di configurazione | -| `--keep-data` | `-d` | Mantieni dati di sessione e snapshot | -| `--dry-run` | | Mostra cosa verrebbe rimosso senza rimuovere | -| `--force` | `-f` | Salta le richieste di conferma | +| Flag | Breve | Descrizione | +| ------------------------------------------- | ----- | -------------------------------------------- | +| {"--keep-config"} | `-c` | Mantieni i file di configurazione | +| {"--keep-data"} | `-d` | Mantieni dati di sessione e snapshot | +| {"--dry-run"} | | Mostra cosa verrebbe rimosso senza rimuovere | +| {"--force"} | `-f` | Salta le richieste di conferma | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flag -| Flag | Breve | Descrizione | -| ---------- | ----- | --------------------------------------------------------- | -| `--method` | `-m` | Metodo di installazione usato: curl, npm, pnpm, bun, brew | +| Flag | Breve | Descrizione | +| -------------------------------------- | ----- | --------------------------------------------------------- | +| {"--method"} | `-m` | Metodo di installazione usato: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI di opencode accetta i seguenti flag globali. -| Flag | Breve | Descrizione | -| -------------- | ----- | -------------------------------------- | -| `--help` | `-h` | Mostra l'help | -| `--version` | `-v` | Stampa il numero di versione | -| `--print-logs` | | Stampa i log su stderr | -| `--log-level` | | Livello log (DEBUG, INFO, WARN, ERROR) | +| Flag | Breve | Descrizione | +| ------------------------------------------ | ----- | -------------------------------------- | +| {"--help"} | `-h` | Mostra l'help | +| {"--version"} | `-v` | Stampa il numero di versione | +| {"--print-logs"} | | Stampa i log su stderr | +| {"--log-level"} | | Livello log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ja/cli.mdx b/packages/web/src/content/docs/ja/cli.mdx index 82a8852ea..120803627 100644 --- a/packages/web/src/content/docs/ja/cli.mdx +++ b/packages/web/src/content/docs/ja/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### フラグ -| フラグ | ショート | 説明 | -| ------------ | ----------- | ---------------------------------------------------------- | -| `--continue` | `-c` | 最後のセッションを続行 | -| `--session` | | 続行時にセッションをフォーク (`-s` または `--fork` と併用) | -| `--continue` | `--session` | 続行するセッション ID | -| `--prompt` | | 使用のプロンプト | -| `--model` | `-m` | プロバイダー/モデルの形式で使用するモデル | -| `--agent` | | 使用するエージェント | -| `--port` | | リッスンするポート | -| `--hostname` | | リッスンするホスト名 | +| フラグ | ショート | 説明 | +| ---------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | | 続行時にセッションをフォーク (`-s` または `--fork` と併用) | +| {"--continue"} | {"--session"} | 続行するセッション ID | +| {"--prompt"} | | 使用のプロンプト | +| {"--model"} | `-m` | プロバイダー/モデルの形式で使用するモデル | +| {"--agent"} | | 使用するエージェント | +| {"--port"} | | リッスンするポート | +| {"--hostname"} | | リッスンするホスト名 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### フラグ -| フラグ | ショート | 説明 | -| ----------- | -------- | ------------------------------ | -| `--dir` | | TUI を開始する作業ディレクトリ | -| `--session` | `-s` | 続行するセッション ID | +| フラグ | ショート | 説明 | +| ---------------------------------------- | -------- | --------------------------------------------------------------------------------- | +| {"--dir"} | | TUI を開始する作業ディレクトリ | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | `-s` | 続行するセッション ID | +| {"--fork"} | | 続行時にセッションをフォーク(`--continue` または `--session` と使用) | +| {"--password"} | `-p` | Basic 認証パスワード(デフォルトは `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic 認証ユーザー名(デフォルトは `OPENCODE_SERVER_USERNAME` または `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### フラグ -| フラグ | 説明 | -| --------- | --------------------------------------------------- | -| `--event` | エージェントを実行するための GitHub モック イベント | -| `--token` | GitHub 個人アクセストークン | +| フラグ | 説明 | +| ------------------------------------- | --------------------------------------------------- | +| {"--event"} | エージェントを実行するための GitHub モック イベント | +| {"--token"} | GitHub 個人アクセストークン | --- @@ -296,10 +300,10 @@ opencode models anthropic #### フラグ -| フラグ | 説明 | -| ----------- | --------------------------------------------------------------- | -| `--refresh` | models.dev からモデルキャッシュを更新します。 | -| `--verbose` | より詳細なモデル出力を使用します (コストなどのメタデータを含む) | +| フラグ | 説明 | +| --------------------------------------- | --------------------------------------------------------------- | +| {"--refresh"} | models.dev からモデルキャッシュを更新します。 | +| {"--verbose"} | より詳細なモデル出力を使用します (コストなどのメタデータを含む) | `--refresh` フラグを使用して、キャッシュされたモデルリストを更新します。これは、新しいモデルがプロバイダーに追加され、それを OpenCode で確認したい場合に便利です。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### フラグ -| フラグ | ショート | 説明 | -| ------------ | -------- | ----------------------------------------------------------------------------------------- | -| `--command` | | 実行するコマンド。引数には message を使用します。 | -| `--continue` | `-c` | 最後のセッションを続行 | -| `--session` | `-s` | 続行するセッション ID | -| `--fork` | | 続行時にセッションをフォーク (`--continue` または `--session` と併用) | -| `--share` | | セッションを共有する | -| `--model` | `-m` | プロバイダー/モデルの形式で使用するモデル | -| `--agent` | | 使用するエージェント | -| `--file` | `-f` | メッセージに添付するファイル | -| `--format` | | 形式: デフォルト (フォーマット済み) または json (生の JSON イベント) | -| `--title` | | セッションのタイトル (値が指定されていない場合は、切り詰められたプロンプトが使用されます) | -| `--attach` | | 実行中の opencode サーバー (http://localhost:4096 など) に接続します。 | -| `--port` | | ローカルサーバーのポート (デフォルトはランダムポート) | +| フラグ | ショート | 説明 | +| ---------------------------------------- | -------- | ----------------------------------------------------------------------------------------- | +| {"--command"} | | 実行するコマンド。引数には message を使用します。 | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | `-s` | 続行するセッション ID | +| {"--fork"} | | 続行時にセッションをフォーク (`--continue` または `--session` と併用) | +| {"--share"} | | セッションを共有する | +| {"--model"} | `-m` | プロバイダー/モデルの形式で使用するモデル | +| {"--agent"} | | 使用するエージェント | +| {"--file"} | `-f` | メッセージに添付するファイル | +| {"--format"} | | 形式: デフォルト (フォーマット済み) または json (生の JSON イベント) | +| {"--title"} | | セッションのタイトル (値が指定されていない場合は、切り詰められたプロンプトが使用されます) | +| {"--attach"} | | 実行中の opencode サーバー (http://localhost:4096 など) に接続します。 | +| {"--password"} | `-p` | Basic 認証パスワード(デフォルトは `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic 認証ユーザー名(デフォルトは `OPENCODE_SERVER_USERNAME` または `opencode`) | +| {"--dir"} | | 実行ディレクトリ、またはアタッチ時のリモートサーバー上のパス | +| {"--variant"} | | モデルバリアント(プロバイダー固有の推論レベル) | +| {"--thinking"} | | 思考ブロックを表示 | +| {"--port"} | | ローカルサーバーのポート (デフォルトはランダムポート) | --- @@ -364,12 +373,12 @@ opencode serve #### フラグ -| フラグ | 説明 | -| ------------ | --------------------------------------- | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | -| `--mdns` | mDNS 検出を有効にする | -| `--cors` | CORS を許可する追加のブラウザーオリジン | +| フラグ | 説明 | +| ---------------------------------------- | --------------------------------------- | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | +| {"--mdns"} | mDNS 検出を有効にする | +| {"--cors"} | CORS を許可する追加のブラウザーオリジン | --- @@ -393,10 +402,10 @@ opencode session list ##### フラグ -| フラグ | ショート | 説明 | -| ------------- | -------- | ---------------------------------------- | -| `--max-count` | `-n` | 最新のセッションを N 個に制限 | -| `--format` | | 出力形式: テーブルまたは json (テーブル) | +| フラグ | ショート | 説明 | +| ----------------------------------------- | -------- | ---------------------------------------- | +| {"--max-count"} | `-n` | 最新のセッションを N 個に制限 | +| {"--format"} | | 出力形式: テーブルまたは json (テーブル) | --- @@ -410,12 +419,12 @@ opencode stats #### フラグ -| フラグ | 説明 | -| ----------- | ------------------------------------------------------------------------------------------ | -| `--days` | 過去 N 日間の統計を表示 (全期間) | -| `--tools` | 表示するツールの数 (すべて) | -| `--models` | モデルの使用状況の内訳を表示 (デフォルトでは非表示)。上位 N 件を表示するには数値を渡します | -| `--project` | プロジェクトでフィルタリング (全プロジェクト、空文字列: 現在のプロジェクト) | +| フラグ | 説明 | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| {"--days"} | 過去 N 日間の統計を表示 (全期間) | +| {"--tools"} | 表示するツールの数 (すべて) | +| {"--models"} | モデルの使用状況の内訳を表示 (デフォルトでは非表示)。上位 N 件を表示するには数値を渡します | +| {"--project"} | プロジェクトでフィルタリング (全プロジェクト、空文字列: 現在のプロジェクト) | --- @@ -460,12 +469,12 @@ opencode web #### フラグ -| フラグ | 説明 | -| ------------ | --------------------------------------- | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | -| `--mdns` | mDNS 検出を有効にする | -| `--cors` | CORS を許可する追加のブラウザーオリジン | +| フラグ | 説明 | +| ---------------------------------------- | --------------------------------------- | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | +| {"--mdns"} | mDNS 検出を有効にする | +| {"--cors"} | CORS を許可する追加のブラウザーオリジン | --- @@ -481,11 +490,11 @@ opencode acp #### フラグ -| フラグ | 説明 | -| ------------ | -------------------- | -| `--cwd` | 作業ディレクトリ | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | +| フラグ | 説明 | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | 作業ディレクトリ | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | --- @@ -499,12 +508,12 @@ opencode uninstall #### フラグ -| フラグ | ショート | 説明 | -| --------------- | -------- | -------------------------------------------- | -| `--keep-config` | `-c` | 構成ファイルを保持する | -| `--keep-data` | `-d` | セッションデータとスナップショットを保持する | -| `--dry-run` | | 削除せずに削除される内容を表示する | -| `--force` | `-f` | 確認プロンプトをスキップする | +| フラグ | ショート | 説明 | +| ------------------------------------------- | -------- | -------------------------------------------- | +| {"--keep-config"} | `-c` | 構成ファイルを保持する | +| {"--keep-data"} | `-d` | セッションデータとスナップショットを保持する | +| {"--dry-run"} | | 削除せずに削除される内容を表示する | +| {"--force"} | `-f` | 確認プロンプトをスキップする | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### フラグ -| フラグ | ショート | 説明 | -| ---------- | -------- | ------------------------------------------------------ | -| `--method` | `-m` | 使用されたインストール方法。curl, npm, pnpm, bun, brew | +| フラグ | ショート | 説明 | +| -------------------------------------- | -------- | ------------------------------------------------------ | +| {"--method"} | `-m` | 使用されたインストール方法。curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI は次のグローバルフラグを受け取ります。 -| フラグ | ショート | 説明 | -| -------------- | -------- | ------------------------------------- | -| `--help` | `-h` | ヘルプを表示 | -| `--version` | `-v` | バージョン番号を出力 | -| `--print-logs` | | ログを標準エラー出力に出力 | -| `--log-level` | | ログレベル (DEBUG、INFO、WARN、ERROR) | +| フラグ | ショート | 説明 | +| ------------------------------------------ | -------- | ------------------------------------- | +| {"--help"} | `-h` | ヘルプを表示 | +| {"--version"} | `-v` | バージョン番号を出力 | +| {"--print-logs"} | | ログを標準エラー出力に出力 | +| {"--log-level"} | | ログレベル (DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/ko/cli.mdx b/packages/web/src/content/docs/ko/cli.mdx index b0ce10567..7b829c7f3 100644 --- a/packages/web/src/content/docs/ko/cli.mdx +++ b/packages/web/src/content/docs/ko/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 플래그 -| 플래그 | 축약 | 설명 | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--continue` | `-c` | 마지막 세션 이어서 실행 | -| `--session` | `-s` | 이어서 실행할 세션 ID | -| `--fork` | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | -| `--prompt` | | 사용할 프롬프트 | -| `--model` | `-m` | 사용할 모델 (`provider/model` 형식) | -| `--agent` | | 사용할 에이전트 | -| `--port` | | 수신 포트 | -| `--hostname` | | 수신 호스트명 | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------- | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | +| {"--prompt"} | | 사용할 프롬프트 | +| {"--model"} | `-m` | 사용할 모델 (`provider/model` 형식) | +| {"--agent"} | | 사용할 에이전트 | +| {"--port"} | | 수신 포트 | +| {"--hostname"} | | 수신 호스트명 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 플래그 -| 플래그 | 축약 | 설명 | -| ----------- | ---- | -------------------------- | -| `--dir` | | TUI를 시작할 작업 디렉터리 | -| `--session` | `-s` | 이어서 실행할 세션 ID | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------- | +| {"--dir"} | | TUI를 시작할 작업 디렉터리 | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 이어서 실행할 때 세션 포크 (`--continue` 또는 `--session`과 함께 사용) | +| {"--password"} | `-p` | 기본 인증 비밀번호 (기본값: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 기본 인증 사용자 이름 (기본값: `OPENCODE_SERVER_USERNAME` 또는 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 플래그 -| 플래그 | 설명 | -| --------- | ------------------------- | -| `--event` | 실행할 GitHub 모의 이벤트 | -| `--token` | GitHub 개인 액세스 토큰 | +| 플래그 | 설명 | +| ------------------------------------- | ------------------------- | +| {"--event"} | 실행할 GitHub 모의 이벤트 | +| {"--token"} | GitHub 개인 액세스 토큰 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 플래그 -| 플래그 | 설명 | -| ----------- | ------------------------------------------------- | -| `--refresh` | models.dev에서 모델 캐시 새로고침 | -| `--verbose` | 더 자세한 모델 출력 사용(비용 등 메타데이터 포함) | +| 플래그 | 설명 | +| --------------------------------------- | ------------------------------------------------- | +| {"--refresh"} | models.dev에서 모델 캐시 새로고침 | +| {"--verbose"} | 더 자세한 모델 출력 사용(비용 등 메타데이터 포함) | `--refresh` 플래그를 사용하면 캐시된 모델 목록을 갱신할 수 있습니다. provider에 새 모델이 추가된 뒤 OpenCode에서 바로 확인하고 싶을 때 유용합니다. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 플래그 -| 플래그 | 축약 | 설명 | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--command` | | 실행할 명령(인수는 message로 전달) | -| `--continue` | `-c` | 마지막 세션 이어서 실행 | -| `--session` | `-s` | 이어서 실행할 세션 ID | -| `--fork` | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | -| `--share` | | 세션 공유 | -| `--model` | `-m` | 사용할 모델 (`provider/model` 형식) | -| `--agent` | | 사용할 에이전트 | -| `--file` | `-f` | 메시지에 첨부할 파일 | -| `--format` | | 출력 형식: default(포맷됨) 또는 json(원시 JSON 이벤트) | -| `--title` | | 세션 제목(값이 없으면 프롬프트를 잘라 자동 생성) | -| `--attach` | | 실행 중인 opencode 서버에 연결(예: http://localhost:4096) | -| `--port` | | 로컬 서버 포트(기본값: 랜덤 포트) | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------- | +| {"--command"} | | 실행할 명령(인수는 message로 전달) | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | +| {"--share"} | | 세션 공유 | +| {"--model"} | `-m` | 사용할 모델 (`provider/model` 형식) | +| {"--agent"} | | 사용할 에이전트 | +| {"--file"} | `-f` | 메시지에 첨부할 파일 | +| {"--format"} | | 출력 형식: default(포맷됨) 또는 json(원시 JSON 이벤트) | +| {"--title"} | | 세션 제목(값이 없으면 프롬프트를 잘라 자동 생성) | +| {"--attach"} | | 실행 중인 opencode 서버에 연결(예: http://localhost:4096) | +| {"--password"} | `-p` | 기본 인증 비밀번호 (기본값: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 기본 인증 사용자 이름 (기본값: `OPENCODE_SERVER_USERNAME` 또는 `opencode`) | +| {"--dir"} | | 실행할 디렉터리, 또는 연결 시 원격 서버 경로 | +| {"--variant"} | | 모델 변형 (제공자별 추론 수준) | +| {"--thinking"} | | 사고 블록 표시 | +| {"--port"} | | 로컬 서버 포트(기본값: 랜덤 포트) | --- @@ -364,12 +373,12 @@ opencode serve #### 플래그 -| 플래그 | 설명 | -| ------------ | --------------------------------- | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | -| `--mdns` | mDNS 검색 활성화 | -| `--cors` | 허용할 추가 브라우저 origin(CORS) | +| 플래그 | 설명 | +| ---------------------------------------- | --------------------------------- | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | +| {"--mdns"} | mDNS 검색 활성화 | +| {"--cors"} | 허용할 추가 브라우저 origin(CORS) | --- @@ -393,10 +402,10 @@ opencode session list ##### 플래그 -| 플래그 | 축약 | 설명 | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | 최근 N개 세션만 표시 | -| `--format` | | 출력 형식: table 또는 json(기본 table) | +| 플래그 | 축약 | 설명 | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | 최근 N개 세션만 표시 | +| {"--format"} | | 출력 형식: table 또는 json(기본 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 플래그 -| 플래그 | 설명 | -| ----------- | ------------------------------------------------------------ | -| `--days` | 최근 N일 통계 표시(기본값: 전체 기간) | -| `--tools` | 표시할 도구 개수(기본값: 전체) | -| `--models` | 모델 사용량 상세 표시(기본 숨김). 숫자를 주면 상위 N개 표시 | -| `--project` | 프로젝트 필터(기본: 전체 프로젝트, 빈 문자열: 현재 프로젝트) | +| 플래그 | 설명 | +| --------------------------------------- | ------------------------------------------------------------ | +| {"--days"} | 최근 N일 통계 표시(기본값: 전체 기간) | +| {"--tools"} | 표시할 도구 개수(기본값: 전체) | +| {"--models"} | 모델 사용량 상세 표시(기본 숨김). 숫자를 주면 상위 N개 표시 | +| {"--project"} | 프로젝트 필터(기본: 전체 프로젝트, 빈 문자열: 현재 프로젝트) | --- @@ -460,12 +469,12 @@ opencode web #### 플래그 -| 플래그 | 설명 | -| ------------ | --------------------------------- | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | -| `--mdns` | mDNS 검색 활성화 | -| `--cors` | 허용할 추가 브라우저 origin(CORS) | +| 플래그 | 설명 | +| ---------------------------------------- | --------------------------------- | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | +| {"--mdns"} | mDNS 검색 활성화 | +| {"--cors"} | 허용할 추가 브라우저 origin(CORS) | --- @@ -481,11 +490,11 @@ opencode acp #### 플래그 -| 플래그 | 설명 | -| ------------ | ------------- | -| `--cwd` | 작업 디렉터리 | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | +| 플래그 | 설명 | +| ---------------------------------------- | ------------- | +| {"--cwd"} | 작업 디렉터리 | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 플래그 -| 플래그 | 축약 | 설명 | -| --------------- | ---- | ------------------------------- | -| `--keep-config` | `-c` | 설정 파일 유지 | -| `--keep-data` | `-d` | 세션 데이터와 스냅샷 유지 | -| `--dry-run` | | 실제 삭제 없이 삭제 대상만 표시 | -| `--force` | `-f` | 확인 프롬프트 건너뛰기 | +| 플래그 | 축약 | 설명 | +| ------------------------------------------- | ---- | ------------------------------- | +| {"--keep-config"} | `-c` | 설정 파일 유지 | +| {"--keep-data"} | `-d` | 세션 데이터와 스냅샷 유지 | +| {"--dry-run"} | | 실제 삭제 없이 삭제 대상만 표시 | +| {"--force"} | `-f` | 확인 프롬프트 건너뛰기 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 플래그 -| 플래그 | 축약 | 설명 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 설치 방식 지정: curl, npm, pnpm, bun, brew | +| 플래그 | 축약 | 설명 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 설치 방식 지정: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI는 아래 전역 플래그를 지원합니다. -| 플래그 | 축약 | 설명 | -| -------------- | ---- | ----------------------------------- | -| `--help` | `-h` | 도움말 표시 | -| `--version` | `-v` | 버전 출력 | -| `--print-logs` | | 로그를 stderr로 출력 | -| `--log-level` | | 로그 레벨(DEBUG, INFO, WARN, ERROR) | +| 플래그 | 축약 | 설명 | +| ------------------------------------------ | ---- | ----------------------------------- | +| {"--help"} | `-h` | 도움말 표시 | +| {"--version"} | `-v` | 버전 출력 | +| {"--print-logs"} | | 로그를 stderr로 출력 | +| {"--log-level"} | | 로그 레벨(DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/nb/cli.mdx b/packages/web/src/content/docs/nb/cli.mdx index 8312a1a7c..824a3dcad 100644 --- a/packages/web/src/content/docs/nb/cli.mdx +++ b/packages/web/src/content/docs/nb/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flagg -| Flagg | Kort | Beskrivelse | -| ------------ | ---- | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Fortsett siste økt | -| `--session` | `-s` | Økt ID for å fortsette | -| `--fork` | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | -| `--prompt` | | Ledetekst som skal brukes | -| `--model` | `-m` | Modell å bruke i form av leverandør/modell | -| `--agent` | | Agent som skal brukes | -| `--port` | | Port å lytte på | -| `--hostname` | | Vertsnavn å lytte på | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--prompt"} | | Ledetekst som skal brukes | +| {"--model"} | `-m` | Modell å bruke i form av leverandør/modell | +| {"--agent"} | | Agent som skal brukes | +| {"--port"} | | Port å lytte på | +| {"--hostname"} | | Vertsnavn å lytte på | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flagg -| Flagg | Kort | Beskrivelse | -| ----------- | ---- | --------------------------------- | -| `--dir` | | Arbeidskatalog for å starte TUI i | -| `--session` | `-s` | Økt ID for å fortsette | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Arbeidskatalog for å starte TUI i | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--password"} | `-p` | Passord for grunnleggende autentisering (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brukernavn for grunnleggende autentisering (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flagg -| Flagg | Beskrivelse | -| --------- | -------------------------------------------- | -| `--event` | GitHub mock-hendelse agenten skal kjøres for | -| `--token` | GitHub personlig tilgangsnøkkel | +| Flagg | Beskrivelse | +| ------------------------------------- | -------------------------------------------- | +| {"--event"} | GitHub mock-hendelse agenten skal kjøres for | +| {"--token"} | GitHub personlig tilgangsnøkkel | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flagg -| Flagg | Beskrivelse | -| ----------- | ------------------------------------------------------------------- | -| `--refresh` | Oppdater modellbufferen fra models.dev | -| `--verbose` | Bruk mer detaljert modellutdata (inkluderer metadata som kostnader) | +| Flagg | Beskrivelse | +| --------------------------------------- | ------------------------------------------------------------------- | +| {"--refresh"} | Oppdater modellbufferen fra models.dev | +| {"--verbose"} | Bruk mer detaljert modellutdata (inkluderer metadata som kostnader) | Bruk `--refresh`-flagget for å oppdatere den bufrede modelllisten. Dette er nyttig når nye modeller er lagt til en leverandør og du vil se dem i OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flagg -| Flagg | Kort | Beskrivelse | -| ------------ | ---- | ------------------------------------------------------------------------ | -| `--command` | | Kommandoen for å kjøre, bruk melding for args | -| `--continue` | `-c` | Fortsett siste økt | -| `--session` | `-s` | Økt ID for å fortsette | -| `--fork` | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | -| `--share` | | Del økten | -| `--model` | `-m` | Modell å bruke i form av leverandør/modell | -| `--agent` | | Agent å bruke | -| `--file` | `-f` | Fil(er) som skal legges ved meldingen | -| `--format` | | Format: standard (formatert) eller json (rå JSON hendelser) | -| `--title` | | Tittel for økten (bruker avkortet ledetekst hvis ingen verdi er oppgitt) | -| `--attach` | | Koble til en kjørende OpenCode-server (f.eks. http://localhost:4096) | -| `--port` | | Port for den lokale serveren (standard til tilfeldig port) | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| {"--command"} | | Kommandoen for å kjøre, bruk melding for args | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--share"} | | Del økten | +| {"--model"} | `-m` | Modell å bruke i form av leverandør/modell | +| {"--agent"} | | Agent å bruke | +| {"--file"} | `-f` | Fil(er) som skal legges ved meldingen | +| {"--format"} | | Format: standard (formatert) eller json (rå JSON hendelser) | +| {"--title"} | | Tittel for økten (bruker avkortet ledetekst hvis ingen verdi er oppgitt) | +| {"--attach"} | | Koble til en kjørende OpenCode-server (f.eks. http://localhost:4096) | +| {"--password"} | `-p` | Passord for grunnleggende autentisering (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brukernavn for grunnleggende autentisering (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | +| {"--dir"} | | Katalog å kjøre i, eller sti på fjernserveren ved tilkobling | +| {"--variant"} | | Modellvariant (leverandørspesifikk resonneringsinnsats) | +| {"--thinking"} | | Vis tenkeblokker | +| {"--port"} | | Port for den lokale serveren (standard til tilfeldig port) | --- @@ -364,12 +373,12 @@ Dette starter en HTTP-server som gir API tilgang til OpenCode-funksjonalitet ute #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------------------------------------- | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | -| `--mdns` | Aktiver mDNS-oppdagelse | -| `--cors` | Ytterligere nettleseropprinnelse som tillater CORS | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------------------------------------- | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | +| {"--mdns"} | Aktiver mDNS-oppdagelse | +| {"--cors"} | Ytterligere nettleseropprinnelse som tillater CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flagg -| Flagg | Kort | Beskrivelse | -| ------------- | ---- | ---------------------------------------- | -| `--max-count` | `-n` | Begrens til N siste økter | -| `--format` | | Utdataformat: tabell eller json (tabell) | +| Flagg | Kort | Beskrivelse | +| ----------------------------------------- | ---- | ---------------------------------------- | +| {"--max-count"} | `-n` | Begrens til N siste økter | +| {"--format"} | | Utdataformat: tabell eller json (tabell) | --- @@ -410,12 +419,12 @@ opencode stats #### Flagg -| Flagg | Beskrivelse | -| ----------- | -------------------------------------------------------------------------------- | -| `--days` | Vis statistikk for de siste N dagene (hele tiden) | -| `--tools` | Antall verktøy som skal vises (alle) | -| `--models` | Vis oversikt over modellbruk (skjult som standard). Gi et tall for å vise topp N | -| `--project` | Filtrer etter prosjekt (alle prosjekter, tom streng: gjeldende prosjekt) | +| Flagg | Beskrivelse | +| --------------------------------------- | -------------------------------------------------------------------------------- | +| {"--days"} | Vis statistikk for de siste N dagene (hele tiden) | +| {"--tools"} | Antall verktøy som skal vises (alle) | +| {"--models"} | Vis oversikt over modellbruk (skjult som standard). Gi et tall for å vise topp N | +| {"--project"} | Filtrer etter prosjekt (alle prosjekter, tom streng: gjeldende prosjekt) | --- @@ -460,12 +469,12 @@ Dette starter en HTTP-server og åpner en nettleser for å få tilgang til OpenC #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------------------------------------- | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | -| `--mdns` | Aktiver mDNS-oppdagelse | -| `--cors` | Ytterligere nettleseropprinnelse som tillater CORS | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------------------------------------- | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | +| {"--mdns"} | Aktiver mDNS-oppdagelse | +| {"--cors"} | Ytterligere nettleseropprinnelse som tillater CORS | --- @@ -481,11 +490,11 @@ Denne kommandoen starter en ACP-server som kommuniserer via stdin/stdout ved å #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------- | -| `--cwd` | Arbeidskatalog | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | Arbeidskatalog | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flagg -| Flagg | Kort | Beskrivelse | -| --------------- | ---- | --------------------------------------------- | -| `--keep-config` | `-c` | Behold konfigurasjonsfiler | -| `--keep-data` | `-d` | Behold øktdata og øyeblikksbilder | -| `--dry-run` | | Vis hva som ville blitt fjernet uten å fjerne | -| `--force` | `-f` | Hopp over bekreftelsesforespørsler | +| Flagg | Kort | Beskrivelse | +| ------------------------------------------- | ---- | --------------------------------------------- | +| {"--keep-config"} | `-c` | Behold konfigurasjonsfiler | +| {"--keep-data"} | `-d` | Behold øktdata og øyeblikksbilder | +| {"--dry-run"} | | Vis hva som ville blitt fjernet uten å fjerne | +| {"--force"} | `-f` | Hopp over bekreftelsesforespørsler | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flagg -| Flagg | Kort | Beskrivelse | -| ---------- | ---- | -------------------------------------------------------------- | -| `--method` | `-m` | Installasjonsmetoden som ble brukt: curl, npm, pnpm, bun, brew | +| Flagg | Kort | Beskrivelse | +| -------------------------------------- | ---- | -------------------------------------------------------------- | +| {"--method"} | `-m` | Installasjonsmetoden som ble brukt: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI bruker følgende globale flagg. -| Flagg | Kort | Beskrivelse | -| -------------- | ---- | ----------------------------------- | -| `--help` | `-h` | Vis hjelp | -| `--version` | `-v` | Skriv ut versjonsnummer | -| `--print-logs` | | Skriv ut logger til stderr | -| `--log-level` | | Loggnivå (DEBUG, INFO, WARN, ERROR) | +| Flagg | Kort | Beskrivelse | +| ------------------------------------------ | ---- | ----------------------------------- | +| {"--help"} | `-h` | Vis hjelp | +| {"--version"} | `-v` | Skriv ut versjonsnummer | +| {"--print-logs"} | | Skriv ut logger til stderr | +| {"--log-level"} | | Loggnivå (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/pl/cli.mdx b/packages/web/src/content/docs/pl/cli.mdx index e175870cb..f2e4ddf9b 100644 --- a/packages/web/src/content/docs/pl/cli.mdx +++ b/packages/web/src/content/docs/pl/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flagi -| Flaga | Skrót | Opis | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--continue` | `-c` | Kontynuuj ostatnią sesję | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | -| `--fork` | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | -| `--prompt` | | Monit do użycia | -| `--model` | `-m` | Model do użycia w formacie dostawca/model | -| `--agent` | | Agent do użycia | -| `--port` | | Port do nasłuchiwania | -| `--hostname` | | Nazwa hosta, do której należy się powiązać | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------- | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | +| {"--prompt"} | | Monit do użycia | +| {"--model"} | `-m` | Model do użycia w formacie dostawca/model | +| {"--agent"} | | Agent do użycia | +| {"--port"} | | Port do nasłuchiwania | +| {"--hostname"} | | Nazwa hosta, do której należy się powiązać | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flagi -| Flaga | Skrót | Opis | -| ----------- | ----- | --------------------------------------- | -| `--dir` | | Katalog roboczy, w którym uruchomić TUI | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Katalog roboczy, w którym uruchomić TUI | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Rozgałęź sesję podczas kontynuowania (użyj z `--continue` lub `--session`) | +| {"--password"} | `-p` | Hasło uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nazwa użytkownika uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_USERNAME` lub `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flagi -| Flaga | Opis | -| --------- | ---------------------------------------- | -| `--event` | Zdarzenie GitHub, które wyzwoliło agenta | -| `--token` | Osobisty token dostępu GitHub | +| Flaga | Opis | +| ------------------------------------- | ---------------------------------------- | +| {"--event"} | Zdarzenie GitHub, które wyzwoliło agenta | +| {"--token"} | Osobisty token dostępu GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flagi -| Flaga | Opis | -| ----------- | ------------------------------------------------------------------------------- | -| `--refresh` | Odśwież pamięć podręczną modeli | -| `--verbose` | Bardziej szczegółowe dane wyjściowe modelu (zawiera metadane, takie jak koszty) | +| Flaga | Opis | +| --------------------------------------- | ------------------------------------------------------------------------------- | +| {"--refresh"} | Odśwież pamięć podręczną modeli | +| {"--verbose"} | Bardziej szczegółowe dane wyjściowe modelu (zawiera metadane, takie jak koszty) | Użyj flagi `--refresh`, aby zaktualizować listę modeli w pamięci podręcznej. Jest to przydatne, gdy dostawca dodał nowe modele, które chcesz zobaczyć w OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flagi -| Flaga | Skrót | Opis | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--command` | | Polecenie do uruchomienia, reszta to argumenty | -| `--continue` | `-c` | Kontynuuj ostatnią sesję | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | -| `--fork` | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | -| `--share` | | Udostępnij sesję po zakończeniu | -| `--model` | `-m` | Model do użycia w formacie dostawca/model | -| `--agent` | | Agent do użycia | -| `--file` | `-f` | Pliki do załączenia do wiadomości | -| `--format` | | Format wyjściowy: `default` (sformatowany) lub `json` (surowy JSON) | -| `--title` | | Tytuł sesji (jeśli nie podano, zostanie wygenerowany z promptu) | -| `--attach` | | Dołącz do działającego serwera OpenCode (np. http://localhost:4096) | -| `--port` | | Port dla serwera lokalnego (domyślnie losowy) | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| {"--command"} | | Polecenie do uruchomienia, reszta to argumenty | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | +| {"--share"} | | Udostępnij sesję po zakończeniu | +| {"--model"} | `-m` | Model do użycia w formacie dostawca/model | +| {"--agent"} | | Agent do użycia | +| {"--file"} | `-f` | Pliki do załączenia do wiadomości | +| {"--format"} | | Format wyjściowy: `default` (sformatowany) lub `json` (surowy JSON) | +| {"--title"} | | Tytuł sesji (jeśli nie podano, zostanie wygenerowany z promptu) | +| {"--attach"} | | Dołącz do działającego serwera OpenCode (np. http://localhost:4096) | +| {"--password"} | `-p` | Hasło uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nazwa użytkownika uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_USERNAME` lub `opencode`) | +| {"--dir"} | | Katalog do uruchomienia lub ścieżka na zdalnym serwerze podczas dołączania | +| {"--variant"} | | Wariant modelu (poziom wnioskowania specyficzny dla dostawcy) | +| {"--thinking"} | | Pokaż bloki myślenia | +| {"--port"} | | Port dla serwera lokalnego (domyślnie losowy) | --- @@ -364,12 +373,12 @@ Uruchamia to serwer HTTP, który zapewnia dostęp do API OpenCode bez interfejsu #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | -| `--mdns` | Włącz wykrywanie mDNS | -| `--cors` | Dodatkowe dozwolone źródła CORS | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | +| {"--mdns"} | Włącz wykrywanie mDNS | +| {"--cors"} | Dodatkowe dozwolone źródła CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flagi -| Flaga | Skrót | Opis | -| ------------- | ----- | ------------------------------------------ | -| `--max-count` | `-n` | Ogranicz do ostatnich N sesji | -| `--format` | | Format wyjściowy: tabela lub json (tabela) | +| Flaga | Skrót | Opis | +| ----------------------------------------- | ----- | ------------------------------------------ | +| {"--max-count"} | `-n` | Ogranicz do ostatnich N sesji | +| {"--format"} | | Format wyjściowy: tabela lub json (tabela) | --- @@ -410,12 +419,12 @@ opencode stats #### Flagi -| Flaga | Opis | -| ----------- | ------------------------------------------------------------------------------------- | -| `--days` | Pokaż statystyki z ostatnich N dni (domyślnie: cały czas) | -| `--tools` | Pokaż użycie poszczególnych narzędzi (domyślnie: wszystkie) | -| `--models` | Pokaż podział na modele (domyślnie ukryty). Podaj liczbę, aby pokazać N najczęstszych | -| `--project` | Filtruj według projektu (domyślnie: wszystkie projekty, pusty ciąg: bieżący projekt) | +| Flaga | Opis | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--days"} | Pokaż statystyki z ostatnich N dni (domyślnie: cały czas) | +| {"--tools"} | Pokaż użycie poszczególnych narzędzi (domyślnie: wszystkie) | +| {"--models"} | Pokaż podział na modele (domyślnie ukryty). Podaj liczbę, aby pokazać N najczęstszych | +| {"--project"} | Filtruj według projektu (domyślnie: wszystkie projekty, pusty ciąg: bieżący projekt) | --- @@ -460,12 +469,12 @@ Uruchamia to serwer HTTP i udostępnia OpenCode przez interfejs przeglądarkowy. #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | -| `--mdns` | Włącz wykrywanie mDNS | -| `--cors` | Dodatkowe dozwolone źródła CORS | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | +| {"--mdns"} | Włącz wykrywanie mDNS | +| {"--cors"} | Dodatkowe dozwolone źródła CORS | --- @@ -481,11 +490,11 @@ Uruchamia serwer ACP, który komunikuje się przez stdin/stdout przy użyciu JSO #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--cwd` | Katalog roboczy | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--cwd"} | Katalog roboczy | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flagi -| Flaga | Skrót | Opis | -| --------------- | ----- | ----------------------------- | -| `--keep-config` | `-c` | Zachowaj pliki konfiguracyjne | -| `--keep-data` | `-d` | Zachowaj dane sesji i migawki | -| `--dry-run` | | Pokaż co zostanie usunięte | -| `--force` | `-f` | Pomiń monity o potwierdzenie | +| Flaga | Skrót | Opis | +| ------------------------------------------- | ----- | ----------------------------- | +| {"--keep-config"} | `-c` | Zachowaj pliki konfiguracyjne | +| {"--keep-data"} | `-d` | Zachowaj dane sesji i migawki | +| {"--dry-run"} | | Pokaż co zostanie usunięte | +| {"--force"} | `-f` | Pomiń monity o potwierdzenie | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flagi -| Flaga | Skrót | Opis | -| ---------- | ----- | --------------------------------------------------- | -| `--method` | `-m` | Wymuś metodę instalacji: curl, npm, pnpm, bun, brew | +| Flaga | Skrót | Opis | +| -------------------------------------- | ----- | --------------------------------------------------- | +| {"--method"} | `-m` | Wymuś metodę instalacji: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 Interfejs CLI OpenCode przyjmuje następujące flagi globalne dla każdego polecenia. -| Flaga | Skrót | Opis | -| -------------- | ----- | ------------------------------------------- | -| `--help` | `-h` | Wyświetl pomoc | -| `--version` | `-v` | Wydrukuj numer wersji | -| `--print-logs` | | Drukuj logi na stderr | -| `--log-level` | | Poziom logowania (DEBUG, INFO, WARN, ERROR) | +| Flaga | Skrót | Opis | +| ------------------------------------------ | ----- | ------------------------------------------- | +| {"--help"} | `-h` | Wyświetl pomoc | +| {"--version"} | `-v` | Wydrukuj numer wersji | +| {"--print-logs"} | | Drukuj logi na stderr | +| {"--log-level"} | | Poziom logowania (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/pt-br/cli.mdx b/packages/web/src/content/docs/pt-br/cli.mdx index 78190b3c5..889626d41 100644 --- a/packages/web/src/content/docs/pt-br/cli.mdx +++ b/packages/web/src/content/docs/pt-br/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opções -| Flag | Curto | Descrição | -| ------------ | ----- | -------------------------------------------------------------------------- | -| `--continue` | `-c` | Continue a última sessão | -| `--session` | `-s` | ID da sessão para continuar | -| `--fork` | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | -| `--prompt` | | Prompt a ser usado | -| `--model` | `-m` | Modelo a ser usado na forma de provider/model | -| `--agent` | | Agente a ser usado | -| `--port` | | Porta para escutar | -| `--hostname` | | Nome do host para escutar | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continue a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | +| {"--prompt"} | | Prompt a ser usado | +| {"--model"} | `-m` | Modelo a ser usado na forma de provider/model | +| {"--agent"} | | Agente a ser usado | +| {"--port"} | | Porta para escutar | +| {"--hostname"} | | Nome do host para escutar | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opções -| Flag | Curto | Descrição | -| ----------- | ----- | ---------------------------------------- | -| `--dir` | | Diretório de trabalho para iniciar o TUI | -| `--session` | `-s` | ID da sessão para continuar | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | --------------------------------------------------------------------------------- | +| {"--dir"} | | Diretório de trabalho para iniciar o TUI | +| {"--continue"} | `-c` | Continuar a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Bifurcar a sessão ao continuar (use com `--continue` ou `--session`) | +| {"--password"} | `-p` | Senha de autenticação básica (padrão: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuário de autenticação básica (padrão: `OPENCODE_SERVER_USERNAME` ou `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opções -| Flag | Descrição | -| --------- | ------------------------------------------------ | -| `--event` | Evento simulado do GitHub para executar o agente | -| `--token` | Token de acesso pessoal do GitHub | +| Flag | Descrição | +| ------------------------------------- | ------------------------------------------------ | +| {"--event"} | Evento simulado do GitHub para executar o agente | +| {"--token"} | Token de acesso pessoal do GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Opções -| Flag | Descrição | -| ----------- | --------------------------------------------------------------------- | -| `--refresh` | Atualiza o cache de modelos a partir do models.dev | -| `--verbose` | Use uma saída de modelo mais detalhada (inclui metadados como custos) | +| Flag | Descrição | +| --------------------------------------- | --------------------------------------------------------------------- | +| {"--refresh"} | Atualiza o cache de modelos a partir do models.dev | +| {"--verbose"} | Use uma saída de modelo mais detalhada (inclui metadados como custos) | Use a flag `--refresh` para atualizar a lista de modelos em cache. Isso é útil quando novos modelos foram adicionados a um provedor e você deseja vê-los no opencode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explique async/await em JavaScript" #### Opções -| Flag | Curto | Descrição | -| ------------ | ----- | ----------------------------------------------------------------------------- | -| `--command` | | O comando a ser executado, use mensagem para argumentos | -| `--continue` | `-c` | Continue a última sessão | -| `--session` | `-s` | ID da sessão para continuar | -| `--fork` | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | -| `--share` | | Compartilhe a sessão | -| `--model` | `-m` | Modelo a ser usado na forma de provider/model | -| `--agent` | | Agente a ser usado | -| `--file` | `-f` | Arquivo(s) a serem anexados à mensagem | -| `--format` | | Formato: padrão (formatado) ou json (eventos JSON brutos) | -| `--title` | | Título para a sessão (usa o prompt truncado se nenhum valor for fornecido) | -| `--attach` | | Anexe a um servidor opencode em execução (por exemplo, http://localhost:4096) | -| `--port` | | Porta para o servidor local (padrão para porta aleatória) | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | --------------------------------------------------------------------------------- | +| {"--command"} | | O comando a ser executado, use mensagem para argumentos | +| {"--continue"} | `-c` | Continue a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | +| {"--share"} | | Compartilhe a sessão | +| {"--model"} | `-m` | Modelo a ser usado na forma de provider/model | +| {"--agent"} | | Agente a ser usado | +| {"--file"} | `-f` | Arquivo(s) a serem anexados à mensagem | +| {"--format"} | | Formato: padrão (formatado) ou json (eventos JSON brutos) | +| {"--title"} | | Título para a sessão (usa o prompt truncado se nenhum valor for fornecido) | +| {"--attach"} | | Anexe a um servidor opencode em execução (por exemplo, http://localhost:4096) | +| {"--password"} | `-p` | Senha de autenticação básica (padrão: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuário de autenticação básica (padrão: `OPENCODE_SERVER_USERNAME` ou `opencode`) | +| {"--dir"} | | Diretório de execução, ou caminho no servidor remoto ao anexar | +| {"--variant"} | | Variante do modelo (nível de raciocínio específico do provedor) | +| {"--thinking"} | | Mostrar blocos de pensamento | +| {"--port"} | | Porta para o servidor local (padrão para porta aleatória) | --- @@ -364,12 +373,12 @@ Isso inicia um servidor HTTP que fornece acesso à funcionalidade do opencode se #### Opções -| Flag | Descrição | -| ------------ | ----------------------------------------------------- | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | -| `--mdns` | Habilitar descoberta mDNS | -| `--cors` | Origem(ns) de navegador adicionais para permitir CORS | +| Flag | Descrição | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | +| {"--mdns"} | Habilitar descoberta mDNS | +| {"--cors"} | Origem(ns) de navegador adicionais para permitir CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Opções -| Flag | Curto | Descrição | -| ------------- | ----- | ----------------------------------------- | -| `--max-count` | `-n` | Limitar às N sessões mais recentes | -| `--format` | | Formato de saída: tabela ou json (tabela) | +| Flag | Curto | Descrição | +| ----------------------------------------- | ----- | ----------------------------------------- | +| {"--max-count"} | `-n` | Limitar às N sessões mais recentes | +| {"--format"} | | Formato de saída: tabela ou json (tabela) | --- @@ -410,12 +419,12 @@ opencode stats #### Opções -| Flag | Descrição | -| ----------- | ---------------------------------------------------------------------------------------------------- | -| `--days` | Mostre estatísticas dos últimos N dias (todo o tempo) | -| `--tools` | Número de ferramentas a serem mostradas (todas) | -| `--models` | Mostre a divisão do uso de modelos (oculto por padrão). Passe um número para mostrar os N principais | -| `--project` | Filtrar por projeto (todos os projetos, string vazia: projeto atual) | +| Flag | Descrição | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| {"--days"} | Mostre estatísticas dos últimos N dias (todo o tempo) | +| {"--tools"} | Número de ferramentas a serem mostradas (todas) | +| {"--models"} | Mostre a divisão do uso de modelos (oculto por padrão). Passe um número para mostrar os N principais | +| {"--project"} | Filtrar por projeto (todos os projetos, string vazia: projeto atual) | --- @@ -460,12 +469,12 @@ Isso inicia um servidor HTTP e abre um navegador para acessar o opencode atravé #### Opções -| Flag | Descrição | -| ------------ | ----------------------------------------------------- | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | -| `--mdns` | Habilitar descoberta mDNS | -| `--cors` | Origem(ns) de navegador adicionais para permitir CORS | +| Flag | Descrição | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | +| {"--mdns"} | Habilitar descoberta mDNS | +| {"--cors"} | Origem(ns) de navegador adicionais para permitir CORS | --- @@ -481,11 +490,11 @@ Este comando inicia um servidor ACP que se comunica via stdin/stdout usando nd-J #### Opções -| Flag | Descrição | -| ------------ | ------------------------- | -| `--cwd` | Diretório de trabalho | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | +| Flag | Descrição | +| ---------------------------------------- | ------------------------- | +| {"--cwd"} | Diretório de trabalho | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | --- @@ -499,12 +508,12 @@ opencode uninstall #### Opções -| Flag | Curto | Descrição | -| --------------- | ----- | ---------------------------------------- | -| `--keep-config` | `-c` | Manter arquivos de configuração | -| `--keep-data` | `-d` | Manter dados de sessão e snapshots | -| `--dry-run` | | Mostrar o que seria removido sem remover | -| `--force` | `-f` | Pular prompts de confirmação | +| Flag | Curto | Descrição | +| ------------------------------------------- | ----- | ---------------------------------------- | +| {"--keep-config"} | `-c` | Manter arquivos de configuração | +| {"--keep-data"} | `-d` | Manter dados de sessão e snapshots | +| {"--dry-run"} | | Mostrar o que seria removido sem remover | +| {"--force"} | `-f` | Pular prompts de confirmação | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Opções -| Flag | Curto | Descrição | -| ---------- | ----- | ---------------------------------------------------------------- | -| `--method` | `-m` | O método de instalação que foi usado; curl, npm, pnpm, bun, brew | +| Flag | Curto | Descrição | +| -------------------------------------- | ----- | ---------------------------------------------------------------- | +| {"--method"} | `-m` | O método de instalação que foi usado; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 A CLI do opencode aceita as seguintes flags globais. -| Flag | Curto | Descrição | -| -------------- | ----- | --------------------------------------- | -| `--help` | `-h` | Exibir ajuda | -| `--version` | `-v` | Imprimir número da versão | -| `--print-logs` | | Imprimir logs no stderr | -| `--log-level` | | Nível de log (DEBUG, INFO, WARN, ERROR) | +| Flag | Curto | Descrição | +| ------------------------------------------ | ----- | --------------------------------------- | +| {"--help"} | `-h` | Exibir ajuda | +| {"--version"} | `-v` | Imprimir número da versão | +| {"--print-logs"} | | Imprimir logs no stderr | +| {"--log-level"} | | Nível de log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ru/cli.mdx b/packages/web/src/content/docs/ru/cli.mdx index f5aeee256..5f52f3d7f 100644 --- a/packages/web/src/content/docs/ru/cli.mdx +++ b/packages/web/src/content/docs/ru/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Флаги -| Флаг | Короткий | Описание | -| ------------ | -------- | ----------------------------------------------------------------------------- | -| `--continue` | `-c` | Продолжить последний сеанс | -| `--session` | `-s` | Идентификатор сеанса для продолжения | -| `--fork` | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | -| `--prompt` | | Промпт для использования | -| `--model` | `-m` | Модель для использования в виде поставщика/модели. | -| `--agent` | | Агент для использования | -| `--port` | | Порт для прослушивания | -| `--hostname` | | Имя хоста для прослушивания | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ----------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | +| {"--prompt"} | | Промпт для использования | +| {"--model"} | `-m` | Модель для использования в виде поставщика/модели. | +| {"--agent"} | | Агент для использования | +| {"--port"} | | Порт для прослушивания | +| {"--hostname"} | | Имя хоста для прослушивания | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Флаги -| Флаг | Короткий | Описание | -| ----------- | -------- | ------------------------------------ | -| `--dir` | | Рабочий каталог для запуска TUI | -| `--session` | `-s` | Идентификатор сеанса для продолжения | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| {"--dir"} | | Рабочий каталог для запуска TUI | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Создать ответвление сеанса при продолжении (используйте с `--continue` или `--session`) | +| {"--password"} | `-p` | Пароль базовой аутентификации (по умолчанию `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Имя пользователя базовой аутентификации (по умолчанию `OPENCODE_SERVER_USERNAME` или `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Флаги -| Флаг | Описание | -| --------- | --------------------------------------------- | -| `--event` | Имитирующее событие GitHub для запуска агента | -| `--token` | Токен личного доступа GitHub | +| Флаг | Описание | +| ------------------------------------- | --------------------------------------------- | +| {"--event"} | Имитирующее событие GitHub для запуска агента | +| {"--token"} | Токен личного доступа GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Флаги -| Флаг | Описание | -| ----------- | --------------------------------------------------------------------------------- | -| `--refresh` | Обновите кеш моделей на сайте models.dev. | -| `--verbose` | Используйте более подробный вывод модели (включая метаданные, такие как затраты). | +| Флаг | Описание | +| --------------------------------------- | --------------------------------------------------------------------------------- | +| {"--refresh"} | Обновите кеш моделей на сайте models.dev. | +| {"--verbose"} | Используйте более подробный вывод модели (включая метаданные, такие как затраты). | Используйте флаг `--refresh` для обновления списка кэшированных моделей. Это полезно, когда к поставщику добавлены новые модели и вы хотите увидеть их в opencode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Флаги -| Флаг | Короткий | Описание | -| ------------ | -------- | -------------------------------------------------------------------------------- | -| `--command` | | Команда для запуска, используйте сообщение для аргументов | -| `--continue` | `-c` | Продолжить последний сеанс | -| `--session` | `-s` | Идентификатор сеанса для продолжения | -| `--fork` | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | -| `--share` | | Поделиться сеансом | -| `--model` | `-m` | Модель для использования в виде поставщика/модели. | -| `--agent` | | Агент для использования | -| `--file` | `-f` | Файл(ы) для прикрепления к сообщению | -| `--format` | | Формат: по умолчанию (отформатированный) или json (необработанные события JSON). | -| `--title` | | Название сеанса (использует усеченное приглашение, если значение не указано) | -| `--attach` | | Подключитесь к работающему серверу opencode (например, http://localhost:4096) | -| `--port` | | Порт локального сервера (по умолчанию случайный порт) | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| {"--command"} | | Команда для запуска, используйте сообщение для аргументов | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | +| {"--share"} | | Поделиться сеансом | +| {"--model"} | `-m` | Модель для использования в виде поставщика/модели. | +| {"--agent"} | | Агент для использования | +| {"--file"} | `-f` | Файл(ы) для прикрепления к сообщению | +| {"--format"} | | Формат: по умолчанию (отформатированный) или json (необработанные события JSON). | +| {"--title"} | | Название сеанса (использует усеченное приглашение, если значение не указано) | +| {"--attach"} | | Подключитесь к работающему серверу opencode (например, http://localhost:4096) | +| {"--password"} | `-p` | Пароль базовой аутентификации (по умолчанию `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Имя пользователя базовой аутентификации (по умолчанию `OPENCODE_SERVER_USERNAME` или `opencode`) | +| {"--dir"} | | Каталог для выполнения или путь на удалённом сервере при подключении | +| {"--variant"} | | Вариант модели (уровень рассуждений для провайдера) | +| {"--thinking"} | | Показать блоки размышлений | +| {"--port"} | | Порт локального сервера (по умолчанию случайный порт) | --- @@ -364,12 +373,12 @@ opencode serve #### Флаги -| Флаг | Описание | -| ------------ | ------------------------------------------------------------- | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | -| `--mdns` | Включить обнаружение mDNS | -| `--cors` | Дополнительные источники браузера, позволяющие разрешить CORS | +| Флаг | Описание | +| ---------------------------------------- | ------------------------------------------------------------- | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | +| {"--mdns"} | Включить обнаружение mDNS | +| {"--cors"} | Дополнительные источники браузера, позволяющие разрешить CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Флаги -| Флаг | Короткий | Описание | -| ------------- | -------- | ----------------------------------------- | -| `--max-count` | `-n` | Ограничить N последних сеансов. | -| `--format` | | Формат вывода: таблица или json (таблица) | +| Флаг | Короткий | Описание | +| ----------------------------------------- | -------- | ----------------------------------------- | +| {"--max-count"} | `-n` | Ограничить N последних сеансов. | +| {"--format"} | | Формат вывода: таблица или json (таблица) | --- @@ -410,12 +419,12 @@ opencode stats #### Флаги -| Флаг | Описание | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `--days` | Показать статистику за последние N дней (все время) | -| `--tools` | Количество инструментов для отображения (все) | -| `--models` | Показать разбивку по использованию модели (по умолчанию скрыто). Передайте номер, чтобы показать верхнюю N | -| `--project` | Фильтровать по проекту (все проекты, пустая строка: текущий проект) | +| Флаг | Описание | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Показать статистику за последние N дней (все время) | +| {"--tools"} | Количество инструментов для отображения (все) | +| {"--models"} | Показать разбивку по использованию модели (по умолчанию скрыто). Передайте номер, чтобы показать верхнюю N | +| {"--project"} | Фильтровать по проекту (все проекты, пустая строка: текущий проект) | --- @@ -460,12 +469,12 @@ opencode web #### Флаги -| Флаг | Описание | -| ------------ | ------------------------------------------------------------- | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | -| `--mdns` | Включить обнаружение mDNS | -| `--cors` | Дополнительные источники браузера, позволяющие разрешить CORS | +| Флаг | Описание | +| ---------------------------------------- | ------------------------------------------------------------- | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | +| {"--mdns"} | Включить обнаружение mDNS | +| {"--cors"} | Дополнительные источники браузера, позволяющие разрешить CORS | --- @@ -481,11 +490,11 @@ opencode acp #### Флаги -| Флаг | Описание | -| ------------ | --------------------------- | -| `--cwd` | Рабочий каталог | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | +| Флаг | Описание | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Рабочий каталог | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | --- @@ -499,12 +508,12 @@ opencode uninstall #### Флаги -| Флаг | Короткий | Описание | -| --------------- | -------- | ------------------------------------------ | -| `--keep-config` | `-c` | Сохраняйте файлы конфигурации | -| `--keep-data` | `-d` | Храните данные сеанса и снимки | -| `--dry-run` | | Покажите, что было бы удалено без удаления | -| `--force` | `-f` | Пропустить запросы подтверждения | +| Флаг | Короткий | Описание | +| ------------------------------------------- | -------- | ------------------------------------------ | +| {"--keep-config"} | `-c` | Сохраняйте файлы конфигурации | +| {"--keep-data"} | `-d` | Храните данные сеанса и снимки | +| {"--dry-run"} | | Покажите, что было бы удалено без удаления | +| {"--force"} | `-f` | Пропустить запросы подтверждения | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Флаги -| Флаг | Короткий | Описание | -| ---------- | -------- | --------------------------------------------------------- | -| `--method` | `-m` | Используемый метод установки: local, npm, pnpm, bun, brew | +| Флаг | Короткий | Описание | +| -------------------------------------- | -------- | --------------------------------------------------------- | +| {"--method"} | `-m` | Используемый метод установки: local, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 CLI opencode принимает следующие глобальные флаги. -| Флаг | Короткий | Описание | -| -------------- | -------- | ------------------------------------------ | -| `--help` | `-h` | Отобразить справку | -| `--version` | `-v` | Распечатать номер версии | -| `--print-logs` | | Печать журналов в stderr | -| `--log-level` | | Уровень журнала (DEBUG, INFO, WARN, ERROR) | +| Флаг | Короткий | Описание | +| ------------------------------------------ | -------- | ------------------------------------------ | +| {"--help"} | `-h` | Отобразить справку | +| {"--version"} | `-v` | Распечатать номер версии | +| {"--print-logs"} | | Печать журналов в stderr | +| {"--log-level"} | | Уровень журнала (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/th/cli.mdx b/packages/web/src/content/docs/th/cli.mdx index 4b2db9d98..d98722846 100644 --- a/packages/web/src/content/docs/th/cli.mdx +++ b/packages/web/src/content/docs/th/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------ | ---- | ---------------------------------------------------------- | -| `--continue` | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | -| `--fork` | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | -| `--prompt` | | พรอมต์เริ่มต้นที่จะใช้ | -| `--model` | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | -| `--agent` | | เอเจนต์ที่จะใช้ | -| `--port` | | พอร์ตที่จะฟัง | -| `--hostname` | | ชื่อโฮสต์ที่จะฟัง | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ---------------------------------------------------------- | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--prompt"} | | พรอมต์เริ่มต้นที่จะใช้ | +| {"--model"} | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | +| {"--agent"} | | เอเจนต์ที่จะใช้ | +| {"--port"} | | พอร์ตที่จะฟัง | +| {"--hostname"} | | ชื่อโฮสต์ที่จะฟัง | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ----------- | ---- | -------------------------------------- | -| `--dir` | | ไดเร็กทอรีการทำงานเพื่อเริ่มต้น TUI ใน | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| {"--dir"} | | ไดเร็กทอรีการทำงานเพื่อเริ่มต้น TUI ใน | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อดำเนินการต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--password"} | `-p` | รหัสผ่านการยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | ชื่อผู้ใช้การยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_USERNAME` หรือ `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### แฟล็ก -| แฟล็ก | คำอธิบาย | -| --------- | -------------------------------------- | -| `--event` | เหตุการณ์ GitHub เพื่อทริกเกอร์เอเจนต์ | -| `--token` | GitHub token | +| แฟล็ก | คำอธิบาย | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | เหตุการณ์ GitHub เพื่อทริกเกอร์เอเจนต์ | +| {"--token"} | GitHub token | --- @@ -296,11 +300,11 @@ opencode models anthropic #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ----------- | ------------------------------------------------------ | -| `--refresh` | รีเฟรชแคชโมเดลจาก models.dev | -| `--verbose` | แสดงรายละเอียดโมเดลเพิ่มเติม (รวมข้อมูลเมตาเช่นต้นทุน) | -| `--json` | แสดงผลลัพธ์เป็น JSON | +| แฟล็ก | คำอธิบาย | +| --------------------------------------- | ------------------------------------------------------ | +| {"--refresh"} | รีเฟรชแคชโมเดลจาก models.dev | +| {"--verbose"} | แสดงรายละเอียดโมเดลเพิ่มเติม (รวมข้อมูลเมตาเช่นต้นทุน) | +| {"--json"} | แสดงผลลัพธ์เป็น JSON | ใช้แฟล็ก `--refresh` เพื่ออัปเดตรายการโมเดลที่แคชไว้ มีประโยชน์เมื่อมีการเพิ่มโมเดลใหม่ให้กับผู้ให้บริการและคุณต้องการเห็นใน OpenCode @@ -336,20 +340,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--command` | | คำสั่งที่จะรัน (ใช้ส่วนที่เหลือของ args เป็นอาร์กิวเมนต์) | -| `--continue` | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | -| `--fork` | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | -| `--share` | | สร้างลิงก์แชร์สำหรับเซสชัน | -| `--model` | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | -| `--agent` | | เอเจนต์ที่จะใช้ | -| `--file` | `-f` | แนบไฟล์ไปกับข้อความ | -| `--format` | | รูปแบบเอาต์พุต: text (จัดรูปแบบ) หรือ json (JSON ดิบ) | -| `--title` | | ชื่อสำหรับเซสชัน (หากไม่ได้ระบุ จะสร้างจากพรอมต์) | -| `--attach` | | แนบไปกับเซิร์ฟเวอร์ opencode ที่ทำงานอยู่ (เช่น http://localhost:4096) | -| `--port` | | พอร์ตสำหรับเซิร์ฟเวอร์ภายในเครื่อง (หากไม่ได้ระบุ จะใช้พอร์ตสุ่ม) | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| {"--command"} | | คำสั่งที่จะรัน (ใช้ส่วนที่เหลือของ args เป็นอาร์กิวเมนต์) | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--share"} | | สร้างลิงก์แชร์สำหรับเซสชัน | +| {"--model"} | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | +| {"--agent"} | | เอเจนต์ที่จะใช้ | +| {"--file"} | `-f` | แนบไฟล์ไปกับข้อความ | +| {"--format"} | | รูปแบบเอาต์พุต: text (จัดรูปแบบ) หรือ json (JSON ดิบ) | +| {"--title"} | | ชื่อสำหรับเซสชัน (หากไม่ได้ระบุ จะสร้างจากพรอมต์) | +| {"--attach"} | | แนบไปกับเซิร์ฟเวอร์ opencode ที่ทำงานอยู่ (เช่น http://localhost:4096) | +| {"--password"} | `-p` | รหัสผ่านการยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | ชื่อผู้ใช้การยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_USERNAME` หรือ `opencode`) | +| {"--dir"} | | ไดเร็กทอรีสำหรับรัน หรือเส้นทางบนเซิร์ฟเวอร์ระยะไกลเมื่อแนบ | +| {"--variant"} | | ตัวแปรโมเดล (ระดับการใช้เหตุผลเฉพาะผู้ให้บริการ) | +| {"--thinking"} | | แสดงบล็อกความคิด | +| {"--port"} | | พอร์ตสำหรับเซิร์ฟเวอร์ภายในเครื่อง (หากไม่ได้ระบุ จะใช้พอร์ตสุ่ม) | --- @@ -365,12 +374,12 @@ opencode serve #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | -------------------------------------- | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | -| `--mdns` | เปิดใช้งานการค้นหา mDNS | -| `--cors` | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | -------------------------------------- | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | +| {"--mdns"} | เปิดใช้งานการค้นหา mDNS | +| {"--cors"} | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | --- @@ -394,10 +403,10 @@ opencode session list ##### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------- | ---- | ----------------------------- | -| `--max-count` | `-n` | จำกัดการแสดงผล N รายการล่าสุด | -| `--format` | | รูปแบบ table หรือ json | +| แฟล็ก | สั้น | คำอธิบาย | +| ----------------------------------------- | ---- | ----------------------------- | +| {"--max-count"} | `-n` | จำกัดการแสดงผล N รายการล่าสุด | +| {"--format"} | | รูปแบบ table หรือ json | --- @@ -411,12 +420,12 @@ opencode stats #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ----------- | ---------------------------------------------------- | -| `--days` | แสดงสถิติของ N วันที่ผ่านมา (ค่าเริ่มต้น: ตลอดเวลา) | -| `--tools` | แสดงสถิติการใช้เครื่องมือ | -| `--models` | แสดงรายละเอียดการใช้งานโมเดล (ซ่อนไว้ตามค่าเริ่มต้น) | -| `--project` | กรองตามโครงการ (ค่าเริ่มต้น: โครงการปัจจุบัน) | +| แฟล็ก | คำอธิบาย | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | แสดงสถิติของ N วันที่ผ่านมา (ค่าเริ่มต้น: ตลอดเวลา) | +| {"--tools"} | แสดงสถิติการใช้เครื่องมือ | +| {"--models"} | แสดงรายละเอียดการใช้งานโมเดล (ซ่อนไว้ตามค่าเริ่มต้น) | +| {"--project"} | กรองตามโครงการ (ค่าเริ่มต้น: โครงการปัจจุบัน) | --- @@ -461,12 +470,12 @@ opencode web #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | -------------------------------------- | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | -| `--mdns` | เปิดใช้งานการค้นหา mDNS | -| `--cors` | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | -------------------------------------- | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | +| {"--mdns"} | เปิดใช้งานการค้นหา mDNS | +| {"--cors"} | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | --- @@ -482,11 +491,11 @@ opencode acp #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | ------------------ | -| `--cwd` | ไดเร็กทอรีการทำงาน | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | ------------------ | +| {"--cwd"} | ไดเร็กทอรีการทำงาน | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | --- @@ -500,12 +509,12 @@ opencode uninstall #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| --------------- | ---- | ----------------------------------- | -| `--keep-config` | `-c` | เก็บไฟล์การกำหนดค่าไว้ | -| `--keep-data` | `-d` | เก็บไฟล์ข้อมูล (เซสชันและสแน็ปช็อต) | -| `--dry-run` | | แสดงสิ่งที่จะลบออกโดยไม่ต้องทำจริง | -| `--force` | `-f` | บังคับลบโดยไม่มีการแจ้งเตือน | +| แฟล็ก | สั้น | คำอธิบาย | +| ------------------------------------------- | ---- | ----------------------------------- | +| {"--keep-config"} | `-c` | เก็บไฟล์การกำหนดค่าไว้ | +| {"--keep-data"} | `-d` | เก็บไฟล์ข้อมูล (เซสชันและสแน็ปช็อต) | +| {"--dry-run"} | | แสดงสิ่งที่จะลบออกโดยไม่ต้องทำจริง | +| {"--force"} | `-f` | บังคับลบโดยไม่มีการแจ้งเตือน | --- @@ -531,9 +540,9 @@ opencode upgrade v0.1.48 #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ---------- | ---- | ----------------------------------------------- | -| `--method` | `-m` | วิธีการติดตั้งที่ใช้ curl, npm, pnpm, bun, brew | +| แฟล็ก | สั้น | คำอธิบาย | +| -------------------------------------- | ---- | ----------------------------------------------- | +| {"--method"} | `-m` | วิธีการติดตั้งที่ใช้ curl, npm, pnpm, bun, brew | --- @@ -541,12 +550,12 @@ opencode upgrade v0.1.48 OpenCode CLI ยอมรับแฟล็กสากลต่อไปนี้สำหรับทุกคำสั่ง -| แฟล็ก | สั้น | คำอธิบาย | -| -------------- | ---- | ----------------------------------------- | -| `--help` | `-h` | แสดงความช่วยเหลือ | -| `--version` | `-v` | พิมพ์เวอร์ชัน | -| `--print-logs` | | พิมพ์บันทึกไปยัง stderr | -| `--log-level` | | ระดับการบันทึก (DEBUG, INFO, WARN, ERROR) | +| แฟล็ก | สั้น | คำอธิบาย | +| ------------------------------------------ | ---- | ----------------------------------------- | +| {"--help"} | `-h` | แสดงความช่วยเหลือ | +| {"--version"} | `-v` | พิมพ์เวอร์ชัน | +| {"--print-logs"} | | พิมพ์บันทึกไปยัง stderr | +| {"--log-level"} | | ระดับการบันทึก (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/tr/cli.mdx b/packages/web/src/content/docs/tr/cli.mdx index 75ecca992..25b74ecfe 100644 --- a/packages/web/src/content/docs/tr/cli.mdx +++ b/packages/web/src/content/docs/tr/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ------------ | ---- | --------------------------------------------------------------------------- | -| `--continue` | `-c` | Son oturuma devam et | -| `--session` | `-s` | Devam edecek oturum kimliği | -| `--fork` | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | -| `--prompt` | | Kullanılacak prompt | -| `--model` | `-m` | provider/model biçiminde kullanılacak model | -| `--agent` | | Kullanılacak agent | -| `--port` | | Dinlenecek port | -| `--hostname` | | Dinlenecek host adı | +| Bayrak | Kısa | Açıklama | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum kimliği | +| {"--fork"} | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | +| {"--prompt"} | | Kullanılacak prompt | +| {"--model"} | `-m` | provider/model biçiminde kullanılacak model | +| {"--agent"} | | Kullanılacak agent | +| {"--port"} | | Dinlenecek port | +| {"--hostname"} | | Dinlenecek host adı | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Bayraklar -| Bayrak | Kısa | Tanım | -| ----------- | ---- | ------------------------------------ | -| `--dir` | | TUI'yi başlatmak için çalışma dizini | -| `--session` | `-s` | Devam edecek oturum açma bilgileri | +| Bayrak | Kısa | Tanım | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | TUI'yi başlatmak için çalışma dizini | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum açma bilgileri | +| {"--fork"} | | Devam ederken oturumu çatalla (`--continue` veya `--session` ile kullanın) | +| {"--password"} | `-p` | Temel kimlik doğrulama parolası (varsayılan: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Temel kimlik doğrulama kullanıcı adı (varsayılan: `OPENCODE_SERVER_USERNAME` veya `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Bayraklar -| Bayrak | Açıklama | -| --------- | ------------------------------------------- | -| `--event` | Aracıyı çalıştırmak için GitHub sahte olayı | -| `--token` | GitHub personal access token | +| Bayrak | Açıklama | +| ------------------------------------- | ------------------------------------------- | +| {"--event"} | Aracıyı çalıştırmak için GitHub sahte olayı | +| {"--token"} | GitHub personal access token | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Bayraklar -| Bayrak | Tanım | -| ----------- | --------------------------------------------------------------------------- | -| `--refresh` | Modeller.dev'den model önbelleğini yenileyin | -| `--verbose` | Daha ayrıntılı model çıktısı kullanın (maliyetler gibi meta veriler içerir) | +| Bayrak | Tanım | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--refresh"} | Modeller.dev'den model önbelleğini yenileyin | +| {"--verbose"} | Daha ayrıntılı model çıktısı kullanın (maliyetler gibi meta veriler içerir) | Önbelleğe alınan model listesini güncellemek için `--refresh` bayrağını kullanın. Bu, bir sağlayıcıya yeni modeller eklendiğinde ve bunları opencode'da görmek istediğinizde kullanışlıdır. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ------------ | ---- | --------------------------------------------------------------------------------- | -| `--command` | | Çalıştırılacak komut, args için mesajı kullanın | -| `--continue` | `-c` | Son oturuma devam et | -| `--session` | `-s` | Devam edecek oturum kimliği | -| `--fork` | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | -| `--share` | | Oturumu paylaşın | -| `--model` | `-m` | provider/model biçiminde kullanılacak model | -| `--agent` | | Kullanılacak temsilci | -| `--file` | `-f` | Mesaja eklenecek dosya(lar) | -| `--format` | | Biçim: varsayılan (biçimlendirilmiş) veya json (ham JSON olayları) | -| `--title` | | Oturumun başlığı (değer sağlanmazsa kısaltılmış bilgi istemi kullanılır) | -| `--attach` | | Çalışan bir opencode sunucusuna ekleyin (ör. http://localhost:4096) | -| `--port` | | Yerel sunucunun bağlantı noktası (varsayılan olarak rastgele bağlantı noktasıdır) | +| Bayrak | Kısa | Açıklama | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Çalıştırılacak komut, args için mesajı kullanın | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum kimliği | +| {"--fork"} | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | +| {"--share"} | | Oturumu paylaşın | +| {"--model"} | `-m` | provider/model biçiminde kullanılacak model | +| {"--agent"} | | Kullanılacak temsilci | +| {"--file"} | `-f` | Mesaja eklenecek dosya(lar) | +| {"--format"} | | Biçim: varsayılan (biçimlendirilmiş) veya json (ham JSON olayları) | +| {"--title"} | | Oturumun başlığı (değer sağlanmazsa kısaltılmış bilgi istemi kullanılır) | +| {"--attach"} | | Çalışan bir opencode sunucusuna ekleyin (ör. http://localhost:4096) | +| {"--password"} | `-p` | Temel kimlik doğrulama parolası (varsayılan: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Temel kimlik doğrulama kullanıcı adı (varsayılan: `OPENCODE_SERVER_USERNAME` veya `opencode`) | +| {"--dir"} | | Çalıştırılacak dizin veya bağlanırken uzak sunucudaki yol | +| {"--variant"} | | Model varyantı (sağlayıcıya özgü muhakeme düzeyi) | +| {"--thinking"} | | Düşünme bloklarını göster | +| {"--port"} | | Yerel sunucunun bağlantı noktası (varsayılan olarak rastgele bağlantı noktasıdır) | --- @@ -364,12 +373,12 @@ Bu, TUI arayüzü olmadan opencode işlevselliğine API erişimi sağlayan bir H #### Bayraklar -| Bayrak | Tanım | -| ------------ | ------------------------------------------ | -| `--port` | Dinlenecek bağlantı noktası | -| `--hostname` | Dinlenecek ana bilgisayar adı | -| `--mdns` | mDNS bulmayı etkinleştir | -| `--cors` | CORS'a izin verecek ek tarayıcı kaynakları | +| Bayrak | Tanım | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Dinlenecek bağlantı noktası | +| {"--hostname"} | Dinlenecek ana bilgisayar adı | +| {"--mdns"} | mDNS bulmayı etkinleştir | +| {"--cors"} | CORS'a izin verecek ek tarayıcı kaynakları | --- @@ -393,10 +402,10 @@ opencode session list ##### Bayraklar -| Bayrak | Kısa | Tanım | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | En son N oturumla sınırla | -| `--format` | | Çıkış formatı: tablo veya json (tablo) | +| Bayrak | Kısa | Tanım | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | En son N oturumla sınırla | +| {"--format"} | | Çıkış formatı: tablo veya json (tablo) | --- @@ -410,12 +419,12 @@ opencode stats #### Bayraklar -| Bayrak | Açıklama | -| ----------- | ----------------------------------------------------------------------------------------------------------- | -| `--days` | Son N güne ait istatistikleri göster (tüm zamanlar) | -| `--tools` | Gösterilecek araç sayısı (tümü) | -| `--models` | Model kullanım dökümünü göster (varsayılan olarak gizlidir). En üstteki N'yi göstermek için bir sayı iletin | -| `--project` | Projeye göre filtrele (tüm projeler, boş değer: mevcut proje) | +| Bayrak | Açıklama | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| {"--days"} | Son N güne ait istatistikleri göster (tüm zamanlar) | +| {"--tools"} | Gösterilecek araç sayısı (tümü) | +| {"--models"} | Model kullanım dökümünü göster (varsayılan olarak gizlidir). En üstteki N'yi göstermek için bir sayı iletin | +| {"--project"} | Projeye göre filtrele (tüm projeler, boş değer: mevcut proje) | --- @@ -460,12 +469,12 @@ Bu, bir HTTP sunucusunu başlatır ve bir web arayüzü aracılığıyla opencod #### Bayraklar -| Bayrak | Tanım | -| ------------ | ------------------------------------------ | -| `--port` | Dinlenecek bağlantı noktası | -| `--hostname` | Dinlenecek ana bilgisayar adı | -| `--mdns` | mDNS bulmayı etkinleştir | -| `--cors` | CORS'a izin verecek ek tarayıcı kaynakları | +| Bayrak | Tanım | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Dinlenecek bağlantı noktası | +| {"--hostname"} | Dinlenecek ana bilgisayar adı | +| {"--mdns"} | mDNS bulmayı etkinleştir | +| {"--cors"} | CORS'a izin verecek ek tarayıcı kaynakları | --- @@ -481,11 +490,11 @@ Bu komut, nd-JSON kullanarak stdin/stdout aracılığıyla iletişim kuran bir A #### Bayraklar -| Bayrak | Açıklama | -| ------------ | ------------------- | -| `--cwd` | Çalışma dizini | -| `--port` | Dinlenecek port | -| `--hostname` | Dinlenecek host adı | +| Bayrak | Açıklama | +| ---------------------------------------- | ------------------- | +| {"--cwd"} | Çalışma dizini | +| {"--port"} | Dinlenecek port | +| {"--hostname"} | Dinlenecek host adı | --- @@ -499,12 +508,12 @@ opencode uninstall #### Bayraklar -| Bayrak | Kısa | Tanım | -| --------------- | ---- | ----------------------------------------------- | -| `--keep-config` | `-c` | Yapılandırma dosyalarını sakla | -| `--keep-data` | `-d` | Oturum verilerini ve anlık görüntüleri saklayın | -| `--dry-run` | | Nelerin kaldırılmadan kaldırılacağı göster | -| `--force` | `-f` | Onay istemlerini atla | +| Bayrak | Kısa | Tanım | +| ------------------------------------------- | ---- | ----------------------------------------------- | +| {"--keep-config"} | `-c` | Yapılandırma dosyalarını sakla | +| {"--keep-data"} | `-d` | Oturum verilerini ve anlık görüntüleri saklayın | +| {"--dry-run"} | | Nelerin kaldırılmadan kaldırılacağı göster | +| {"--force"} | `-f` | Onay istemlerini atla | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ---------- | ---- | ------------------------------------------------------ | -| `--method` | `-m` | Kullanılan kurulum yöntemi: curl, npm, pnpm, bun, brew | +| Bayrak | Kısa | Açıklama | +| -------------------------------------- | ---- | ------------------------------------------------------ | +| {"--method"} | `-m` | Kullanılan kurulum yöntemi: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI aşağıdaki global bayrakları destekler. -| Bayrak | Kısa | Tanım | -| -------------- | ---- | ---------------------------------------- | -| `--help` | `-h` | Yardımı görüntüle | -| `--version` | `-v` | Sürüm numarasını yazdır | -| `--print-logs` | | Günlükleri stderr'e yazdır | -| `--log-level` | | Günlük düzeyi (DEBUG, INFO, WARN, ERROR) | +| Bayrak | Kısa | Tanım | +| ------------------------------------------ | ---- | ---------------------------------------- | +| {"--help"} | `-h` | Yardımı görüntüle | +| {"--version"} | `-v` | Sürüm numarasını yazdır | +| {"--print-logs"} | | Günlükleri stderr'e yazdır | +| {"--log-level"} | | Günlük düzeyi (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx index c0cff134a..aa992b673 100644 --- a/packages/web/src/content/docs/zh-cn/cli.mdx +++ b/packages/web/src/content/docs/zh-cn/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 标志 -| 标志 | 简写 | 描述 | -| ------------ | ---- | --------------------------------------------------------- | -| `--continue` | `-c` | 继续上一个会话 | -| `--session` | `-s` | 要继续的会话 ID | -| `--fork` | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | -| `--prompt` | | 要使用的提示词 | -| `--model` | `-m` | 要使用的模型,格式为 provider/model | -| `--agent` | | 要使用的代理 | -| `--port` | | 监听端口 | -| `--hostname` | | 监听主机名 | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | --------------------------------------------------------- | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--prompt"} | | 要使用的提示词 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 监听端口 | +| {"--hostname"} | | 监听主机名 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 标志 -| 标志 | 简写 | 描述 | -| ----------- | ---- | ------------------- | -| `--dir` | | 启动 TUI 的工作目录 | -| `--session` | `-s` | 要继续的会话 ID | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--dir"} | | 启动 TUI 的工作目录 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时派生会话(与 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 标志 -| 标志 | 描述 | -| --------- | ------------------------------ | -| `--event` | 用于运行代理的 GitHub 模拟事件 | -| `--token` | GitHub 个人访问令牌 | +| 标志 | 描述 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用于运行代理的 GitHub 模拟事件 | +| {"--token"} | GitHub 个人访问令牌 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 标志 -| 标志 | 描述 | -| ----------- | ---------------------------------------- | -| `--refresh` | 从 models.dev 刷新模型缓存 | -| `--verbose` | 使用更详细的模型输出(包含费用等元数据) | +| 标志 | 描述 | +| --------------------------------------- | ---------------------------------------- | +| {"--refresh"} | 从 models.dev 刷新模型缓存 | +| {"--verbose"} | 使用更详细的模型输出(包含费用等元数据) | 使用 `--refresh` 标志可以更新缓存的模型列表。当提供商新增了模型并且您希望在 OpenCode 中看到它们时,此功能非常有用。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 标志 -| 标志 | 简写 | 描述 | -| ------------ | ---- | -------------------------------------------------------------- | -| `--command` | | 要运行的命令,使用 message 作为参数 | -| `--continue` | `-c` | 继续上一个会话 | -| `--session` | `-s` | 要继续的会话 ID | -| `--fork` | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | -| `--share` | | 分享会话 | -| `--model` | `-m` | 要使用的模型,格式为 provider/model | -| `--agent` | | 要使用的代理 | -| `--file` | `-f` | 附加到消息的文件 | -| `--format` | | 格式:default(格式化输出)或 json(原始 JSON 事件) | -| `--title` | | 会话标题(未提供值时使用截断的提示词) | -| `--attach` | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | -| `--port` | | 本地服务器端口(默认为随机端口) | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--command"} | | 要运行的命令,使用 message 作为参数 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--share"} | | 分享会话 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到消息的文件 | +| {"--format"} | | 格式:default(格式化输出)或 json(原始 JSON 事件) | +| {"--title"} | | 会话标题(未提供值时使用截断的提示词) | +| {"--attach"} | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 运行目录,或附加时远程服务器上的路径 | +| {"--variant"} | | 模型变体(特定于提供商的推理级别) | +| {"--thinking"} | | 显示思考块 | +| {"--port"} | | 本地服务器端口(默认为随机端口) | --- @@ -364,12 +373,12 @@ opencode serve #### 标志 -| 标志 | 描述 | -| ------------ | -------------------------- | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | -| `--mdns` | 启用 mDNS 发现 | -| `--cors` | 允许 CORS 的额外浏览器来源 | +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | --- @@ -393,10 +402,10 @@ opencode session list ##### 标志 -| 标志 | 简写 | 描述 | -| ------------- | ---- | ------------------------------------- | -| `--max-count` | `-n` | 限制为最近 N 个会话 | -| `--format` | | 输出格式:table 或 json(默认 table) | +| 标志 | 简写 | 描述 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制为最近 N 个会话 | +| {"--format"} | | 输出格式:table 或 json(默认 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 标志 -| 标志 | 描述 | -| ----------- | ------------------------------------------------------ | -| `--days` | 显示最近 N 天的统计信息(默认为所有时间) | -| `--tools` | 显示的工具数量(默认为全部) | -| `--models` | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | -| `--project` | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | +| 标志 | 描述 | +| --------------------------------------- | ------------------------------------------------------ | +| {"--days"} | 显示最近 N 天的统计信息(默认为所有时间) | +| {"--tools"} | 显示的工具数量(默认为全部) | +| {"--models"} | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | +| {"--project"} | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | --- @@ -460,12 +469,12 @@ opencode web #### 标志 -| 标志 | 描述 | -| ------------ | -------------------------- | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | -| `--mdns` | 启用 mDNS 发现 | -| `--cors` | 允许 CORS 的额外浏览器来源 | +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | --- @@ -481,11 +490,11 @@ opencode acp #### 标志 -| 标志 | 描述 | -| ------------ | ---------- | -| `--cwd` | 工作目录 | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | +| 标志 | 描述 | +| ---------------------------------------- | ---------- | +| {"--cwd"} | 工作目录 | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 标志 -| 标志 | 简写 | 描述 | -| --------------- | ---- | ------------------------------ | -| `--keep-config` | `-c` | 保留配置文件 | -| `--keep-data` | `-d` | 保留会话数据和快照 | -| `--dry-run` | | 显示将被删除的内容但不实际删除 | -| `--force` | `-f` | 跳过确认提示 | +| 标志 | 简写 | 描述 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留配置文件 | +| {"--keep-data"} | `-d` | 保留会话数据和快照 | +| {"--dry-run"} | | 显示将被删除的内容但不实际删除 | +| {"--force"} | `-f` | 跳过确认提示 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 标志 -| 标志 | 简写 | 描述 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | +| 标志 | 简写 | 描述 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI 接受以下全局标志。 -| 标志 | 简写 | 描述 | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | 显示帮助信息 | -| `--version` | `-v` | 打印版本号 | -| `--print-logs` | | 将日志输出到 stderr | -| `--log-level` | | 日志级别(DEBUG、INFO、WARN、ERROR) | +| 标志 | 简写 | 描述 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 显示帮助信息 | +| {"--version"} | `-v` | 打印版本号 | +| {"--print-logs"} | | 将日志输出到 stderr | +| {"--log-level"} | | 日志级别(DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx index 4df9d13fd..0d51bff2f 100644 --- a/packages/web/src/content/docs/zh-tw/cli.mdx +++ b/packages/web/src/content/docs/zh-tw/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------ | ---- | ------------------------------------------------------------- | -| `--continue` | `-c` | 繼續上一個工作階段 | -| `--session` | `-s` | 要繼續的工作階段 ID | -| `--fork` | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | -| `--prompt` | | 要使用的提示詞 | -| `--model` | `-m` | 要使用的模型,格式為 provider/model | -| `--agent` | | 要使用的代理 | -| `--port` | | 監聽連接埠 | -| `--hostname` | | 監聽主機名稱 | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ------------------------------------------------------------- | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--prompt"} | | 要使用的提示詞 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 監聽連接埠 | +| {"--hostname"} | | 監聽主機名稱 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ----------- | ---- | ------------------- | -| `--dir` | | 啟動 TUI 的工作目錄 | -| `--session` | `-s` | 要繼續的工作階段 ID | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--dir"} | | 啟動 TUI 的工作目錄 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分支工作階段(與 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 旗標 -| 旗標 | 說明 | -| --------- | ------------------------------ | -| `--event` | 用於執行代理的 GitHub 模擬事件 | -| `--token` | GitHub 個人存取權杖 | +| 旗標 | 說明 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用於執行代理的 GitHub 模擬事件 | +| {"--token"} | GitHub 個人存取權杖 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 旗標 -| 旗標 | 說明 | -| ----------- | ------------------------------------------ | -| `--refresh` | 從 models.dev 重新整理模型快取 | -| `--verbose` | 使用更詳細的模型輸出(包含費用等中繼資料) | +| 旗標 | 說明 | +| --------------------------------------- | ------------------------------------------ | +| {"--refresh"} | 從 models.dev 重新整理模型快取 | +| {"--verbose"} | 使用更詳細的模型輸出(包含費用等中繼資料) | 使用 `--refresh` 旗標可以更新快取的模型列表。當供應商新增了模型並且您希望在 OpenCode 中看到它們時,此功能非常有用。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------ | ---- | -------------------------------------------------------------- | -| `--command` | | 要執行的指令,使用 message 作為參數 | -| `--continue` | `-c` | 繼續上一個工作階段 | -| `--session` | `-s` | 要繼續的工作階段 ID | -| `--fork` | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | -| `--share` | | 分享工作階段 | -| `--model` | `-m` | 要使用的模型,格式為 provider/model | -| `--agent` | | 要使用的代理 | -| `--file` | `-f` | 附加到訊息的檔案 | -| `--format` | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | -| `--title` | | 工作階段標題(未提供值時使用截斷的提示詞) | -| `--attach` | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | -| `--port` | | 本地伺服器連接埠(預設為隨機連接埠) | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--command"} | | 要執行的指令,使用 message 作為參數 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--share"} | | 分享工作階段 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到訊息的檔案 | +| {"--format"} | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | +| {"--title"} | | 工作階段標題(未提供值時使用截斷的提示詞) | +| {"--attach"} | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 執行目錄,或附加時遠端伺服器上的路徑 | +| {"--variant"} | | 模型變體(特定於提供者的推理級別) | +| {"--thinking"} | | 顯示思考區塊 | +| {"--port"} | | 本地伺服器連接埠(預設為隨機連接埠) | --- @@ -364,12 +373,12 @@ opencode serve #### 旗標 -| 旗標 | 說明 | -| ------------ | -------------------------- | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | -| `--mdns` | 啟用 mDNS 探索 | -| `--cors` | 允許 CORS 的額外瀏覽器來源 | +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | --- @@ -393,10 +402,10 @@ opencode session list ##### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------- | ---- | ------------------------------------- | -| `--max-count` | `-n` | 限制為最近 N 個工作階段 | -| `--format` | | 輸出格式:table 或 json(預設 table) | +| 旗標 | 簡寫 | 說明 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制為最近 N 個工作階段 | +| {"--format"} | | 輸出格式:table 或 json(預設 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 旗標 -| 旗標 | 說明 | -| ----------- | ---------------------------------------------------- | -| `--days` | 顯示最近 N 天的統計資訊(預設為所有時間) | -| `--tools` | 顯示的工具數量(預設為全部) | -| `--models` | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | -| `--project` | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | +| 旗標 | 說明 | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | 顯示最近 N 天的統計資訊(預設為所有時間) | +| {"--tools"} | 顯示的工具數量(預設為全部) | +| {"--models"} | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | +| {"--project"} | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | --- @@ -460,12 +469,12 @@ opencode web #### 旗標 -| 旗標 | 說明 | -| ------------ | -------------------------- | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | -| `--mdns` | 啟用 mDNS 探索 | -| `--cors` | 允許 CORS 的額外瀏覽器來源 | +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | --- @@ -481,11 +490,11 @@ opencode acp #### 旗標 -| 旗標 | 說明 | -| ------------ | ------------ | -| `--cwd` | 工作目錄 | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | +| 旗標 | 說明 | +| ---------------------------------------- | ------------ | +| {"--cwd"} | 工作目錄 | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 旗標 -| 旗標 | 簡寫 | 說明 | -| --------------- | ---- | ------------------------------ | -| `--keep-config` | `-c` | 保留設定檔 | -| `--keep-data` | `-d` | 保留工作階段資料和快照 | -| `--dry-run` | | 顯示將被刪除的內容但不實際刪除 | -| `--force` | `-f` | 跳過確認提示 | +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留設定檔 | +| {"--keep-data"} | `-d` | 保留工作階段資料和快照 | +| {"--dry-run"} | | 顯示將被刪除的內容但不實際刪除 | +| {"--force"} | `-f` | 跳過確認提示 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | +| 旗標 | 簡寫 | 說明 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI 接受以下全域旗標。 -| 旗標 | 簡寫 | 說明 | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | 顯示說明資訊 | -| `--version` | `-v` | 印出版本號 | -| `--print-logs` | | 將日誌輸出到 stderr | -| `--log-level` | | 日誌等級(DEBUG、INFO、WARN、ERROR) | +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 顯示說明資訊 | +| {"--version"} | `-v` | 印出版本號 | +| {"--print-logs"} | | 將日誌輸出到 stderr | +| {"--log-level"} | | 日誌等級(DEBUG、INFO、WARN、ERROR) | --- From 146ff8ad855072b8ea5b8e92a91a5ffe53f2ed78 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:08:04 -0400 Subject: [PATCH 041/174] feat(cli): add effectCmd wrapper + convert models command (#25429) --- packages/opencode/src/cli/cmd/models.ts | 99 ++++++++------------- packages/opencode/src/cli/effect-cmd.ts | 52 +++++++++++ packages/opencode/src/cli/error.ts | 7 ++ packages/opencode/src/effect/app-runtime.ts | 3 + 4 files changed, 101 insertions(+), 60 deletions(-) create mode 100644 packages/opencode/src/cli/effect-cmd.ts diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 0b5d35275..cfbb959e7 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,19 +1,16 @@ -import type { Argv } from "yargs" -import { Instance } from "../../project/instance" +import { EOL } from "os" +import { Effect } from "effect" import { Provider } from "@/provider/provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "@/provider/models" -import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" -import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" -export const ModelsCommand = cmd({ +export const ModelsCommand = effectCmd({ command: "models [provider]", describe: "list all available models", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("provider", { describe: "provider ID to filter models by", type: "string", @@ -26,63 +23,45 @@ export const ModelsCommand = cmd({ .option("refresh", { describe: "refresh the models cache from models.dev", type: "boolean", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - await ModelsDev.refresh(true) + // followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap. + yield* Effect.promise(() => ModelsDev.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - await Instance.provide({ - directory: process.cwd(), - async fn() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() + const provider = yield* Provider.Service + const providers = yield* provider.list() - const print = (providerID: ProviderID, verbose?: boolean) => { - const provider = providers[providerID] - const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } + const print = (providerID: ProviderID, verbose?: boolean) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } + } - if (args.provider) { - const providerID = ProviderID.make(args.provider) - const provider = providers[providerID] - if (!provider) { - yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) - return - } + if (args.provider) { + const providerID = ProviderID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + print(providerID, args.verbose) + return + } - yield* Effect.sync(() => print(providerID, args.verbose)) - return - } - - const ids = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) - - yield* Effect.sync(() => { - for (const providerID of ids) { - print(ProviderID.make(providerID), args.verbose) - } - }) - }), - ) - }, + const ids = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) }) - }, + + for (const providerID of ids) print(ProviderID.make(providerID), args.verbose) + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 000000000..758ac6904 --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,52 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { cmd } from "./cmd/cmd" + +/** + * User-visible command failure. Throw via `fail("...")` from an effectCmd handler + * to surface a printed message + non-zero exit. Recognised by the global error + * formatter in `src/cli/error.ts` (FormatError), so the existing top-level + * catch + cleanup in `src/index.ts` runs normally. + */ +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +/** + * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is + * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. + * + * Errors propagate to the existing top-level handler in `src/index.ts`; use + * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * + * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, + * which adds a named tracing span per CLI invocation. Once all commands use + * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's + * `Command.make(...)` won't touch any handler bodies. + */ +export const effectCmd = (opts: { + command: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +}) => + cmd<{}, Args>({ + command: opts.command, + describe: opts.describe, + builder: opts.builder as never, + async handler(rawArgs) { + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as Args + const directory = opts.directory?.(args) ?? process.cwd() + await AppRuntime.runPromise( + InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))), + ) + }, + }) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index adf52f568..628aa9569 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean { } export function FormatError(input: unknown) { + // CliError: domain failure surfaced from an effectCmd handler via fail("...") + if (isTaggedError(input, "CliError")) { + const data = input as ErrorLike & { exitCode?: number } + if (data.exitCode != null) process.exitCode = data.exitCode + return data.message ?? "" + } + // MCPFailed: { name: string } if (NamedError.hasName(input, "MCPFailed")) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f3376ad85..97cd2f629 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -105,6 +105,9 @@ export const AppLayer = Layer.mergeAll( const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick + +/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */ +export type AppServices = ManagedRuntime.ManagedRuntime.Services const wrap = (effect: Parameters[0]) => attach(effect as never) as never export const AppRuntime: Runtime = { From ff4779ca11a0a2daa7541dcaa054d5c7c4a88d8b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 16:09:04 +0000 Subject: [PATCH 042/174] chore: generate --- packages/opencode/src/cli/effect-cmd.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 758ac6904..cc4dd2ed7 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -45,8 +45,6 @@ export const effectCmd = (opts: { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise( - InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))), - ) + await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args)))) }, }) From b460db15d7cb8613e7619f429f9b660506954639 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 11:12:07 -0500 Subject: [PATCH 043/174] tweak: allow read tool to accept offset of 0 (#25431) --- packages/opencode/src/tool/read.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 78436489f..bf01fc7d2 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -154,10 +154,6 @@ export const ReadTool = Tool.define( params: Schema.Schema.Type, ctx: Tool.Context, ) { - if (params.offset !== undefined && params.offset < 1) { - return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) - } - const instance = yield* InstanceState.context let filepath = params.filePath if (!path.isAbsolute(filepath)) { @@ -192,7 +188,7 @@ export const ReadTool = Tool.define( if (stat.type === "Directory") { const items = yield* list(filepath) const limit = params.limit ?? DEFAULT_READ_LIMIT - const offset = params.offset ?? 1 + const offset = params.offset || 1 const start = offset - 1 const sliced = items.slice(start, start + limit) const truncated = start + sliced.length < items.length @@ -249,7 +245,7 @@ export const ReadTool = Tool.define( } const file = yield* Effect.promise(() => - lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }), + lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }), ) if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) { return yield* Effect.fail( From f8738c900285d6725ca79ca7b47c8c5ccee1a56e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 13:59:08 -0400 Subject: [PATCH 044/174] feat(models): effectify ModelsDev as Service (#25434) --- packages/opencode/src/cli/cmd/models.ts | 3 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/provider/models.ts | 192 +++++++------ packages/opencode/src/provider/provider.ts | 6 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/provider.ts | 2 +- .../opencode/test/provider/models.test.ts | 260 ++++++++++++++++++ 8 files changed, 383 insertions(+), 86 deletions(-) create mode 100644 packages/opencode/test/provider/models.test.ts diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index cfbb959e7..183b1816d 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({ }), handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - // followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap. - yield* Effect.promise(() => ModelsDev.refresh(true)) + yield* ModelsDev.Service.use((s) => s.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 97cd2f629..bbf1f4f8d 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" @@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll( Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, + ModelsDev.defaultLayer, Provider.defaultLayer, ProviderAuth.defaultLayer, Agent.defaultLayer, diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 170fe516c..3654f66c7 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,25 +1,14 @@ import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import path from "path" -import { Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Installation } from "../installation" import { Flag } from "@opencode-ai/core/flag/flag" -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" - -// Try to import bundled snapshot (generated at build time) -// Falls back to undefined in dev mode when snapshot doesn't exist -/* @ts-ignore */ - -const log = Log.create({ service: "models.dev" }) -const source = url() -const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, -) -const ttl = 5 * 60 * 1000 +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { makeRuntime } from "@/effect/run-service" +import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ input: Schema.Finite, @@ -101,76 +90,119 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type -function url() { - return Flag.OPENCODE_MODELS_URL || "https://models.dev" +export interface Interface { + readonly get: () => Effect.Effect> + readonly refresh: (force?: boolean) => Effect.Effect } -function fresh() { - return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl -} +export class Service extends Context.Service()("@opencode/ModelsDev") {} -function skip(force: boolean) { - return !force && fresh() -} +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) -const fetchApi = async () => { - const result = await fetch(`${url()}/api.json`, { - headers: { "User-Agent": Installation.USER_AGENT }, - signal: AbortSignal.timeout(10000), - }) - return { ok: result.ok, text: await result.text() } -} + const source = Flag.OPENCODE_MODELS_URL || "https://models.dev" + const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + ) + const ttl = Duration.minutes(5) + const lockKey = `models-dev:${filepath}` -export const Data = lazy(async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - // @ts-ignore - const snapshot = await import("./models-snapshot.js") - .then((m) => m.snapshot as Record) - .catch(() => undefined) - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - const result2 = await fetchApi() - if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) - } - return JSON.parse(result2.text) - }) -}) - -export async function get() { - const result = await Data() - return result as Record -} - -export async function refresh(force = false) { - if (skip(force)) return Data.reset() - await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return Data.reset() - const result = await fetchApi() - if (!result.ok) return - await Filesystem.write(filepath, result.text) - Data.reset() - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, + const fresh = Effect.fnUntraced(function* () { + const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat) return false + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() + return Date.now() - mtime < Duration.toMillis(ttl) }) - }) -} -if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - void refresh() - setInterval( - async () => { - await refresh() - }, - 60 * 1000 * 60, - ).unref() -} + const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { + return yield* HttpClientRequest.get(`${source}/api.json`).pipe( + HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT), + http.execute, + Effect.flatMap((res) => res.text), + Effect.timeout("10 seconds"), + ) + }) + + const loadFromDisk = fs + .readJson(Flag.OPENCODE_MODELS_PATH ?? filepath) + .pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) + + // Bundled at build time; absent in dev — `tryPromise` covers both. + const loadSnapshot = Effect.tryPromise({ + // @ts-ignore — generated at build time, may not exist in dev + try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record | undefined), + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + yield* fs.writeWithDirs(filepath, text) + return text + }) + + const populate = Effect.gen(function* () { + const fromDisk = yield* loadFromDisk + if (fromDisk) return fromDisk + const snapshot = yield* loadSnapshot + if (snapshot) return snapshot + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + // Flock is cross-process: concurrent opencode CLIs can race on this cache file. + const text = yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + return yield* fetchAndWrite() + }), + ) + return JSON.parse(text) as Record + }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + + const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) + + const get = (): Effect.Effect> => cachedGet + + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + if (!force && (yield* fresh())) return + yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + // Re-check under the lock: another process may have refreshed between + // our outer check and lock acquisition. + if (!force && (yield* fresh())) return + yield* fetchAndWrite() + yield* invalidate + }), + ).pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), + Effect.ignore, + ) + }) + + if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { + // Schedule.spaced runs the effect once, then waits between completions. + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore)) + } + + return Service.of({ get, refresh }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), +) + +// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers). +// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one +// AppRuntime sees — Effect callers and Promise callers operate on the same cache. +const runtime = makeRuntime(Service, defaultLayer) +export const get = () => runtime.runPromise((s) => s.get()) +export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force)) export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d9806d13..939110e04 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const layer: Layer.Layer< Service, never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -1083,13 +1083,14 @@ const layer: Layer.Layer< const auth = yield* Auth.Service const env = yield* Env.Service const plugin = yield* Plugin.Service + const modelsDevSvc = yield* ModelsDev.Service const state = yield* InstanceState.make(() => Effect.gen(function* () { using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const modelsDev = yield* modelsDevSvc.get() const database = mapValues(modelsDev, fromModelsDevProvider) const providers: Record = {} as Record @@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index c8689eaba..f9df530a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 3ac0298c6..767bfc31d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { Question } from "@/question" @@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) { InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, MCP.defaultLayer, + ModelsDev.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index cc6735590..8ff7bc310 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() => const svc = yield* Provider.Service const cfg = yield* Config.Service const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts new file mode 100644 index 000000000..feb5bb589 --- /dev/null +++ b/packages/opencode/test/provider/models.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { ModelsDev } from "../../src/provider/models" +import { it } from "../lib/effect" +import { rm, writeFile, utimes, mkdir } from "fs/promises" +import path from "path" + +// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can +// resolve providers without network. These tests need to drive the on-disk +// cache themselves and silence the eager refresh fork. Save/restore around +// the suite — never leak the mutation to subsequent test files in the same +// bun process. +const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH +const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH +beforeAll(() => { + Flag.OPENCODE_MODELS_PATH = undefined + Flag.OPENCODE_DISABLE_MODELS_FETCH = true +}) +afterAll(() => { + Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH + Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH +}) + +const cacheFile = path.join(Global.Path.cache, "models.json") + +const fixture: Record = { + acme: { + id: "acme", + name: "Acme", + env: ["ACME_API_KEY"], + models: { + "acme-1": { + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }, + }, + }, +} + +const fixture2: Record = { + beta: { + id: "beta", + name: "Beta", + env: ["BETA_API_KEY"], + models: { + "beta-1": { + id: "beta-1", + name: "Beta One", + release_date: "2026-02-01", + attachment: false, + reasoning: true, + temperature: false, + tool_call: false, + limit: { context: 64000, output: 4096 }, + }, + }, + }, +} + +interface MockState { + body: string + status: number + calls: Array<{ url: string }> +} + +const makeMockClient = (state: Ref.Ref) => + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] })) + const s = yield* Ref.get(state) + return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) + }), + ) + +const buildLayer = (state: Ref.Ref) => + // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // and Effect.provide uses a process-global MemoMap by default — without fresh, + // every test would reuse the cachedInvalidateWithTTL state from the first run. + Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + Layer.provide(AppFileSystem.defaultLayer), + ) + +const writeCache = (data: object, mtimeMs?: number) => + Effect.promise(async () => { + await mkdir(Global.Path.cache, { recursive: true }) + await writeFile(cacheFile, JSON.stringify(data)) + if (mtimeMs !== undefined) { + const t = mtimeMs / 1000 + await utimes(cacheFile, t, t) + } + }) + +const provided = (state: Ref.Ref, eff: Effect.Effect) => + eff.pipe(Effect.provide(buildLayer(state))) + +beforeEach(async () => { + await rm(cacheFile, { force: true }) +}) + +afterAll(async () => { + await rm(cacheFile, { force: true }) +}) + +const initialState: MockState = { + body: JSON.stringify(fixture), + status: 200, + calls: [], +} + +describe("ModelsDev Service", () => { + it.live("get() returns providers from disk when cache file exists", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual(fixture) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() returns {} when disk empty and fetch disabled", () => + Effect.gen(function* () { + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual({}) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() is single-flight under concurrent calls", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const results = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + return yield* Effect.all( + [svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], + { concurrency: "unbounded" }, + ) + }), + ) + for (const result of results) expect(result).toEqual(fixture) + }), + ) + + it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const first = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const a = yield* svc.get() + // mutate disk between calls — cache should mask the change + yield* writeCache(fixture2) + const b = yield* svc.get() + return { a, b } + }), + ) + expect(first.a).toEqual(fixture) + expect(first.b).toEqual(fixture) + }), + ) + + it.live("refresh(true) fetches via HttpClient and updates the cache", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const before = yield* svc.get() + yield* svc.refresh(true) + const after = yield* svc.get() + return { before, after } + }), + ) + expect(result.before).toEqual(fixture) + expect(result.after).toEqual(fixture2) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(final.calls[0].url).toContain("/api.json") + }), + ) + + it.live("refresh(false) skips fetch when on-disk file is fresh", () => + Effect.gen(function* () { + // Fresh: mtime within the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + yield* provided( + state, + ModelsDev.Service.use((s) => s.refresh(false)), + ) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("refresh(false) fetches when on-disk file is stale", () => + Effect.gen(function* () { + // Stale: mtime 10 minutes ago, beyond the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const after = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(false) + return yield* svc.get() + }), + ) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(after).toEqual(fixture2) + }), + ) + + it.live("refresh swallows HTTP errors and leaves cache intact", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(true) + return yield* svc.get() + }), + ) + expect(result).toEqual(fixture) + // withTransientReadRetry retries 5xx, so calls may be > 1. + const final = yield* Ref.get(state) + expect(final.calls.length).toBeGreaterThanOrEqual(1) + }), + ) +}) From b3a75137654b844b6260d2ddf804affac4000475 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 18:00:11 +0000 Subject: [PATCH 045/174] chore: generate --- packages/opencode/src/provider/models.ts | 10 ++++------ packages/opencode/test/provider/models.test.ts | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 3654f66c7..d3f9fa9ea 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -127,12 +127,10 @@ export const layer: Layer.Layer Effect.succeed(undefined)), - Effect.map((v) => v as Record | undefined), - ) + const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) // Bundled at build time; absent in dev — `tryPromise` covers both. const loadSnapshot = Effect.tryPromise({ diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts index feb5bb589..7ccf126a9 100644 --- a/packages/opencode/test/provider/models.test.ts +++ b/packages/opencode/test/provider/models.test.ts @@ -154,10 +154,9 @@ describe("ModelsDev Service", () => { state, Effect.gen(function* () { const svc = yield* ModelsDev.Service - return yield* Effect.all( - [svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], - { concurrency: "unbounded" }, - ) + return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { + concurrency: "unbounded", + }) }), ) for (const result of results) expect(result).toEqual(fixture) From 6cd02c05c26958ef87ab6d00a2215fb8ade95e3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 14:49:56 -0400 Subject: [PATCH 046/174] fix(telemetry): emit Tool.execute span for MCP and plugin tools (#25452) --- packages/opencode/src/session/prompt.ts | 15 ++++++++++++--- packages/opencode/src/tool/registry.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fb822ff17..80c47d3ce 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -464,9 +464,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result: Awaited>> = yield* Effect.promise(() => - execute(args, opts), + const result: Awaited>> = yield* Effect.gen(function* () { + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => execute(args, opts)) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": key, + "tool.call_id": opts.toolCallId, + "session.id": ctx.sessionID, + "message.id": input.processor.message.id, + }, + }), ) yield* plugin.trigger( "tool.execute.after", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a9a853e50..ebe3bb530 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -154,7 +154,16 @@ export const layer: Layer.Layer< ...(out.truncated && { outputPath: out.outputPath }), }, } - }), + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": id, + "session.id": toolCtx.sessionID, + "message.id": toolCtx.messageID, + ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}), + }, + }), + ), } } From 05b82a6a30a48de393bd929de457c42fdb15c622 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 15:11:01 -0400 Subject: [PATCH 047/174] refactor(cli): drop ModelsDev Promise compat shim (#25460) --- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 11 +++++++---- packages/opencode/src/provider/models.ts | 8 -------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index b31825fd9..106d48466 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -212,7 +212,7 @@ export const GithubInstallCommand = cmd({ const app = await getAppInfo() await installGitHubApp() - const providers = await ModelsDev.get().then((p) => { + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { // TODO: add guide for copilot, for now just hide it delete p["github-copilot"] return p diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 278522555..c383e79ce 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -4,6 +4,9 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" + +const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())) +const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true))) import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" @@ -245,7 +248,7 @@ export const ProvidersListCommand = cmd({ return Object.entries(yield* auth.all()) }), ) - const database = await ModelsDev.get() + const database = await getModels() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -334,14 +337,14 @@ export const ProvidersLoginCommand = cmd({ prompts.outro("Done") return } - await ModelsDev.refresh(true).catch(() => {}) + await refreshModels().catch(() => {}) const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const providers = await ModelsDev.get().then((x) => { + const providers = await getModels().then((x) => { const filtered: Record = {} for (const [key, value] of Object.entries(x)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { @@ -505,7 +508,7 @@ export const ProvidersLogoutCommand = cmd({ prompts.log.error("No credentials found") return } - const database = await ModelsDev.get() + const database = await getModels() const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index d3f9fa9ea..77e217eb7 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -7,7 +7,6 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { makeRuntime } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ @@ -196,11 +195,4 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(AppFileSystem.defaultLayer), ) -// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers). -// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one -// AppRuntime sees — Effect callers and Promise callers operate on the same cache. -const runtime = makeRuntime(Service, defaultLayer) -export const get = () => runtime.runPromise((s) => s.get()) -export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force)) - export * as ModelsDev from "./models" From 430bde9e9bb8095ef1f6fd2e6f75c9106f61f05d Mon Sep 17 00:00:00 2001 From: HyeokjaeLee Date: Sun, 3 May 2026 04:26:30 +0900 Subject: [PATCH 048/174] =?UTF-8?q?fix(instance):=20restore=20InstanceBoot?= =?UTF-8?q?strap=20init=20parameter=20for=20non-Effec=E2=80=A6=20(#25449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dax Raad --- packages/opencode/src/cli/bootstrap.ts | 2 ++ packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++- packages/opencode/src/effect/app-runtime.ts | 16 +++++++++++++++- .../src/server/routes/instance/middleware.ts | 3 ++- .../src/server/routes/instance/project.ts | 4 ++-- packages/opencode/src/server/workspace.ts | 4 +++- 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index a0dd9fe2a..da90ec403 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,9 +1,11 @@ import { Instance } from "../project/instance" import { InstanceStore } from "../project/instance-store" +import { getBootstrapRunEffect } from "../effect/app-runtime" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, + init: await getBootstrapRunEffect(), fn: async () => { try { const result = await cb() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 0f0fd693d..dd6f7e246 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -10,7 +10,7 @@ import { GlobalBus } from "@/bus/global" import { Flag } from "@opencode-ai/core/flag/flag" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" -import { AppRuntime } from "@/effect/app-runtime" +import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" ensureProcessMetadata("worker") @@ -77,6 +77,7 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, + init: await getBootstrapRunEffect(), fn: async () => { await upgrade().catch(() => {}) }, diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index bbf1f4f8d..66f3a9b37 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,4 +1,4 @@ -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" @@ -40,6 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" @@ -93,6 +94,7 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, @@ -130,3 +132,15 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } + +let bootstrapRun: Promise> +export function getBootstrapRunEffect(): Promise> { + if (!bootstrapRun) { + bootstrapRun = AppRuntime.runPromise( + Effect.gen(function* () { + return (yield* InstanceBootstrap.Service).run + }), + ) + } + return bootstrapRun +} diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 622d6296f..db7b9b52f 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { AppRuntime } from "@/effect/app-runtime" +import { getBootstrapRunEffect } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -23,6 +23,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, + init: await getBootstrapRunEffect(), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 04cc432d0..dbca75c19 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -8,7 +8,7 @@ import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { AppRuntime } from "@/effect/app-runtime" +import { getBootstrapRunEffect } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,7 +82,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next }) + await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 06930d07c..0036c9ab4 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,10 +5,10 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" +import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" @@ -94,11 +94,13 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adapter.target(workspace) if (target.type === "local") { + const init = await getBootstrapRunEffect() return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => Instance.provide({ directory: target.directory, + init, async fn() { return next() }, From c444e971b01fadeabf63625c0ab29e41c597b079 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 19:27:24 +0000 Subject: [PATCH 049/174] chore: generate --- .../src/server/routes/instance/project.ts | 7 +- packages/sdk/js/src/v2/gen/types.gen.ts | 128 +++---- packages/sdk/openapi.json | 360 +++++++++--------- 3 files changed, 250 insertions(+), 245 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index dbca75c19..01a45c2fb 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -82,7 +82,12 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) + await InstanceStore.reloadInstance({ + directory: dir, + worktree: dir, + project: next, + init: await getBootstrapRunEffect(), + }) return c.json(next) }, ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e46f8e04f..b925ec609 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -33,6 +33,13 @@ export type EventProjectUpdated = { properties: Project } +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -47,10 +54,18 @@ export type EventGlobalDisposed = { } } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" +export type EventFileEdited = { + type: "file.edited" properties: { - directory: string + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" } } @@ -215,53 +230,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -484,6 +452,38 @@ export type EventVcsBranchUpdated = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1112,9 +1112,11 @@ export type GlobalEvent = { workspace?: string payload: | EventProjectUpdated + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1124,12 +1126,6 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1145,6 +1141,10 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2055,9 +2055,11 @@ export type File = { export type Event = | EventProjectUpdated + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2067,12 +2069,6 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2088,6 +2084,10 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 930fd8c92..cfd8277a3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7674,6 +7674,25 @@ }, "required": ["type", "properties"] }, + "Event.server.instance.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.instance.disposed" + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -7702,21 +7721,44 @@ }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { + "Event.file.edited": { "type": "object", "properties": { "type": { "type": "string", - "const": "server.instance.disposed" + "const": "file.edited" }, "properties": { "type": "object", "properties": { - "directory": { + "file": { "type": "string" } }, - "required": ["directory"] + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] } }, "required": ["type", "properties"] @@ -8183,144 +8225,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -8839,6 +8743,102 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10960,6 +10960,9 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -10967,7 +10970,10 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -10996,24 +11002,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11059,6 +11047,18 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,6 +13253,9 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -13260,7 +13263,10 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13289,24 +13295,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13352,6 +13340,18 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From 43e20874f4e1e0221220c40ba8115375ac176bba Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 2 May 2026 19:53:06 +0000 Subject: [PATCH 050/174] sync release versions for v1.14.33 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 2efb1208c..12677ea97 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.32", + "version": "1.14.33", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.32", + "version": "1.14.33", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 7196ddd4f..5f4d79e44 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.32", + "version": "1.14.33", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 45eb7d0b7..cb5b4bf9a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e94be9498..bfb7f7db8 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.32", + "version": "1.14.33", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index c8590d6aa..f6072bd37 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.32", + "version": "1.14.33", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index f72d7f100..d73a23e08 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index b1e8aa635..4ba8d1401 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.32", + "version": "1.14.33", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 5089278bf..7a26516a9 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f16bf9368..1327423e5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index b4c487f2f..16e142b9c 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.32", + "version": "1.14.33", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ecc7e8f6b..d9e71219f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.32" +version = "1.14.33" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 52dfab2ad..1eb790cce 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.32", + "version": "1.14.33", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 706986e42..8c5aa3499 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.32", + "version": "1.14.33", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2a96f1b8f..d6bfdd844 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8729c96a5..de69e685c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 32d830bba..04b996aca 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 59039f05b..cd210c4d6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index ab8031cf9..c346fe5e7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.32", + "version": "1.14.33", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 185bc9339..67617771f 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.32", + "version": "1.14.33", "publisher": "sst-dev", "repository": { "type": "git", From 8396d6b016d04ce763bcf8887eb2de5a9b94205b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:01:46 -0400 Subject: [PATCH 051/174] refactor(cli): convert pr command to effectCmd (#25465) --- packages/opencode/src/cli/cmd/pr.ts | 196 ++++++++++++---------------- 1 file changed, 81 insertions(+), 115 deletions(-) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index f392bab4c..8a5645e67 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,11 +1,11 @@ +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../effect-cmd" import { Git } from "@/git" -import { Instance } from "@/project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { Process } from "@/util/process" -export const PrCommand = cmd({ +export const PrCommand = effectCmd({ command: "pr ", describe: "fetch and checkout a GitHub PR branch, then run opencode", builder: (yargs) => @@ -14,125 +14,91 @@ export const PrCommand = cmd({ describe: "PR number to checkout", demandOption: true, }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const project = Instance.project - if (project.vcs !== "git") { - UI.error("Could not find git repository. Please run this command from a git repository.") - process.exit(1) + handler: Effect.fn("Cli.pr")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* fail("Could not load instance context") + if (ctx.project.vcs !== "git") { + return yield* fail("Could not find git repository. Please run this command from a git repository.") + } + + const git = yield* Git.Service + const worktree = ctx.worktree + + const prNumber = args.number + const localBranchName = `pr/${prNumber}` + UI.println(`Fetching and checking out PR #${prNumber}...`) + + const checkout = yield* Effect.promise(() => + Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }), + ) + if (checkout.code !== 0) { + return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) + } + + const prInfoResult = yield* Effect.promise(() => + Process.text( + ["gh", "pr", "view", `${prNumber}`, "--json", "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body"], + { nothrow: true }, + ), + ) + + let sessionId: string | undefined + + if (prInfoResult.code === 0 && prInfoResult.text.trim()) { + const prInfo = JSON.parse(prInfoResult.text) + + if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { + const forkOwner = prInfo.headRepositoryOwner.login + const forkName = prInfo.headRepository.name + const remoteName = forkOwner + + const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim() + if (!remotes.split("\n").includes(remoteName)) { + yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: worktree }) + UI.println(`Added fork remote: ${remoteName}`) } - const prNumber = args.number - const localBranchName = `pr/${prNumber}` - UI.println(`Fetching and checking out PR #${prNumber}...`) - - // Use gh pr checkout with custom branch name - const result = await Process.run( - ["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], - { - nothrow: true, - }, + yield* git.run( + ["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], + { cwd: worktree }, ) + } - if (result.code !== 0) { - UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) - process.exit(1) - } + if (prInfo?.body) { + const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) + if (sessionMatch) { + const sessionUrl = sessionMatch[0] + UI.println(`Found opencode session: ${sessionUrl}`) + UI.println(`Importing session...`) - // Fetch PR info for fork handling and session link detection - const prInfoResult = await Process.text( - [ - "gh", - "pr", - "view", - `${prNumber}`, - "--json", - "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", - ], - { nothrow: true }, - ) - - let sessionId: string | undefined - - if (prInfoResult.code === 0) { - const prInfoText = prInfoResult.text - if (prInfoText.trim()) { - const prInfo = JSON.parse(prInfoText) - - // Handle fork PRs - if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { - const forkOwner = prInfo.headRepositoryOwner.login - const forkName = prInfo.headRepository.name - const remoteName = forkOwner - - // Check if remote already exists - const remotes = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - if (!remotes.split("\n").includes(remoteName)) { - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { - cwd: Instance.worktree, - }), - ), - ) - UI.println(`Added fork remote: ${remoteName}`) - } - - // Set upstream to the fork so pushes go there - const headRefName = prInfo.headRefName - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { - cwd: Instance.worktree, - }), - ), - ) - } - - // Check for opencode session link in PR body - if (prInfo && prInfo.body) { - const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) - if (sessionMatch) { - const sessionUrl = sessionMatch[0] - UI.println(`Found opencode session: ${sessionUrl}`) - UI.println(`Importing session...`) - - const importResult = await Process.text(["opencode", "import", sessionUrl], { - nothrow: true, - }) - if (importResult.code === 0) { - const importOutput = importResult.text.trim() - // Extract session ID from the output (format: "Imported session: ") - const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/) - if (sessionIdMatch) { - sessionId = sessionIdMatch[1] - UI.println(`Session imported: ${sessionId}`) - } - } - } + const importResult = yield* Effect.promise(() => Process.text(["opencode", "import", sessionUrl], { nothrow: true })) + if (importResult.code === 0) { + const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) + if (sessionIdMatch) { + sessionId = sessionIdMatch[1] + UI.println(`Session imported: ${sessionId}`) } } } + } + } - UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) - UI.println() - UI.println("Starting opencode...") - UI.println() + UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) + UI.println() + UI.println("Starting opencode...") + UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] - const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - cwd: process.cwd(), - }) - const code = await opencodeProcess.exited - if (code !== 0) throw new Error(`opencode exited with code ${code}`) - }, - }) - }, + const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const code = yield* Effect.promise(() => + Process.spawn(["opencode", ...opencodeArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + cwd: process.cwd(), + }).exited, + ) + // Match legacy throw semantics — propagate as a defect so the top-level + // index.ts catch handles it identically (exit 1, "Unexpected error" banner). + if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`)) + }), }) From b314781a1a69754da6047ba87847601ef2b379d4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 21:02:46 +0000 Subject: [PATCH 052/174] chore: generate --- packages/opencode/src/cli/cmd/pr.ts | 39 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 8a5645e67..420972235 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -37,7 +37,14 @@ export const PrCommand = effectCmd({ const prInfoResult = yield* Effect.promise(() => Process.text( - ["gh", "pr", "view", `${prNumber}`, "--json", "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body"], + [ + "gh", + "pr", + "view", + `${prNumber}`, + "--json", + "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", + ], { nothrow: true }, ), ) @@ -54,14 +61,15 @@ export const PrCommand = effectCmd({ const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: worktree }) + yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + cwd: worktree, + }) UI.println(`Added fork remote: ${remoteName}`) } - yield* git.run( - ["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], - { cwd: worktree }, - ) + yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], { + cwd: worktree, + }) } if (prInfo?.body) { @@ -71,7 +79,9 @@ export const PrCommand = effectCmd({ UI.println(`Found opencode session: ${sessionUrl}`) UI.println(`Importing session...`) - const importResult = yield* Effect.promise(() => Process.text(["opencode", "import", sessionUrl], { nothrow: true })) + const importResult = yield* Effect.promise(() => + Process.text(["opencode", "import", sessionUrl], { nothrow: true }), + ) if (importResult.code === 0) { const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) if (sessionIdMatch) { @@ -89,13 +99,14 @@ export const PrCommand = effectCmd({ UI.println() const opencodeArgs = sessionId ? ["-s", sessionId] : [] - const code = yield* Effect.promise(() => - Process.spawn(["opencode", ...opencodeArgs], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - cwd: process.cwd(), - }).exited, + const code = yield* Effect.promise( + () => + Process.spawn(["opencode", ...opencodeArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + cwd: process.cwd(), + }).exited, ) // Match legacy throw semantics — propagate as a defect so the top-level // index.ts catch handles it identically (exit 1, "Unexpected error" banner). From e318e173d8cc9c9bc92c2171d0d858edf525db48 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:45:41 -0400 Subject: [PATCH 053/174] refactor(cli): convert export command to effectCmd (#25471) --- packages/opencode/src/cli/cmd/export.ts | 143 +++++++++++------------- 1 file changed, 68 insertions(+), 75 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 62ba20e2c..5ff282b54 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,13 +1,13 @@ -import type { Argv } from "yargs" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -220,11 +220,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) } } -export const ExportCommand = cmd({ +export const ExportCommand = effectCmd({ command: "export [sessionID]", describe: "export session data as JSON", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("sessionID", { describe: "session id to export", type: "string", @@ -232,72 +232,65 @@ export const ExportCommand = cmd({ .option("sanitize", { describe: "redact sensitive transcript and file data", type: "boolean", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined - process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) - - if (!sessionID) { - UI.empty() - prompts.intro("Export session", { - output: process.stderr, - }) - - const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list())) - - if (sessions.length === 0) { - prompts.log.error("No sessions found", { - output: process.stderr, - }) - prompts.outro("Done", { - output: process.stderr, - }) - return - } - - sessions.sort((a, b) => b.time.updated - a.time.updated) - - const selectedSession = await prompts.autocomplete({ - message: "Select session to export", - maxItems: 10, - options: sessions.map((session) => ({ - label: session.title, - value: session.id, - hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, - })), - output: process.stderr, - }) - - if (prompts.isCancel(selectedSession)) { - throw new UI.CancelledError() - } - - sessionID = selectedSession - - prompts.outro("Exporting session...", { - output: process.stderr, - }) - } - - try { - const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!))) - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })), - ) - - const exportData = { - info: sessionInfo, - messages, - } - - process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) - process.stdout.write(EOL) - } catch { - UI.error(`Session not found: ${sessionID!}`) - process.exit(1) - } - }) - }, + }), + handler: Effect.fn("Cli.export")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx))) + }), +}) + +const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) { + const svc = yield* Session.Service + let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined + process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) + + if (!sessionID) { + UI.empty() + prompts.intro("Export session", { output: process.stderr }) + + const sessions = yield* svc.list() + + if (sessions.length === 0) { + prompts.log.error("No sessions found", { output: process.stderr }) + prompts.outro("Done", { output: process.stderr }) + return + } + + sessions.sort((a, b) => b.time.updated - a.time.updated) + + const selectedSession = yield* Effect.promise(() => + prompts.autocomplete({ + message: "Select session to export", + maxItems: 10, + options: sessions.map((session) => ({ + label: session.title, + value: session.id, + hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, + })), + output: process.stderr, + }), + ) + + if (prompts.isCancel(selectedSession)) { + return yield* Effect.die(new UI.CancelledError()) + } + + sessionID = selectedSession + + prompts.outro("Exporting session...", { output: process.stderr }) + } + + // Match legacy try/catch — catches both typed failures and defects + // (Session.Service.get throws NotFoundError as a defect, not a typed E). + return yield* Effect.gen(function* () { + const sessionInfo = yield* svc.get(sessionID!) + const messages = yield* svc.messages({ sessionID: sessionInfo.id }) + + const exportData = { info: sessionInfo, messages } + + process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) + process.stdout.write(EOL) + }).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`))) }) From 0c816eb4b1de0c5999d8c5349a34deb52a250d7d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:55:13 -0400 Subject: [PATCH 054/174] refactor(cli): convert plugin command to effectCmd (#25473) --- packages/opencode/src/cli/cmd/plug.ts | 39 ++++++++++++------------- packages/opencode/src/cli/effect-cmd.ts | 2 ++ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 1ac0b071d..1529e9b71 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -1,16 +1,16 @@ import { intro, log, outro, spinner } from "@clack/prompts" -import type { Argv } from "yargs" +import { Effect } from "effect" import { ConfigPaths } from "@/config/paths" import { Global } from "@opencode-ai/core/global" import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install" import { resolvePluginTarget } from "../../plugin/shared" -import { Instance } from "../../project/instance" import { errorMessage } from "../../util/error" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" type Spin = { start: (msg: string) => void @@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps } } -export const PluginCommand = cmd({ +export const PluginCommand = effectCmd({ command: "plugin ", aliases: ["plug"], describe: "install plugin and update config", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("module", { type: "string", describe: "npm module name", @@ -196,9 +196,8 @@ export const PluginCommand = cmd({ type: "boolean", default: false, describe: "replace existing plugin version", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.plug")(function* (args) { const mod = String(args.module ?? "").trim() if (!mod) { UI.error("module is required") @@ -214,20 +213,18 @@ export const PluginCommand = cmd({ global: Boolean(args.global), force: Boolean(args.force), }) - let ok = true - await Instance.provide({ - directory: process.cwd(), - fn: async () => { - ok = await run({ - vcs: Instance.project.vcs, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - }) + const ctx = yield* InstanceRef + if (!ctx) return + const ok = yield* Effect.promise(() => + run({ + vcs: ctx.project.vcs, + worktree: ctx.worktree, + directory: ctx.directory, + }), + ) outro("Done") if (!ok) process.exitCode = 1 - }, + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index cc4dd2ed7..29f750d16 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -31,6 +31,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( */ export const effectCmd = (opts: { command: string | readonly string[] + aliases?: string | readonly string[] describe: string | false builder?: (yargs: Argv) => Argv /** Defaults to process.cwd(). Override for commands that take a directory positional. */ @@ -39,6 +40,7 @@ export const effectCmd = (opts: { }) => cmd<{}, Args>({ command: opts.command, + aliases: opts.aliases, describe: opts.describe, builder: opts.builder as never, async handler(rawArgs) { From 79b6ce5db47d9107711dbc3b8bf02ebabe5b47ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:56:32 -0400 Subject: [PATCH 055/174] refactor(cli): convert import command to effectCmd (#25467) --- packages/opencode/src/cli/cmd/import.ts | 250 ++++++++++++------------ 1 file changed, 130 insertions(+), 120 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index d55aba091..8d1937666 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,17 +1,15 @@ -import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { AppRuntime } from "@/effect/app-runtime" -import { Schema } from "effect" +import { Effect, Schema } from "effect" const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) const decodePart = Schema.decodeUnknownSync(MessageV2.Part) @@ -78,135 +76,147 @@ export function transformShareData(shareData: ShareData[]): { } } -export const ImportCommand = cmd({ +type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } + +export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", - builder: (yargs: Argv) => { - return yargs.positional("file", { + builder: (yargs) => + yargs.positional("file", { describe: "path to JSON file or share URL", type: "string", demandOption: true, + }), + handler: Effect.fn("Cli.import")(function* (args) { + // effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant. + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const store = yield* InstanceStore.Service + // Ensure store.dispose runs disposers and emits server.instance.disposed + // on every exit path: success, early return, typed failure, defect, interrupt. + return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) + }), +}) + +const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) { + const share = yield* ShareNext.Service + + let exportData: ExportData | undefined + + const isUrl = file.startsWith("http://") || file.startsWith("https://") + + if (isUrl) { + const slug = parseShareUrl(file) + if (!slug) { + const baseUrl = yield* Effect.orDie(share.url()) + process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) + process.stdout.write(EOL) + return + } + + const baseUrl = new URL(file).origin + const req = yield* Effect.orDie(share.request()) + const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} + + const tryFetch = (url: string) => + Effect.tryPromise({ + try: () => fetch(url, { headers }), + catch: (e) => + new CliError({ + message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`, + }), + }) + + const dataPath = req.api.data(slug) + let response = yield* tryFetch(`${baseUrl}${dataPath}`) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`) + } + + if (!response.ok) { + process.stdout.write(`Failed to fetch share data: ${response.statusText}`) + process.stdout.write(EOL) + return + } + + const shareData = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new CliError({ message: "Share data was not valid JSON" }), }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let exportData: - | { - info: SDKSession - messages: Array<{ - info: Message - parts: Part[] - }> - } - | undefined + const transformed = transformShareData(shareData) - const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") + if (!transformed) { + process.stdout.write(`Share not found or empty: ${slug}`) + process.stdout.write(EOL) + return + } - if (isUrl) { - const slug = parseShareUrl(args.file) - if (!slug) { - const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) - process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) - process.stdout.write(EOL) - return - } + exportData = transformed + } else { + exportData = yield* Effect.promise(() => + Filesystem.readJson>(file).catch(() => undefined), + ) + if (!exportData) { + process.stdout.write(`File not found: ${file}`) + process.stdout.write(EOL) + return + } + } - const parsed = new URL(args.file) - const baseUrl = parsed.origin - const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) - const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} + if (!exportData) { + process.stdout.write(`Failed to read session data`) + process.stdout.write(EOL) + return + } - const dataPath = req.api.data(slug) - let response = await fetch(`${baseUrl}${dataPath}`, { - headers, + const info = Schema.decodeUnknownSync(Session.Info)({ + ...exportData.info, + projectID, + }) as Session.Info + const row = Session.toRow(info) + Database.use((db) => + db + .insert(SessionTable) + .values(row) + .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .run(), + ) + + for (const msg of exportData.messages) { + const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + const { id, sessionID: _, ...msgData } = msgInfo + Database.use((db) => + db + .insert(MessageTable) + .values({ + id, + session_id: row.id, + time_created: msgInfo.time?.created ?? Date.now(), + data: msgData, }) + .onConflictDoNothing() + .run(), + ) - if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = await fetch(`${baseUrl}/api/share/${slug}/data`, { - headers, - }) - } - - if (!response.ok) { - process.stdout.write(`Failed to fetch share data: ${response.statusText}`) - process.stdout.write(EOL) - return - } - - const shareData: ShareData[] = await response.json() - const transformed = transformShareData(shareData) - - if (!transformed) { - process.stdout.write(`Share not found or empty: ${slug}`) - process.stdout.write(EOL) - return - } - - exportData = transformed - } else { - exportData = await Filesystem.readJson>(args.file).catch(() => undefined) - if (!exportData) { - process.stdout.write(`File not found: ${args.file}`) - process.stdout.write(EOL) - return - } - } - - if (!exportData) { - process.stdout.write(`Failed to read session data`) - process.stdout.write(EOL) - return - } - - const info = Schema.decodeUnknownSync(Session.Info)({ - ...exportData.info, - projectID: Instance.project.id, - }) as Session.Info - const row = Session.toRow(info) + for (const part of msg.parts) { + const partInfo = decodePart(part) as MessageV2.Part + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db - .insert(SessionTable) - .values(row) - .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .insert(PartTable) + .values({ + id: partId, + message_id: messageID, + session_id: row.id, + data: partData, + }) + .onConflictDoNothing() .run(), ) + } + } - for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info - const { id, sessionID: _, ...msgData } = msgInfo - Database.use((db) => - db - .insert(MessageTable) - .values({ - id, - session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, - }) - .onConflictDoNothing() - .run(), - ) - - for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part - const { id: partId, sessionID: _s, messageID, ...partData } = partInfo - Database.use((db) => - db - .insert(PartTable) - .values({ - id: partId, - message_id: messageID, - session_id: row.id, - data: partData, - }) - .onConflictDoNothing() - .run(), - ) - } - } - - process.stdout.write(`Imported session: ${exportData.info.id}`) - process.stdout.write(EOL) - }) - }, + process.stdout.write(`Imported session: ${exportData.info.id}`) + process.stdout.write(EOL) }) From c1686c6ddc8c05a452b8f5771f717c7bb1401114 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 18:01:06 -0400 Subject: [PATCH 056/174] refactor(cli): convert stats command to effectCmd (#25474) --- packages/opencode/src/cli/cmd/stats.ts | 61 ++++++++++++++------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 6d00fdf9f..9a8843160 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,11 +1,11 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" -import { bootstrap } from "../bootstrap" import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { @@ -47,11 +47,11 @@ interface SessionStats { medianTokensPerSession: number } -export const StatsCommand = cmd({ +export const StatsCommand = effectCmd({ command: "stats", describe: "show token usage and cost statistics", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("days", { describe: "show stats for the last N days (default: all time)", type: "number", @@ -66,34 +66,39 @@ export const StatsCommand = cmd({ .option("project", { describe: "filter by project (default: all projects, empty string: current project)", type: "string", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const stats = await aggregateSessionStats(args.days, args.project) - - let modelLimit: number | undefined - if (args.models === true) { - modelLimit = Infinity - } else if (typeof args.models === "number") { - modelLimit = args.models - } - - displayStats(stats, args.tools, modelLimit) - }) - }, + }), + handler: Effect.fn("Cli.stats")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -async function getCurrentProject(): Promise { - return Instance.project -} +const run = (args: { days?: number; tools?: number; models?: unknown; project?: string }, currentProject: Project.Info) => + Effect.promise(async () => { + const stats = await aggregateSessionStats(args.days, args.project, currentProject) + + let modelLimit: number | undefined + if (args.models === true) { + modelLimit = Infinity + } else if (typeof args.models === "number") { + modelLimit = args.models + } + + displayStats(stats, args.tools, modelLimit) + }) async function getAllSessions(): Promise { const rows = Database.use((db) => db.select().from(SessionTable).all()) return rows.map((row) => Session.fromRow(row)) } -export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { +export async function aggregateSessionStats( + days?: number, + projectFilter?: string, + currentProject?: Project.Info, +): Promise { const sessions = await getAllSessions() const MS_IN_DAY = 24 * 60 * 60 * 1000 @@ -117,7 +122,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin if (projectFilter !== undefined) { if (projectFilter === "") { - const currentProject = await getCurrentProject() + if (!currentProject) throw new Error("currentProject required when projectFilter is empty string") filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id) } else { filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter) From dfe1325fca27612bab879102eb8974270cc13407 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 22:02:14 +0000 Subject: [PATCH 057/174] chore: generate --- packages/opencode/src/cli/cmd/stats.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 9a8843160..966eb5f66 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -75,7 +75,10 @@ export const StatsCommand = effectCmd({ }), }) -const run = (args: { days?: number; tools?: number; models?: unknown; project?: string }, currentProject: Project.Info) => +const run = ( + args: { days?: number; tools?: number; models?: unknown; project?: string }, + currentProject: Project.Info, +) => Effect.promise(async () => { const stats = await aggregateSessionStats(args.days, args.project, currentProject) From 1986a6e81775b5a716c44ae0b6fc4ba8d1ef32cc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 18:15:28 -0400 Subject: [PATCH 058/174] refactor(cli): convert session subcommands to effectCmd (#25483) --- packages/opencode/src/cli/cmd/session.ts | 100 +++++++++++------------ 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 52a3d7204..dbf27ccc6 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -1,8 +1,9 @@ import type { Argv } from "yargs" +import { Effect } from "effect" import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { Session } from "@/session/session" import { SessionID } from "../../session/schema" -import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Locale } from "@/util/locale" import { Flag } from "@opencode-ai/core/flag/flag" @@ -11,7 +12,8 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -47,36 +49,35 @@ export const SessionCommand = cmd({ async handler() {}, }) -export const SessionDeleteCommand = cmd({ +export const SessionDeleteCommand = effectCmd({ command: "delete ", describe: "delete a session", - builder: (yargs: Argv) => { - return yargs.positional("sessionID", { + builder: (yargs) => + yargs.positional("sessionID", { describe: "session ID to delete", type: "string", demandOption: true, - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { + }), + handler: Effect.fn("Cli.session.delete")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const svc = yield* Session.Service const sessionID = SessionID.make(args.sessionID) - try { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) - } catch { - UI.error(`Session not found: ${args.sessionID}`) - process.exit(1) - } - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -export const SessionListCommand = cmd({ +export const SessionListCommand = effectCmd({ command: "list", describe: "list sessions", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("max-count", { alias: "n", describe: "limit to N most recent sessions", @@ -87,47 +88,42 @@ export const SessionListCommand = cmd({ type: "string", choices: ["table", "json"], default: "table", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const sessions = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })), - ) + }), + handler: Effect.fn("Cli.session.list")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - if (sessions.length === 0) { - return - } + if (sessions.length === 0) return - let output: string - if (args.format === "json") { - output = formatSessionJSON(sessions) - } else { - output = formatSessionTable(sessions) - } + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" if (shouldPaginate) { - const proc = Process.spawn(pagerCmd(), { - stdin: "pipe", - stdout: "inherit", - stderr: "inherit", + yield* Effect.promise(async () => { + const proc = Process.spawn(pagerCmd(), { + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", + }) + + if (!proc.stdin) { + console.log(output) + return + } + + proc.stdin.write(output) + proc.stdin.end() + await proc.exited }) - - if (!proc.stdin) { - console.log(output) - return - } - - proc.stdin.write(output) - proc.stdin.end() - await proc.exited } else { console.log(output) } - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) function formatSessionTable(sessions: Session.Info[]): string { From 3f459819ba6d3224eef6bbc88b4f239fa89491af Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 09:18:48 +1000 Subject: [PATCH 059/174] feat: refactor bash tool with shell-aware prompts for bash, pwsh+powershell, and cmd (#20039) --- packages/opencode/src/acp/agent.ts | 36 ++- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 7 +- .../src/cli/cmd/tui/routes/session/index.tsx | 9 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/tool/registry.ts | 8 +- .../opencode/src/tool/{bash.ts => shell.ts} | 95 +++--- packages/opencode/src/tool/shell/id.ts | 19 ++ packages/opencode/src/tool/shell/prompt.ts | 297 ++++++++++++++++++ .../src/tool/{bash.txt => shell/shell.txt} | 60 +--- .../opencode/test/session/message-v2.test.ts | 6 +- .../opencode/test/tool/parameters.test.ts | 16 +- .../test/tool/{bash.test.ts => shell.test.ts} | 122 +++++-- 14 files changed, 506 insertions(+), 177 deletions(-) rename packages/opencode/src/tool/{bash.ts => shell.ts} (81%) create mode 100644 packages/opencode/src/tool/shell/id.ts create mode 100644 packages/opencode/src/tool/shell/prompt.ts rename packages/opencode/src/tool/{bash.txt => shell/shell.txt} (59%) rename packages/opencode/test/tool/{bash.test.ts => shell.test.ts} (92%) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index af16cba11..8bbc2427f 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -144,7 +145,7 @@ export class Agent implements ACPAgent { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -283,16 +284,16 @@ export class Agent implements ACPAgent { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (part.tool === ShellID.ToolID) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -311,7 +312,7 @@ export class Agent implements ACPAgent { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -342,7 +343,7 @@ export class Agent implements ACPAgent { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -423,7 +424,7 @@ export class Agent implements ACPAgent { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -837,10 +838,10 @@ export class Agent implements ACPAgent { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -871,7 +872,7 @@ export class Agent implements ACPAgent { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -951,7 +952,7 @@ export class Agent implements ACPAgent { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + private shellOutput(part: ToolPart) { + if (part.tool !== ShellID.ToolID) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1549,9 +1550,11 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + switch (tool) { - case "bash": + case ShellID.ToolID: return "execute" + case "webfetch": return "fetch" @@ -1576,6 +1579,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + switch (tool) { case "read": case "edit": @@ -1584,7 +1588,7 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": + case ShellID.ToolID: return [] default: return [] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 106d48466..a75dc3163 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -879,7 +879,7 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c94e96203..f73ca6717 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -22,7 +22,8 @@ import { WriteTool } from "../../tool/write" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { ShellTool } from "../../tool/shell" +import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" import { AppRuntime } from "@/effect/app-runtime" @@ -175,7 +176,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -400,7 +401,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(props(part)) + if (part.tool === ShellID.ToolID) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "read") return read(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8855338d1..d43edd2dd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,8 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { ShellTool } from "@/tool/shell" +import { ShellID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1552,8 +1553,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1784,7 +1785,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 720a05ff7..e7e4c7cea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" +import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === ShellID.ToolID) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 80c47d3ce..9f1420388 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,6 +41,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -789,7 +790,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: ShellID.ToolID, callID: ulid(), state: { status: "running", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ebe3bb530..a4eb31acc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" -import { BashTool } from "./bash" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -106,7 +106,7 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const bash = yield* BashTool + const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -195,7 +195,7 @@ export const layer: Layer.Layer< const tool = yield* Effect.all({ invalid: Tool.init(invalid), - bash: Tool.init(bash), + shell: Tool.init(shell), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -217,7 +217,7 @@ export const layer: Layer.Layer< builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), - tool.bash, + tool.shell, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 81% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index bf0008250..bb2e4e58d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,12 +1,11 @@ -import { Schema } from "effect" -import { PositiveInt } from "@/util/schema" +import { Effect, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" import path from "path" -import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" import { containsPath, type InstanceContext } from "../project/instance-context" +import { InstanceState } from "@/effect/instance-state" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -14,20 +13,21 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" import { Shell } from "@/shell/shell" +import { ShellID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" -import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { InstanceState } from "@/effect/instance-state" +import { ShellPrompt, type Parameters } from "./shell/prompt" +import { BashArity } from "@/permission/arity" + +export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -const CWD = new Set(["cd", "push-location", "set-location"]) +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, "rm", @@ -50,21 +50,10 @@ const FILES = new Set([ "new-item", "rename-item", ]) +const CMD_FILES = new Set(["copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", "rename", "rmdir", "type"]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ - description: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - }), -}) - type Part = { type: string text: string @@ -81,7 +70,7 @@ type Chunk = { size: number } -export const log = Log.create({ service: "bash-tool" }) +export const log = Log.create({ service: "shell-tool" }) const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) @@ -187,11 +176,16 @@ function prefix(text: string) { return text.slice(0, match.index) } -function pathArgs(list: Part[], ps: boolean) { +function pathArgs(list: Part[], ps: boolean, cmd = false) { if (!ps) { return list .slice(1) - .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .filter( + (item) => + !item.text.startsWith("-") && + !(cmd && item.text.startsWith("/")) && + !(list[0]?.text === "chmod" && item.text.startsWith("+")), + ) .map((item) => item.text) } @@ -251,13 +245,13 @@ function tail(text: string, maxLines: number, maxBytes: number) { } } -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -273,7 +267,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) if (scan.patterns.size === 0) return yield* ctx.ask({ - permission: "bash", + permission: ShellID.ToolID, patterns: Array.from(scan.patterns), always: Array.from(scan.always), metadata: {}, @@ -325,9 +319,8 @@ const parser = lazy(async () => { return { bash, ps } }) -// TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define( - "bash", +export const ShellTool = Tool.define( + ShellID.ToolID, Effect.gen(function* () { const config = yield* Config.Service const spawner = yield* ChildProcessSpawner @@ -335,7 +328,7 @@ export const BashTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -344,7 +337,7 @@ export const BashTool = Tool.define( return AppFileSystem.normalizePath(file) }) - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { const file = yield* cygpath(shell, text) @@ -355,7 +348,7 @@ export const BashTool = Tool.define( return path.resolve(root, text) }) - const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) const file = text && prefix(text) if (!file || dynamic(file, ps)) return @@ -364,7 +357,7 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* ( + const collect = Effect.fn("ShellTool.collect")(function* ( root: Node, cwd: string, ps: boolean, @@ -376,14 +369,15 @@ export const BashTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellID.toKind(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0] - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { + if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) { + for (const arg of pathArgs(command, ps, shellKind === "cmd")) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) if (!resolved || containsPath(resolved, instance)) continue @@ -401,7 +395,7 @@ export const BashTool = Tool.define( return scan }) - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -413,7 +407,7 @@ export const BashTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string command: string @@ -527,7 +521,7 @@ export const BashTool = Tool.define( const meta: string[] = [] if (expired) { meta.push( - `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } if (aborted) meta.push("User aborted the command") @@ -546,7 +540,7 @@ export const BashTool = Tool.define( } if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" + output += "\n\n\n" + meta.join("\n") + "\n" } if (sink) { const stream = sink @@ -577,25 +571,14 @@ export const BashTool = Tool.define( const cfg = yield* config.get() const shell = Shell.acceptable(cfg.shell) const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) - const limits = yield* trunc.limits() - const instance = yield* InstanceState.context + const prompt = ShellPrompt.render(name, process.platform, limits) + log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", instance.directory) - .replaceAll("${tmp}", Global.Path.tmp) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(limits.maxLines)) - .replaceAll("${maxBytes}", String(limits.maxBytes)), - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + description: prompt.description, + parameters: prompt.parameters, + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const executeInstance = yield* InstanceState.context const cwd = params.workdir diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 000000000..061253f8f --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +const kinds = ["bash", "pwsh", "powershell", "cmd"] as const +export type Kind = (typeof kinds)[number] + +const shellKinds = new Set(kinds) + +function isKind(value: string): value is Kind { + return shellKinds.has(value) +} + +export function toKind(value: string): Kind { + return isKind(value) ? value : "bash" +} + +// Keep the exposed tool ID and permission key as "bash" for compatibility with +// existing plugins, users, and saved permissions. Rename with opencode 2.0. +export const ToolID = "bash" +export type ToolID = typeof ToolID + +export * as ShellID from "./id" diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 000000000..77d0f4b5e --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,297 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" +import { PositiveInt } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { ShellID } from "./id" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before bash for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo\\" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + tmp: Global.Path.tmp, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: ShellID.ToolID, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 59% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index a131ed7e6..5cba07805 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,54 +1,14 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +${workdirSection} Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - +${commandSection} # Committing changes with git @@ -67,7 +27,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -84,18 +44,18 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote @@ -104,11 +64,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + - ${createPrInstruction} -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> +${createPrExample} Important: diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index afd24e7e1..a7853be0b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -620,7 +620,7 @@ describe("session.message-v2.toModelMessage", () => { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -740,9 +740,9 @@ describe("session.message-v2.toModelMessage", () => { "12179", "4575", "", - "", + "", "User aborted the command", - "", + "", ].join("\n") const input: MessageV2.WithParts[] = [ diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index bc42b0324..9f6a0617e 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" // byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" -import { Parameters as Bash } from "../../src/tool/bash" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" import { Parameters as Grep } from "../../src/tool/grep" @@ -19,6 +18,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp" import { Parameters as Plan } from "../../src/tool/plan" import { Parameters as Question } from "../../src/tool/question" import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" @@ -35,7 +35,7 @@ const accepts = (schema: Schema.Decoder, input: unknown): boolean => describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) - test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) @@ -66,20 +66,20 @@ describe("tool parameters", () => { }) }) - describe("bash", () => { + describe("shell", () => { test("accepts minimum: command + description", () => { - expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description (required by zod)", () => { - expect(accepts(Bash, { command: "ls" })).toBe(false) + test("rejects missing description", () => { + expect(accepts(Shell, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(accepts(Bash, { description: "list" })).toBe(false) + expect(accepts(Shell, { description: "list" })).toBe(false) }) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 92% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index 513cfa18e..43295e2d5 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -4,7 +4,7 @@ import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -28,9 +28,11 @@ const runtime = ManagedRuntime.make( ) function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) + return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) } +const initShell = initBash + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -68,6 +70,7 @@ const shells = (() => { })() const PS = new Set(["pwsh", "powershell"]) const ps = shells.filter((item) => PS.has(item.label)) +const cmdShell = shells.find((item) => item.label === "cmd") const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) @@ -135,12 +138,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -184,13 +187,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -213,7 +216,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -239,7 +242,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -261,11 +264,43 @@ describe("tool.bash permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -301,7 +336,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await Effect.runPromise( @@ -334,7 +369,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -364,7 +399,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await Effect.runPromise( @@ -396,7 +431,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -426,7 +461,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -521,7 +556,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -680,7 +715,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -702,6 +737,35 @@ describe("tool.bash permissions", () => { } } + if (process.platform === "win32" && cmdShell) { + test( + "asks for external_directory permission for cmd file commands [cmd]", + withShell(cmdShell, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -945,7 +1009,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -967,7 +1031,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -1001,12 +1065,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash abort", () => { +describe("tool.shell abort", () => { test("preserves output when aborted", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const controller = new AbortController() const collected: string[] = [] const res = await Effect.runPromise( @@ -1040,7 +1104,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1052,7 +1116,7 @@ describe("tool.bash abort", () => { ), ) expect(result.output).toContain("started") - expect(result.output).toContain("bash tool terminated command after exceeding timeout") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") expect(result.output).toContain("retry with a larger timeout value in milliseconds") }, }) @@ -1062,7 +1126,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1083,7 +1147,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1128,12 +1192,12 @@ describe("tool.bash abort", () => { }) }) -describe("tool.bash truncation", () => { +describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 500 const result = await Effect.runPromise( bash.execute( @@ -1155,7 +1219,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const byteCount = Truncate.MAX_BYTES + 10000 const result = await Effect.runPromise( bash.execute( @@ -1177,7 +1241,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1197,7 +1261,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 100 const result = await Effect.runPromise( bash.execute( From 7ab1c1c74a93162b3ca6fb60b2739be7a029365d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:19:06 -0400 Subject: [PATCH 060/174] refactor(cli): convert debug agent command to effectCmd (#25485) --- packages/opencode/src/cli/cmd/debug/agent.ts | 191 +++++++++---------- 1 file changed, 90 insertions(+), 101 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index cff9a7f9c..831ca08b6 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,14 +7,14 @@ import { Session } from "@/session/session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "@/tool/registry" -import { Instance } from "../../../project/instance" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" +import type { InstanceContext } from "@/project/instance" -export const AgentCommand = cmd({ +export const AgentCommand = effectCmd({ command: "agent ", describe: "show agent configuration details", builder: (yargs) => @@ -32,60 +32,61 @@ export const AgentCommand = cmd({ type: "string", description: "Tool params as JSON or a JS object literal", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const agentName = args.name as string - const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName))) - if (!agent) { - process.stderr.write( - `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, - ) - process.exit(1) - } - const availableTools = await getAvailableTools(agent) - const resolvedTools = await resolveTools(agent, availableTools) - const toolID = args.tool as string | undefined - if (toolID) { - const tool = availableTools.find((item) => item.id === toolID) - if (!tool) { - process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) - process.exit(1) - } - if (resolvedTools[toolID] === false) { - process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) - process.exit(1) - } - const params = parseToolParams(args.params as string | undefined) - const ctx = await createToolContext(agent) - const result = await tool.execute(params, ctx) - process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) - return - } - - const output = { - ...agent, - tools: resolvedTools, - } - process.stdout.write(JSON.stringify(output, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.agent")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* run(args, ctx).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -async function getAvailableTools(agent: Agent.Info) { - return AppRuntime.runPromise( - Effect.gen(function* () { - const provider = yield* Provider.Service - const registry = yield* ToolRegistry.Service - const model = agent.model ?? (yield* provider.defaultModel()) - return yield* registry.tools({ - ...model, - agent, - }) - }), - ) -} +const run = Effect.fn("Cli.debug.agent.body")(function* ( + args: { name: string; tool?: string; params?: string }, + ctx: InstanceContext, +) { + const agentName = args.name + const agent = yield* Agent.Service.use((svc) => svc.get(agentName)) + if (!agent) { + process.stderr.write( + `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, + ) + return yield* fail("", 1) + } + const availableTools = yield* getAvailableTools(agent) + const resolvedTools = resolveTools(agent, availableTools) + const toolID = args.tool + if (toolID) { + const tool = availableTools.find((item) => item.id === toolID) + if (!tool) { + process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + if (resolvedTools[toolID] === false) { + process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + const params = parseToolParams(args.params) + const toolCtx = yield* createToolContext(agent, ctx) + const result = yield* tool.execute(params, toolCtx) + process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) + return + } -async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { + const output = { + ...agent, + tools: resolvedTools, + } + process.stdout.write(JSON.stringify(output, null, 2) + EOL) +}) + +const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) { + const provider = yield* Provider.Service + const registry = yield* ToolRegistry.Service + const model = agent.model ?? (yield* provider.defaultModel()) + return yield* registry.tools({ ...model, agent }) +}) + +function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) { const disabled = Permission.disabled( availableTools.map((tool) => tool.id), agent.permission, @@ -123,50 +124,38 @@ function parseToolParams(input?: string) { return parsed as Record } -async function createToolContext(agent: Agent.Info) { - const { session, messageID } = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const result = yield* session.create({ title: `Debug tool run (${agent.name})` }) - const messageID = MessageID.ascending() - const model = agent.model - ? agent.model - : yield* Effect.gen(function* () { - const provider = yield* Provider.Service - return yield* provider.defaultModel() - }) - const now = Date.now() - const message: MessageV2.Assistant = { - id: messageID, - sessionID: result.id, - role: "assistant", - time: { - created: now, - }, - parentID: messageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "debug", - agent: agent.name, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } - yield* session.updateMessage(message) - return { session: result, messageID } - }), - ) +const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* ( + agent: Agent.Info, + ctx: InstanceContext, +) { + const sessionSvc = yield* Session.Service + const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` }) + const messageID = MessageID.ascending() + const model = agent.model + ? agent.model + : yield* Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }) + const now = Date.now() + const message: MessageV2.Assistant = { + id: messageID, + sessionID: session.id, + role: "assistant", + time: { created: now }, + parentID: messageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "debug", + agent: agent.name, + path: { + cwd: ctx.directory, + root: ctx.worktree, + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + yield* sessionSvc.updateMessage(message) const ruleset = Permission.merge(agent.permission, session.permission ?? []) @@ -189,4 +178,4 @@ async function createToolContext(agent: Agent.Info) { }) }, } -} +}) From 9d03d4419e4fada267dc522c0ae55b488ab85d3b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 23:20:15 +0000 Subject: [PATCH 061/174] chore: generate --- packages/opencode/src/tool/shell.ts | 15 ++++++++++++++- packages/opencode/src/tool/shell/prompt.ts | 6 ++---- packages/opencode/test/tool/shell.test.ts | 4 +--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index bb2e4e58d..d3ca54268 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -50,7 +50,20 @@ const FILES = new Set([ "new-item", "rename-item", ]) -const CMD_FILES = new Set(["copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", "rename", "rmdir", "type"]) +const CMD_FILES = new Set([ + "copy", + "del", + "dir", + "erase", + "md", + "mkdir", + "move", + "rd", + "ren", + "rename", + "rmdir", + "type", +]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 77d0f4b5e..45c637863 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -8,12 +8,10 @@ const PS = new Set(["powershell", "pwsh"]) const CMD = new Set(["cmd"]) const descriptions = { - bash: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", powershell: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', + cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', } export type Limits = { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 43295e2d5..e68d16ba8 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -757,9 +757,7 @@ describe("tool.shell permissions", () => { ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) + expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*"))) }, }) }), From 4de44bbbefec557e519b9ac99bc39482957df99e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:22:51 -0400 Subject: [PATCH 062/174] refactor(cli): convert debug subcommands to effectCmd (#25479) --- packages/opencode/src/cli/cmd/debug/config.ts | 22 ++--- packages/opencode/src/cli/cmd/debug/file.ts | 81 +++++++++++-------- packages/opencode/src/cli/cmd/debug/lsp.ts | 60 ++++++++------ .../opencode/src/cli/cmd/debug/ripgrep.ts | 80 +++++++++--------- packages/opencode/src/cli/cmd/debug/skill.ts | 27 +++---- .../opencode/src/cli/cmd/debug/snapshot.ts | 54 ++++++++----- 6 files changed, 185 insertions(+), 139 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index a80b6a581..8102fcfb8 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,17 +1,21 @@ import { EOL } from "os" +import { Effect } from "effect" import { Config } from "@/config/config" -import { AppRuntime } from "@/effect/app-runtime" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" -export const ConfigCommand = cmd({ +export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + handler: Effect.fn("Cli.debug.config")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 8e4eaa4e4..1e2eb13bb 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,11 +1,13 @@ import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" -const FileSearchCommand = cmd({ +const FileSearchCommand = effectCmd({ command: "search ", describe: "search files by query", builder: (yargs) => @@ -14,15 +16,18 @@ const FileSearchCommand = cmd({ demandOption: true, description: "Search query", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query }))) + handler: Effect.fn("Cli.debug.file.search")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) process.stdout.write(results.join(EOL) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileReadCommand = cmd({ +const FileReadCommand = effectCmd({ command: "read ", describe: "read file contents as JSON", builder: (yargs) => @@ -31,27 +36,33 @@ const FileReadCommand = cmd({ demandOption: true, description: "File path to read", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path))) + handler: Effect.fn("Cli.debug.file.read")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const content = yield* File.Service.use((svc) => svc.read(args.path)) process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileStatusCommand = cmd({ +const FileStatusCommand = effectCmd({ command: "status", describe: "show file status information", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status())) + handler: Effect.fn("Cli.debug.file.status")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const status = yield* File.Service.use((svc) => svc.status()) process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileListCommand = cmd({ +const FileListCommand = effectCmd({ command: "list ", describe: "list files in a directory", builder: (yargs) => @@ -60,15 +71,18 @@ const FileListCommand = cmd({ demandOption: true, description: "File path to list", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path))) + handler: Effect.fn("Cli.debug.file.list")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const files = yield* File.Service.use((svc) => svc.list(args.path)) process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileTreeCommand = cmd({ +const FileTreeCommand = effectCmd({ command: "tree [dir]", describe: "show directory tree", builder: (yargs) => @@ -77,12 +91,15 @@ const FileTreeCommand = cmd({ description: "Directory to tree", default: process.cwd(), }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + handler: Effect.fn("Cli.debug.file.tree")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) console.log(JSON.stringify(tree, null, 2)) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) export const FileCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 6312afcf1..b822a98bc 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,10 +1,11 @@ import { LSP } from "@/lsp/lsp" -import { AppRuntime } from "../../../effect/app-runtime" import { Effect } from "effect" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" export const LSPCommand = cmd({ command: "lsp", @@ -14,47 +15,54 @@ export const LSPCommand = cmd({ async handler() {}, }) -const DiagnosticsCommand = cmd({ +const DiagnosticsCommand = effectCmd({ command: "diagnostics ", describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const out = await AppRuntime.runPromise( - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ), + handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), ) process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -export const SymbolsCommand = cmd({ +export const SymbolsCommand = effectCmd({ command: "symbols ", describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { using _ = Log.Default.time("symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -export const DocumentSymbolsCommand = cmd({ +export const DocumentSymbolsCommand = effectCmd({ command: "document-symbols ", describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { using _ = Log.Default.time("document-symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 9b7e82691..73c7ada2b 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,10 +1,10 @@ import { EOL } from "os" import { Effect, Stream } from "effect" -import { AppRuntime } from "../../../effect/app-runtime" import { Ripgrep } from "../../../file/ripgrep" -import { Instance } from "../../../project/instance" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" export const RipgrepCommand = cmd({ command: "rg", @@ -13,24 +13,25 @@ export const RipgrepCommand = cmd({ async handler() {}, }) -const TreeCommand = cmd({ +const TreeCommand = effectCmd({ command: "tree", describe: "show file tree using ripgrep", builder: (yargs) => yargs.option("limit", { type: "number", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })), - ) + handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FilesCommand = cmd({ +const FilesCommand = effectCmd({ command: "files", describe: "list files using ripgrep", builder: (yargs) => @@ -47,29 +48,29 @@ const FilesCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise( - Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg - .files({ - cwd: Instance.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - ) - }), - ) + handler: Effect.fn("Cli.debug.rg.files")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, + ) process.stdout.write(files.join(EOL) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const SearchCommand = cmd({ +const SearchCommand = effectCmd({ command: "search ", describe: "search file contents using ripgrep", builder: (yargs) => @@ -87,12 +88,15 @@ const SearchCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise( + handler: Effect.fn("Cli.debug.rg.search")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const results = yield* Effect.orDie( Ripgrep.Service.use((svc) => svc.search({ - cwd: Instance.directory, + cwd: ctx.directory, pattern: args.pattern, glob: args.glob as string[] | undefined, limit: args.limit, @@ -100,6 +104,6 @@ const SearchCommand = cmd({ ), ) process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index 79179411b..e23410a69 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -1,23 +1,22 @@ import { EOL } from "os" import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" import { Skill } from "../../../skill" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" -export const SkillCommand = cmd({ +export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const skills = await AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) + handler: Effect.fn("Cli.debug.skill")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const skill = yield* Skill.Service + const skills = yield* skill.all() process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 6663398a4..1675f175d 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -1,7 +1,9 @@ -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Snapshot } from "../../../snapshot" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" export const SnapshotCommand = cmd({ command: "snapshot", @@ -10,17 +12,21 @@ export const SnapshotCommand = cmd({ async handler() {}, }) -const TrackCommand = cmd({ +const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", - async handler() { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track()))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.track")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const PatchCommand = cmd({ +const PatchCommand = effectCmd({ command: "patch ", describe: "show patch for a snapshot hash", builder: (yargs) => @@ -29,14 +35,18 @@ const PatchCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const DiffCommand = cmd({ +const DiffCommand = effectCmd({ command: "diff ", describe: "show diff for a snapshot hash", builder: (yargs) => @@ -45,9 +55,13 @@ const DiffCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) From 36007aecf429603f8a2e823106cff02baffa2bc3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 23:23:53 +0000 Subject: [PATCH 063/174] chore: generate --- packages/opencode/src/cli/cmd/debug/ripgrep.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 73c7ada2b..f0be70448 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -25,7 +25,9 @@ const TreeCommand = effectCmd({ if (!ctx) return const store = yield* InstanceStore.Service return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) + const tree = yield* Effect.orDie( + Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), + ) process.stdout.write(tree + EOL) }).pipe(Effect.ensuring(store.dispose(ctx))) }), From f98053c34e5ca56901818f72aeee84536a6187a5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:33:38 -0400 Subject: [PATCH 064/174] fix(instance): run bootstrap from instance store (#25475) --- packages/opencode/src/cli/bootstrap.ts | 6 +- packages/opencode/src/cli/cmd/tui/worker.ts | 17 ++-- packages/opencode/src/config/config.ts | 36 ++------ packages/opencode/src/effect/app-runtime.ts | 20 +---- .../opencode/src/project/bootstrap-service.ts | 9 ++ packages/opencode/src/project/bootstrap.ts | 14 ++- .../opencode/src/project/instance-runtime.ts | 27 ++++++ .../opencode/src/project/instance-store.ts | 15 +--- packages/opencode/src/project/instance.ts | 6 +- .../opencode/src/server/global-lifecycle.ts | 37 ++++++++ packages/opencode/src/server/routes/global.ts | 24 +++--- .../src/server/routes/instance/config.ts | 5 +- .../routes/instance/httpapi/groups/global.ts | 1 + .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/global.ts | 15 ++-- .../httpapi/middleware/instance-context.ts | 10 +-- .../server/routes/instance/httpapi/server.ts | 6 +- .../src/server/routes/instance/index.ts | 5 +- .../src/server/routes/instance/middleware.ts | 2 - .../src/server/routes/instance/project.ts | 10 +-- packages/opencode/src/server/workspace.ts | 4 +- .../agent/plugin-agent-regression.test.ts | 51 +++++++++++ packages/opencode/test/config/config.test.ts | 53 +++++++----- packages/opencode/test/config/tui.test.ts | 11 ++- .../test/effect/instance-state.test.ts | 7 +- packages/opencode/test/fixture/config.ts | 23 +++++ packages/opencode/test/fixture/fixture.ts | 47 +++++++--- packages/opencode/test/mcp/lifecycle.test.ts | 4 +- .../opencode/test/permission/next.test.ts | 16 ++-- .../test/plugin/auth-override.test.ts | 73 +++++++++++----- .../test/plugin/loader-shared.test.ts | 40 ++++++--- .../instance-bootstrap-regression.test.ts | 85 +++++++++++++++++++ .../opencode/test/project/instance.test.ts | 7 +- .../opencode/test/project/worktree.test.ts | 16 ++-- .../opencode/test/question/question.test.ts | 6 +- .../server/httpapi-instance-context.test.ts | 6 +- .../opencode/test/server/httpapi-mcp.test.ts | 4 +- .../test/server/httpapi-provider.test.ts | 4 +- .../opencode/test/session/compaction.test.ts | 3 +- .../opencode/test/session/instruction.test.ts | 17 +--- packages/opencode/test/tool/registry.test.ts | 42 ++++++++- .../opencode/test/tool/truncation.test.ts | 3 +- 42 files changed, 540 insertions(+), 249 deletions(-) create mode 100644 packages/opencode/src/project/bootstrap-service.ts create mode 100644 packages/opencode/src/project/instance-runtime.ts create mode 100644 packages/opencode/src/server/global-lifecycle.ts create mode 100644 packages/opencode/test/agent/plugin-agent-regression.test.ts create mode 100644 packages/opencode/test/fixture/config.ts create mode 100644 packages/opencode/test/project/instance-bootstrap-regression.test.ts diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index da90ec403..81a085d68 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,17 +1,15 @@ import { Instance } from "../project/instance" -import { InstanceStore } from "../project/instance-store" -import { getBootstrapRunEffect } from "../effect/app-runtime" +import { InstanceRuntime } from "../project/instance-runtime" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: await getBootstrapRunEffect(), fn: async () => { try { const result = await cb() return result } finally { - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index dd6f7e246..e4fbeb2fb 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,7 +2,7 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -10,8 +10,10 @@ import { GlobalBus } from "@/bus/global" import { Flag } from "@opencode-ai/core/flag/flag" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" -import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime" +import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { Effect } from "effect" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" ensureProcessMetadata("worker") @@ -77,19 +79,24 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, - init: await getBootstrapRunEffect(), fn: async () => { await upgrade().catch(() => {}) }, }) }, async reload() { - await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) + await AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + yield* cfg.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) + }), + ) }, async shutdown() { Log.Default.info("worker shutting down") - await InstanceStore.disposeAllInstances() + await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 46a31cf1c..a63d77013 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,11 +12,8 @@ import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { type InstanceContext } from "../project/instance" -import { InstanceStore } from "../project/instance-store" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" -import { GlobalBus } from "@/bus/global" -import { Event } from "../server/event" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" @@ -289,9 +286,9 @@ export interface Interface { readonly get: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect - readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect - readonly updateGlobal: (config: Info) => Effect.Effect - readonly invalidate: (wait?: boolean) => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -730,37 +727,17 @@ export const layer = Layer.effect( ) }) - const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) { + const update = Effect.fn("Config.update")(function* (config: Info) { const dir = yield* InstanceState.directory const file = path.join(dir, "config.json") const existing = yield* loadFile(file) yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) - if (options?.dispose !== false) { - // Fail loudly if no instance is bound — silently skipping would - // mask "config update without an active instance" bugs. The throw - // comes from `Instance.current` inside `InstanceState.context`. - const ctx = yield* InstanceState.context - yield* Effect.promise(() => InstanceStore.disposeInstance(ctx)) - } }) - const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { + const invalidate = Effect.fn("Config.invalidate")(function* () { yield* invalidateGlobal - const task = InstanceStore.disposeAllInstances() - .catch(() => undefined) - .finally(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }), - ) - if (wait) yield* Effect.promise(() => task) - else void task }) const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { @@ -784,9 +761,8 @@ export const layer = Layer.effect( if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } - // Only tear down running instances if the config actually changed. if (changed) yield* invalidate() - return next + return { info: next, changed } }) return Service.of({ diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 66f3a9b37..901738646 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" @@ -40,8 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -94,8 +93,7 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceRuntime.layer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, @@ -132,15 +130,3 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } - -let bootstrapRun: Promise> -export function getBootstrapRunEffect(): Promise> { - if (!bootstrapRun) { - bootstrapRun = AppRuntime.runPromise( - Effect.gen(function* () { - return (yield* InstanceBootstrap.Service).run - }), - ) - } - return bootstrapRun -} diff --git a/packages/opencode/src/project/bootstrap-service.ts b/packages/opencode/src/project/bootstrap-service.ts new file mode 100644 index 000000000..b20cc54cd --- /dev/null +++ b/packages/opencode/src/project/bootstrap-service.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export * as InstanceBootstrap from "./bootstrap-service" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 9f77de2d4..ea2aa2e84 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,21 +10,19 @@ import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import { Context, Effect, Layer } from "effect" +import { Effect, Layer } from "effect" import { Config } from "@/config/config" +import { Service } from "./bootstrap-service" -export interface Interface { - readonly run: Effect.Effect -} - -export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} +export { Service } from "./bootstrap-service" +export type { Interface } from "./bootstrap-service" export const layer = Layer.effect( Service, Effect.gen(function* () { // Yield each bootstrap dep at layer init so `run` itself has R = never. - // This breaks the circular declaration loop through Config → Instance → InstanceStore - // (instance-store.ts only yields this Service tag, never the impl-side services). + // InstanceStore imports only the lightweight tag from bootstrap-service.ts, + // so it can depend on bootstrap without importing this implementation graph. const bus = yield* Bus.Service const config = yield* Config.Service const file = yield* File.Service diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts new file mode 100644 index 000000000..a30bf5610 --- /dev/null +++ b/packages/opencode/src/project/instance-runtime.ts @@ -0,0 +1,27 @@ +import { makeRuntime } from "@/effect/run-service" +import { type InstanceContext } from "./instance-context" +import { InstanceStore, type LoadInput } from "./instance-store" +import { Effect, Layer } from "effect" + +// Production InstanceStore wiring plus a bridge for Promise/ALS callers that +// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself +// low-level while still giving legacy Hono and CLI paths the production +// bootstrap implementation. Delete the Promise helpers once those callers are +// migrated to Effect boundaries that provide InstanceStore directly. +// Keep the bootstrap implementation import lazy: Instance is imported broadly, +// and importing the app bootstrap graph at module load can trigger ESM cycles. +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +const runtime = makeRuntime(InstanceStore.Service, layer) + +export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input)) +export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) +export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) +export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) + +export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 00075be64..41adcbc7c 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -2,10 +2,10 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance as runDisposers } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" +import { InstanceBootstrap } from "./bootstrap-service" import * as Project from "./project" export interface LoadInput { @@ -36,10 +36,11 @@ interface Entry { readonly deferred: Deferred.Deferred } -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service + const bootstrap = yield* InstanceBootstrap.Service const scope = yield* Scope.Scope const cache = new Map() @@ -59,6 +60,7 @@ export const layer: Layer.Layer = Layer.effect( project: result.project, })), ) + yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) @@ -195,13 +197,4 @@ export const layer: Layer.Layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) -export const runtime = makeRuntime(Service, defaultLayer) - -// Promise-returning helpers for callers without an Effect runtime in scope. -// They route through `runtime` (not a yielded Service from a fresh runtime) -// so they share the cache that `Instance.provide` populates. -export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) -export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) -export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) - export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 5b2bcf6b3..81977affc 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,15 +1,13 @@ import { Effect } from "effect" import { context, type InstanceContext } from "./instance-context" -import { InstanceStore } from "./instance-store" +import { InstanceRuntime } from "./instance-runtime" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" export const Instance = { async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - const ctx = await InstanceStore.runtime.runPromise((store) => - store.load({ directory: input.directory, init: input.init }), - ) + const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init }) return context.provide(ctx, async () => input.fn()) }, get current() { diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts new file mode 100644 index 000000000..fbc300fad --- /dev/null +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -0,0 +1,37 @@ +import { GlobalBus } from "@/bus/global" +import { InstanceStore } from "@/project/instance-store" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { Event } from "./event" + +const log = Log.create({ service: "server" }) + +export const emitGlobalDisposed = Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), +) + +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn( + "Server.disposeAllInstancesAndEmitGlobalDisposed", +)(function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* (options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("global disposal failed", { cause }) + }), + ), + ) + : store.disposeAll()) + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) +}) + +export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index f40a58453..4a491d95b 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -1,25 +1,23 @@ import { Hono, type Context } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import { streamSSE } from "hono/streaming" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { InstanceStore } from "../../project/instance-store" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { lazy } from "../../util/lazy" import { Config } from "@/config/config" import { errors } from "../error" +import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" const log = Log.create({ service: "server" }) -export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({})) - async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { return streamSSE(c, async (stream) => { const q = new AsyncQueue() @@ -178,8 +176,13 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info.zod), async (c) => { const config = c.req.valid("json") - const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) - return c.json(next) + const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) + if (result.changed) { + void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( + () => undefined, + ) + } + return c.json(result.info) }, ) .post( @@ -200,14 +203,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await InstanceStore.disposeAllInstances() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: GlobalDisposedEvent.type, - properties: {}, - }, - }) + await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index f055917b0..96a7e756d 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -1,7 +1,8 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" @@ -55,7 +56,9 @@ export const ConfigRoutes = lazy(() => jsonRequest("ConfigRoutes.update", c, function* () { const config = c.req.valid("json") const cfg = yield* Config.Service + const store = yield* InstanceStore.Service yield* cfg.update(config) + yield* store.dispose(yield* InstanceState.context) return config }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 272b08606..75441b4ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" +import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 58aa81098..753ba0313 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -16,7 +16,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h }) const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) + yield* configSvc.update(ctx.payload) yield* markInstanceForDisposal(yield* InstanceState.context) return ctx.payload }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index bcad2832e..f9be57f4f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,8 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { EffectBridge } from "@/effect/bridge" import { Installation } from "@/installation" -import { InstanceStore } from "@/project/instance-store" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -68,7 +69,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service - const store = yield* InstanceStore.Service + const bridge = yield* EffectBridge.make() const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -83,15 +84,13 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) + const result = yield* config.updateGlobal(ctx.payload) + if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })) + return result.info }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* store.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) + yield* disposeAllInstancesAndEmitGlobalDisposed() return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 0e82da31b..d4913696d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,5 +1,4 @@ import { WorkspaceRef } from "@/effect/instance-ref" -import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -24,12 +23,11 @@ function decode(input: string): string { function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, - bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext return yield* store.provide( - { directory: decode(route.directory), init: bootstrap.run }, + { directory: decode(route.directory) }, effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) @@ -39,15 +37,13 @@ export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { const store = yield* InstanceStore.Service - const bootstrap = yield* InstanceBootstrap.Service - return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const store = yield* InstanceStore.Service - const bootstrap = yield* InstanceBootstrap.Service - return (effect) => provideInstanceContext(effect, store, bootstrap) + return (effect) => provideInstanceContext(effect, store) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 767bfc31d..ce1b21372 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -18,8 +18,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -153,8 +152,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceRuntime.layer, MCP.defaultLayer, ModelsDev.defaultLayer, Permission.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 530c02345..f0da2f3d8 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -6,7 +6,7 @@ import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -25,7 +25,6 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" -import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { @@ -63,7 +62,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index db7b9b52f..494459500 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { getBootstrapRunEffect } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -23,7 +22,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, - init: await getBootstrapRunEffect(), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 01a45c2fb..3d8bb605b 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,13 +2,12 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { getBootstrapRunEffect } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,12 +81,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ - directory: dir, - worktree: dir, - project: next, - init: await getBootstrapRunEffect(), - }) + await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 0036c9ab4..dbf693e8f 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,7 +5,7 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime" +import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" @@ -94,13 +94,11 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adapter.target(workspace) if (target.type === "local") { - const init = await getBootstrapRunEffect() return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => Instance.provide({ directory: target.directory, - init, async fn() { return next() }, diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts new file mode 100644 index 000000000..89e8a6640 --- /dev/null +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +test("plugin-registered agents appear in Agent.list", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + "export default async () => ({", + " config: async (cfg) => {", + " cfg.agent = cfg.agent ?? {}", + " cfg.agent.plugin_added = {", + ' description: "Added by a plugin via the config hook",', + ' mode: "subagent",', + " }", + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) + const added = agents.find((agent) => agent.name === "plugin_added") + expect(added?.description).toBe("Added by a plugin via the config hook") + expect(added?.mode).toBe("subagent") + }, + }) +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 5b2e91e37..9c4cbd788 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -12,8 +12,9 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -41,6 +42,12 @@ const emptyAuth = Layer.mock(Auth.Service)({ const testFlock = EffectFlock.defaultLayer +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + which: () => Effect.succeed(Option.none()), +}) + const layer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -48,7 +55,7 @@ const layer = Config.layer.pipe( Layer.provide(emptyAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) const it = testEffect(layer) @@ -57,9 +64,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe const save = (config: Config.Info) => Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) const saveGlobal = (config: Config.Info) => - Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer))) -const clear = (wait = false) => - Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) + Effect.runPromise( + Config.Service.use((svc) => svc.updateGlobal(config)).pipe( + Effect.map((result) => result.info), + Effect.scoped, + Effect.provide(layer), + ), + ) +const clear = async (wait = false) => { + await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) + if (wait) await InstanceRuntime.disposeAllInstances() +} const listDirs = () => Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) const ready = () => @@ -108,7 +123,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await disposeAllInstances() + await InstanceRuntime.disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } @@ -483,6 +498,7 @@ test("resolves env templates in account config with account token", async () => Layer.provide(emptyAuth), Layer.provide(fakeAccount), Layer.provideMerge(infra), + Layer.provide(noopNpm), ) try { @@ -493,7 +509,7 @@ test("resolves env templates in account config with account token", async () => expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") }), ), - ).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise) + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { if (originalControlToken !== undefined) { process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken @@ -550,7 +566,7 @@ test("validates config schema and throws on invalid fields", async () => { }) }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { // Strict schema should throw an error for invalid fields @@ -565,7 +581,7 @@ test("throws error for invalid JSON", async () => { await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { await expect(load()).rejects.toThrow() @@ -986,11 +1002,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const prev = process.env.OPENCODE_CONFIG_DIR process.env.OPENCODE_CONFIG_DIR = tmp.extra - const noopNpm = Layer.mock(Npm.Service)({ - install: () => Effect.void, - add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), - }) const testLayer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -1061,7 +1072,7 @@ test("resolves scoped npm plugins in config", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() @@ -1099,7 +1110,7 @@ test("merges plugin arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1258,7 +1269,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1307,7 +1318,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const cfg = await load() @@ -1883,7 +1894,7 @@ test("project config overrides remote well-known config", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -1941,7 +1952,7 @@ test("wellknown URL with trailing slash is normalized", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -2096,7 +2107,7 @@ describe("deduplicatePluginOrigins", () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 46a3f0626..a3f2a1b5f 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" @@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" import { ConfigPlugin } from "@/config/plugin" const wintest = process.platform === "win32" ? test : test.skip -const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) +const clear = async (wait = false) => { + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate())) + if (wait) await InstanceRuntime.disposeAllInstances() +} const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) beforeEach(async () => { @@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const server = await load() diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 0a8972ca4..f5e693388 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => const [, b] = yield* Effect.all( [ - Effect.promise(() => InstanceStore.reloadInstance({ directory: one })), + Effect.promise(() => reloadTestInstance({ directory: one })), Test.use((svc) => svc.get()).pipe(provideInstance(two)), ], { concurrency: "unbounded" }, diff --git a/packages/opencode/test/fixture/config.ts b/packages/opencode/test/fixture/config.ts new file mode 100644 index 000000000..4cd90c51b --- /dev/null +++ b/packages/opencode/test/fixture/config.ts @@ -0,0 +1,23 @@ +import { Config } from "@/config/config" +import { emptyConsoleState } from "@/config/console-state" +import { Effect, Layer } from "effect" + +export function make(overrides: Partial = {}) { + return Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed({ info: config, changed: false }), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + ...overrides, + }) +} + +export function layer(overrides?: Partial) { + return Layer.succeed(Config.Service, make(overrides)) +} + +export * as TestConfig from "./config" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 1b193e382..38017e516 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,20 +1,44 @@ import { $ } from "bun" +import * as Observability from "@opencode-ai/core/effect/observability" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, Context } from "effect" +import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" -// Re-export for test ergonomics. The implementation lives next to the runtime -// it consumes; see `InstanceStore.disposeAllInstances` for the rationale. -export { disposeAllInstances } from "../../src/project/instance-store" +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const testInstanceRuntime = ManagedRuntime.make( + InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)), +) + +const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => + testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) + +export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init })) + try { + return await Instance.restore(ctx, () => input.fn()) + } finally { + await runTestInstanceStore((store) => store.dispose(ctx)) + } +} + +export async function reloadTestInstance(input: { directory: string }) { + return runTestInstanceStore((store) => store.reload(input)) +} + +export async function disposeAllInstances() { + await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())]) +} // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -129,12 +153,10 @@ export const provideInstance = (directory: string) => (self: Effect.Effect): Effect.Effect => Effect.contextWith((services: Context.Context) => - Effect.promise(async () => - Instance.provide({ - directory, - fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))), - }), - ), + Effect.promise(async () => { + const ctx = await runTestInstanceStore((store) => store.load({ directory })) + return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))) + }), ) export function provideTmpdirInstance( @@ -148,10 +170,7 @@ export function provideTmpdirInstance( yield* Effect.addFinalizer(() => provided ? Effect.promise(() => - Instance.provide({ - directory: path, - fn: () => InstanceStore.disposeInstance(Instance.current), - }), + runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))), ).pipe(Effect.ignore) : Effect.void, ) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 59fa54cea..2ba487f3f 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,5 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -198,7 +198,7 @@ function withInstance( fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index c615e55e5..4d66784d8 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,8 +6,14 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { + disposeAllInstances, + provideInstance, + provideTmpdirInstance, + reloadTestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -1000,7 +1006,7 @@ it.live("pending permission rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }), + Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) @@ -1024,7 +1030,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1118,7 +1124,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 4bee98579..c77c0ca1c 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,11 +1,40 @@ import { describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect } from "effect" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { pathToFileURL } from "url" +import { Effect, Layer } from "effect" +import { provideTestInstance, tmpdir } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" +import { Plugin } from "@/plugin" +import { Auth } from "@/auth" +import { Bus } from "@/bus" +import { TestConfig } from "../fixture/config" + +function layer(directory: string, plugins: string[]) { + return ProviderAuth.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), + }), + directories: () => Effect.succeed([directory]), + }), + ), + ), + ), + ) +} describe("plugin.auth-override", () => { test("user plugin overrides built-in github-copilot auth", async () => { @@ -37,30 +66,32 @@ describe("plugin.auth-override", () => { await using plain = await tmpdir() - const methods = await Instance.provide({ - directory: tmp.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) - - const plainMethods = await Instance.provide({ - directory: plain.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) + const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href + const [methods, plainMethods] = await Promise.all([ + provideTestInstance({ + directory: tmp.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))), + ) + }, + }), + provideTestInstance({ + directory: plain.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))), + ) + }, + }), + ]) const copilot = methods[ProviderID.make("github-copilot")] expect(copilot).toBeDefined() expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") - }, 30000) // Increased timeout for plugin installation + }, 30000) }) const file = path.join(import.meta.dir, "../../src/plugin/index.ts") diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index e24cd0507..8c55950af 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -12,8 +12,9 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { Instance } = await import("../../src/project/instance") +const { Bus } = await import("../../src/bus") const { Npm } = await import("@opencode-ai/core/npm") +const { TestConfig } = await import("../fixture/config") afterAll(() => { if (disableDefault === undefined) { @@ -28,14 +29,31 @@ afterEach(async () => { }) async function load(dir: string) { - return Instance.provide({ - directory: dir, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.list() - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) + const source = path.join(dir, "opencode.json") + const config = (await Bun.file(source).json()) as { plugin?: Array]> } + const plugins = config.plugin ?? [] + return Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ), + ), + ), + provideInstance(dir), + Effect.runPromise, + ) } describe("plugin.loader.shared", () => { diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts new file mode 100644 index 000000000..bb8d43e01 --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, test } from "bun:test" +import { Hono } from "hono" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { Instance } from "../../src/project/instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +// These regressions cover the legacy instance-loading paths fixed by PRs +// #25389 and #25449. The plugin config hook writes a marker file, and the test +// bodies deliberately avoid touching Plugin or config directly. The marker only +// exists if InstanceBootstrap ran at the instance boundary. + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { + await using tmp = await bootstrapFixture() + + await Instance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { + await using tmp = await bootstrapFixture() + const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) + + const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 852c58ef4..bc8809af9 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -3,12 +3,17 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) + +const it = testEffect( + Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), +) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 806c47615..60c66981d 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,7 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -138,9 +138,10 @@ describe("Worktree", () => { expect(props.branch).toBe(info.branch) yield* Effect.promise(() => - InstanceStore.runtime.runPromise((s) => - s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), - ), + Instance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) @@ -162,9 +163,10 @@ describe("Worktree", () => { yield* Effect.promise(() => ready) yield* Effect.promise(() => - InstanceStore.runtime.runPromise((s) => - s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), - ), + Instance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 83968a6f8..694a37e99 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,7 +1,7 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" @@ -422,7 +422,7 @@ test("pending question rejects on instance dispose", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) }, }) @@ -457,7 +457,7 @@ test("pending question rejects on instance reload", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await InstanceStore.reloadInstance({ directory: tmp.path }) + await InstanceRuntime.reloadInstance({ directory: tmp.path }) }, }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index ece01cf32..f311de2b4 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,9 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -42,8 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceRuntime.layer, Project.defaultLayer, Workspace.defaultLayer, ), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 6f2b4cee3..396d04feb 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,7 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -59,7 +59,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) ) yield* Effect.addFinalizer(() => Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index b4cec9115..8118aa784 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -91,7 +91,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f35e044d7..f3f7cbaef 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -26,6 +26,7 @@ import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { TestConfig } from "../fixture/config" void Log.init({ print: false }) @@ -208,7 +209,7 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: Config.Info["compaction"]) { const base = Config.Info.zod.parse({}) - return Layer.mock(Config.Service)({ + return TestConfig.layer({ get: () => Effect.succeed({ ...base, compaction }), }) } diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index f80081759..3bb38c878 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Config } from "@/config/config" -import { emptyConsoleState } from "@/config/console-state" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" @@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) -const configLayer = Layer.succeed( - Config.Service, - Config.Service.of({ - get: () => Effect.succeed({}), - getGlobal: () => Effect.succeed({}), - getConsoleState: () => Effect.succeed(emptyConsoleState), - update: () => Effect.void, - updateGlobal: (config) => Effect.succeed(config), - invalidate: () => Effect.void, - directories: () => Effect.succeed([]), - waitForDependencies: () => Effect.void, - }), -) +const configLayer = TestConfig.layer() const instructionLayer = (global: Partial) => Instruction.layer.pipe( diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0cd3ec4d1..f9ac07831 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -7,10 +7,50 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Plugin } from "@/plugin" +import { Question } from "@/question" +import { Todo } from "@/session/todo" +import { Skill } from "@/skill" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Provider } from "@/provider/provider" +import { LSP } from "@/lsp/lsp" +import { Instruction } from "@/session/instruction" +import { Bus } from "@/bus" +import { FetchHttpClient } from "effect/unstable/http" +import { Format } from "@/format" +import { Ripgrep } from "@/file/ripgrep" +import * as Truncate from "@/tool/truncate" +import { InstanceState } from "@/effect/instance-state" const node = CrossSpawnSpawner.defaultLayer +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +const registryLayer = ToolRegistry.layer.pipe( + Layer.provide(configLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(node), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), +) + +const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9a01f95cd..e836b23eb 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" +import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") @@ -19,7 +20,7 @@ const configuredLayer = (cfg: Config.Info) => Layer.mergeAll( Truncate.defaultLayer, NodeFileSystem.layer, - Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }), + TestConfig.layer({ get: () => Effect.succeed(cfg) }), ) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) From 9bef88e3b072e426edfce2d7acb9a7f58ce53455 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 23:34:40 +0000 Subject: [PATCH 065/174] chore: generate --- .../opencode/src/server/global-lifecycle.ts | 34 +- packages/opencode/test/fixture/fixture.ts | 8 +- .../opencode/test/tool/truncation.test.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 154 +++---- packages/sdk/openapi.json | 428 +++++++++--------- 5 files changed, 315 insertions(+), 315 deletions(-) diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts index fbc300fad..aa761a42b 100644 --- a/packages/opencode/src/server/global-lifecycle.ts +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -16,22 +16,22 @@ export const emitGlobalDisposed = Effect.sync(() => }), ) -export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn( - "Server.disposeAllInstancesAndEmitGlobalDisposed", -)(function* (options?: { swallowErrors?: boolean }) { - const store = yield* InstanceStore.Service - yield* Effect.gen(function* () { - yield* (options?.swallowErrors - ? store.disposeAll().pipe( - Effect.catchCause((cause) => - Effect.sync(() => { - log.warn("global disposal failed", { cause }) - }), - ), - ) - : store.disposeAll()) - yield* emitGlobalDisposed - }).pipe(Effect.uninterruptible) -}) +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( + function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("global disposal failed", { cause }) + }), + ), + ) + : store.disposeAll() + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) + }, +) export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 38017e516..e6c8aebcb 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -155,7 +155,9 @@ export const provideInstance = Effect.contextWith((services: Context.Context) => Effect.promise(async () => { const ctx = await runTestInstanceStore((store) => store.load({ directory })) - return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))) + return Instance.restore(ctx, () => + Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))), + ) }), ) @@ -170,7 +172,9 @@ export function provideTmpdirInstance( yield* Effect.addFinalizer(() => provided ? Effect.promise(() => - runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))), + runTestInstanceStore((store) => + store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))), + ), ).pipe(Effect.ignore) : Effect.void, ) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index e836b23eb..e948a6dcb 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -17,11 +17,7 @@ const ROOT = path.resolve(import.meta.dir, "..", "..") const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) const configuredLayer = (cfg: Config.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - TestConfig.layer({ get: () => Effect.succeed(cfg) }), - ) + Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, TestConfig.layer({ get: () => Effect.succeed(cfg) })) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b925ec609..e60ea7694 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -40,35 +40,6 @@ export type EventServerInstanceDisposed = { } } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -230,6 +201,53 @@ export type EventInstallationUpdateAvailable = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -452,38 +470,6 @@ export type EventVcsBranchUpdated = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -988,6 +974,20 @@ export type EventSessionDeleted = { } } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -1113,10 +1113,6 @@ export type GlobalEvent = { payload: | EventProjectUpdated | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1126,6 +1122,12 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1141,10 +1143,6 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -1158,6 +1156,8 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2056,10 +2056,6 @@ export type File = { export type Event = | EventProjectUpdated | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2069,6 +2065,12 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2084,10 +2086,6 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2101,6 +2099,8 @@ export type Event = | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed export type McpStatusConnected = { status: "connected" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index cfd8277a3..0e9c11ca6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7693,76 +7693,6 @@ }, "required": ["type", "properties"] }, - "Event.server.connected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.connected" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -8225,6 +8155,144 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "QuestionOption": { "type": "object", "properties": { @@ -8743,102 +8811,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10441,6 +10413,34 @@ }, "required": ["type", "properties"] }, + "Event.server.connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, "SyncEvent.message.updated": { "type": "object", "properties": { @@ -10963,18 +10963,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -11002,6 +10990,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11047,18 +11053,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -11098,6 +11092,12 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, { "$ref": "#/components/schemas/SyncEvent.message.updated" }, @@ -13256,18 +13256,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -13295,6 +13283,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13340,18 +13346,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13390,6 +13384,12 @@ }, { "$ref": "#/components/schemas/Event.session.deleted" + }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" } ] }, From 85bb9007baab8e6c5cd28ea39e9eddb15022cb5d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:54:13 -0400 Subject: [PATCH 066/174] feat(cli): auto-dispose InstanceContext after effectCmd handlers (#25481) --- packages/opencode/src/cli/effect-cmd.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 29f750d16..6785e0b61 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -2,6 +2,7 @@ import type { Argv } from "yargs" import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" +import { InstanceRef } from "@/effect/instance-ref" import { cmd } from "./cmd/cmd" /** @@ -21,6 +22,11 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. * + * The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded + * InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`) + * on every Exit — success, typed failure, defect, or interruption. Matches the + * legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate. + * * Errors propagate to the existing top-level handler in `src/index.ts`; use * `fail("...")` for user-visible domain failures (clean exit, formatted message). * @@ -47,6 +53,17 @@ export const effectCmd = (opts: { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args)))) + await AppRuntime.runPromise( + InstanceStore.Service.use((store) => + store.provide( + { directory }, + Effect.gen(function* () { + const ctx = yield* InstanceRef + const body = opts.handler(args) + return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body + }), + ), + ), + ) }, }) From 6b68b1020e3efbeb7d09b3318495593b66f1c745 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 10:09:50 +1000 Subject: [PATCH 067/174] docs: clarify LSP and formatter opt-in config (#25502) --- README.md | 2 +- packages/opencode/src/config/config.ts | 10 ++++- packages/web/src/content/docs/config.mdx | 39 +++++++++++++++++++- packages/web/src/content/docs/formatters.mdx | 29 ++++++++++----- packages/web/src/content/docs/lsp.mdx | 34 ++++++++++++----- 5 files changed, 92 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 79ccf8b34..3ebfb1627 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ - 100% open source - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. -- Out-of-the-box LSP support +- Built-in opt-in LSP support - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a63d77013..c6557360b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -192,8 +192,14 @@ export const Info = Schema.Struct({ ]), ), ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), - formatter: Schema.optional(ConfigFormatter.Info), - lsp: Schema.optional(ConfigLSP.Info), + formatter: Schema.optional(ConfigFormatter.Info).annotate({ + description: + "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), + lsp: Schema.optional(ConfigLSP.Info).annotate({ + description: + "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include", }), diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 14eefdd81..8568ffbb9 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -575,7 +575,16 @@ Notice that this only works if it was not installed using a package manager such ### Formatters -You can configure code formatters through the `formatter` option. +You can enable and configure code formatters through the `formatter` option. Omit it to keep formatters disabled. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom formatters. ```json title="opencode.json" { @@ -599,6 +608,34 @@ You can configure code formatters through the `formatter` option. --- +### LSP Servers + +You can enable and configure LSP servers through the `lsp` option. Omit it to keep LSP disabled. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom LSP servers. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "disabled": true + } + } +} +``` + +[Learn more about LSP servers here](/docs/lsp). + +--- + ### Permissions By default, opencode **allows all operations** without requiring explicit approval. You can change this using the `permission` option. diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index dbee49dca..ec7a965d2 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -3,7 +3,7 @@ title: Formatters description: OpenCode uses language specific formatters. --- -OpenCode automatically formats files after they are written or edited using language-specific formatters. This ensures that the code that is generated follows the code styles of your project. +OpenCode can format files after they are written or edited using language-specific formatters. Formatters are disabled by default; enable them in your config before OpenCode will run them. --- @@ -40,25 +40,36 @@ OpenCode comes with several built-in formatters for popular languages and framew | uv | .py, .pyi | `uv` command available | | zig | .zig, .zon | `zig` command available | -So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. +When formatters are enabled, OpenCode will use `prettier` for matching files if your project has `prettier` in `package.json`. --- ## How it works -When OpenCode writes or edits a file, it: +When OpenCode writes or edits a file and formatters are enabled, it: 1. Checks the file extension against all enabled formatters. 2. Runs the appropriate formatter command on the file. -3. Applies the formatting changes automatically. +3. Applies the formatting changes. -This process happens in the background, ensuring your code styles are maintained without any manual steps. +This process happens in the background for enabled formatters. --- ## Configure -You can customize formatters through the `formatter` section in your OpenCode config. +You can enable and customize formatters through the `formatter` section in your OpenCode config. + +To enable all built-in formatters, set `formatter` to `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom formatters. ```json title="opencode.json" { @@ -72,7 +83,7 @@ Each formatter configuration supports the following: | Property | Type | Description | | ------------- | -------- | ------------------------------------------------------- | | `disabled` | boolean | Set this to `true` to disable the formatter | -| `command` | string[] | The command to run for formatting | +| `command` | string[] | The command to run for formatting. Required for custom formatters; optional for built-ins. | | `environment` | object | Environment variables to set when running the formatter | | `extensions` | string[] | File extensions this formatter should handle | @@ -82,7 +93,7 @@ Let's look at some examples. ### Disabling formatters -To disable **all** formatters globally, set `formatter` to `false`: +If `formatter` is omitted, all formatters are disabled. To disable all formatters after another config enabled them, set `formatter` to `false`: ```json title="opencode.json" {3} { @@ -108,7 +119,7 @@ To disable a **specific** formatter, set `disabled` to `true`: ### Custom formatters -You can override the built-in formatters or add new ones by specifying the command, environment variables, and file extensions: +You can configure built-in formatters with options like `environment` or `extensions`. To add a custom formatter, specify a `command` and `extensions`: ```json title="opencode.json" {4-14} { diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index ad6a4644d..5854fe1f1 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -3,7 +3,7 @@ title: LSP Servers description: OpenCode integrates with your LSP servers. --- -OpenCode integrates with your Language Server Protocol (LSP) to help the LLM interact with your codebase. It uses diagnostics to provide feedback to the LLM. +OpenCode can integrate with your Language Server Protocol (LSP) to help the LLM interact with your codebase. It uses diagnostics to provide feedback to the LLM. --- @@ -48,7 +48,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | yaml-ls | .yaml, .yml | Auto-installs Red Hat yaml-language-server | | zls | .zig, .zon | `zig` command available | -LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met. +When LSP is enabled, servers start when one of the above file extensions is detected and the requirements are met. :::note You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_LSP_DOWNLOAD` environment variable to `true`. @@ -58,7 +58,7 @@ You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_ ## How It Works -When opencode opens a file, it: +When LSP is enabled and opencode opens a file, it: 1. Checks the file extension against all enabled LSP servers. 2. Starts the appropriate LSP server if not already running. @@ -67,7 +67,18 @@ When opencode opens a file, it: ## Configure -You can customize LSP servers through the `lsp` section in your opencode config. +You can enable and customize LSP servers through the `lsp` section in your opencode config. + +To enable all built-in LSP servers, set `lsp` to `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom servers. ```json title="opencode.json" { @@ -76,7 +87,9 @@ You can customize LSP servers through the `lsp` section in your opencode config. } ``` -Each LSP server supports the following: +Each configured LSP server entry supports the following: + +Server entries need `command` unless they only disable a server. | Property | Type | Description | | ---------------- | -------- | ------------------------------------------------- | @@ -94,11 +107,12 @@ Let's look at some examples. Use the `env` property to set environment variables when starting the LSP server: -```json title="opencode.json" {5-7} +```json title="opencode.json" {5-8} { "$schema": "https://opencode.ai/config.json", "lsp": { "rust": { + "command": ["rust-analyzer"], "env": { "RUST_LOG": "debug" } @@ -113,11 +127,13 @@ Use the `env` property to set environment variables when starting the LSP server Use the `initialization` property to pass initialization options to the LSP server. These are server-specific settings sent during the LSP `initialize` request: -```json title="opencode.json" {5-9} +```json title="opencode.json" {5-13} { "$schema": "https://opencode.ai/config.json", "lsp": { - "typescript": { + "custom-lsp": { + "command": ["custom-lsp-server", "--stdio"], + "extensions": [".custom"], "initialization": { "preferences": { "importModuleSpecifierPreference": "relative" @@ -136,7 +152,7 @@ Initialization options vary by LSP server. Check your LSP server's documentation ### Disabling LSP servers -To disable **all** LSP servers globally, set `lsp` to `false`: +If `lsp` is omitted, all LSP servers are disabled. To disable all LSP servers after another config enabled them, set `lsp` to `false`: ```json title="opencode.json" {3} { From d10fb88b66181ab710b768a23317c0b972bcd9c5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:10:53 +0000 Subject: [PATCH 068/174] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 6 ++++++ packages/sdk/openapi.json | 2 ++ packages/web/src/content/docs/formatters.mdx | 10 +++++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e60ea7694..af29de17f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1592,6 +1592,9 @@ export type Config = { enabled: boolean } } + /** + * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ formatter?: | boolean | { @@ -1604,6 +1607,9 @@ export type Config = { extensions?: Array } } + /** + * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ lsp?: | boolean | { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0e9c11ca6..680771e18 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11928,6 +11928,7 @@ } }, "formatter": { + "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", "anyOf": [ { "type": "boolean" @@ -11970,6 +11971,7 @@ ] }, "lsp": { + "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", "anyOf": [ { "type": "boolean" diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index ec7a965d2..58b63fa34 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -80,12 +80,12 @@ Use an object to keep built-ins enabled while configuring overrides or custom fo Each formatter configuration supports the following: -| Property | Type | Description | -| ------------- | -------- | ------------------------------------------------------- | -| `disabled` | boolean | Set this to `true` to disable the formatter | +| Property | Type | Description | +| ------------- | -------- | ------------------------------------------------------------------------------------------ | +| `disabled` | boolean | Set this to `true` to disable the formatter | | `command` | string[] | The command to run for formatting. Required for custom formatters; optional for built-ins. | -| `environment` | object | Environment variables to set when running the formatter | -| `extensions` | string[] | File extensions this formatter should handle | +| `environment` | object | Environment variables to set when running the formatter | +| `extensions` | string[] | File extensions this formatter should handle | Let's look at some examples. From fd01dc9c890057cd055a5ba1e5307597e0f04a4d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:31:21 -0400 Subject: [PATCH 069/174] test(httpapi): add route exerciser --- packages/opencode/script/httpapi-exercise.ts | 1709 +++++++++++++++++ .../src/server/routes/instance/tui.ts | 6 +- packages/opencode/src/storage/db.ts | 1 + packages/opencode/src/util/lazy.ts | 2 + packages/opencode/test/AGENTS.md | 33 +- packages/opencode/test/bus/bus-effect.test.ts | 167 +- packages/opencode/test/fixture/fixture.ts | 16 + packages/opencode/test/lib/effect.ts | 48 +- .../opencode/test/question/question.test.ts | 718 ++++--- packages/opencode/test/server/global-bus.ts | 34 + .../test/server/httpapi-config.test.ts | 20 +- .../test/server/httpapi-experimental.test.ts | 19 +- .../server/httpapi-instance-context.test.ts | 24 +- .../server/httpapi-instance.legacy.test.ts | 32 +- .../opencode/test/server/httpapi-tui.test.ts | 13 +- packages/opencode/test/tool/glob.test.ts | 78 +- packages/opencode/test/tool/grep.test.ts | 103 +- packages/opencode/test/tool/question.test.ts | 85 +- packages/opencode/test/tool/read.test.ts | 26 +- packages/opencode/test/tool/registry.test.ts | 248 ++- packages/opencode/test/tool/write.test.ts | 316 ++- 21 files changed, 2685 insertions(+), 1013 deletions(-) create mode 100644 packages/opencode/script/httpapi-exercise.ts create mode 100644 packages/opencode/test/server/global-bus.ts diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts new file mode 100644 index 000000000..f0faa2760 --- /dev/null +++ b/packages/opencode/script/httpapi-exercise.ts @@ -0,0 +1,1709 @@ +/** + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. + * + * The script intentionally isolates `OPENCODE_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. + */ +import { Cause, ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { TestLLMServer } from "../test/lib/llm-server" +import type { Config } from "../src/config/config" +import { MessageID, PartID, type SessionID } from "../src/session/schema" +import { ModelID, ProviderID } from "../src/provider/schema" +import type { MessageV2 } from "../src/session/message-v2" +import type { Worktree } from "../src/worktree" +import type { Project } from "../src/project/project" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL +const exerciseGlobalRoot = process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.OPENCODE_DISABLE_SHARE = "true" +const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB +const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.OPENCODE_DB = exerciseDatabasePath +Flag.OPENCODE_DB = exerciseDatabasePath + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const +const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +type Method = (typeof Methods)[number] +type OpenApiMethod = (typeof OpenApiMethods)[number] +type Mode = "effect" | "parity" | "coverage" +type Backend = "effect" | "legacy" +type Comparison = "none" | "status" | "json" +type CaptureMode = "full" | "stream" +type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +type OpenApiSpec = { paths?: Record>> } +type JsonObject = Record + +type Options = { + mode: Mode + include: string | undefined + failOnMissing: boolean + failOnSkip: boolean +} + +type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +type CallResult = { + status: number + contentType: string + body: unknown + text: string +} + +type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +type SeededContext = ScenarioContext & { + state: S +} + +type Scenario = ActiveScenario | TodoScenario +type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean +} + +/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ +type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + capture: CaptureMode + mutates: boolean + reset: boolean +} +type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} +type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +type TodoInfo = { content: string; status: string; priority: string } +type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +type Runtime = { + PublicApi: typeof import("../src/server/routes/instance/httpapi/public")["PublicApi"] + ExperimentalHttpApiServer: typeof import("../src/server/routes/instance/httpapi/server")["ExperimentalHttpApiServer"] + Server: typeof import("../src/server/server")["Server"] + AppLayer: typeof import("../src/effect/app-runtime")["AppLayer"] + InstanceRef: typeof import("../src/effect/instance-ref")["InstanceRef"] + Instance: typeof import("../src/project/instance")["Instance"] + InstanceStore: typeof import("../src/project/instance-store")["InstanceStore"] + Session: typeof import("../src/session/session")["Session"] + Todo: typeof import("../src/session/todo")["Todo"] + Worktree: typeof import("../src/worktree")["Worktree"] + Project: typeof import("../src/project/project")["Project"] + Tui: typeof import("../src/server/routes/instance/tui") + disposeAllInstances: typeof import("../test/fixture/fixture")["disposeAllInstances"] + tmpdir: typeof import("../test/fixture/fixture")["tmpdir"] + resetDatabase: typeof import("../test/fixture/db")["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../src/server/routes/instance/httpapi/server") + const server = await import("../src/server/server") + const appRuntime = await import("../src/effect/app-runtime") + const instanceRef = await import("../src/effect/instance-ref") + const instance = await import("../src/project/instance") + const instanceStore = await import("../src/project/instance-store") + const session = await import("../src/session/session") + const todo = await import("../src/session/todo") + const worktree = await import("../src/worktree") + const project = await import("../src/project/project") + const tui = await import("../src/server/routes/instance/tui") + const fixture = await import("../test/fixture/fixture") + const db = await import("../test/fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string) { + this.state = { + method, + path, + name, + project: { git: true }, + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + capture: "full", + mutates: false, + reset: true, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status(status = 200, inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, compare: Comparison = "status") { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect( + status, + inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, + compare, + ) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json") { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done(compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + } + } +} + +const http = { + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), +} + +const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +function route(template: string, params: Record) { + return Object.entries(params).reduce((next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template) +} + +const scenarios: Scenario[] = [ + http.get("/global/health", "global.health").global().json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http + .get("/global/event", "global.event") + .global() + .stream() + .status(200, (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status"), + http.get("/global/config", "global.config.get").global().json(), + http + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write(path.join(exerciseConfigDirectory, "opencode.jsonc"), JSON.stringify({ username: "httpapi-global" }, null, 2)), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text()) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status"), + http.post("/global/dispose", "global.dispose").global().mutating().json(200, (body) => { + check(body === true, "global dispose should return true") + }, "status"), + http.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") + }), + http.get("/vcs", "vcs.get").json(), + http.get("/vcs/diff", "vcs.diff").at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })).json(200, array), + http.get("/command", "command.list").json(200, array, "status"), + http.get("/agent", "app.agents").json(200, array, "status"), + http.get("/skill", "app.skills").json(200, array, "status"), + http.get("/lsp", "lsp.status").json(200, array), + http.get("/formatter", "formatter.status").json(200, array), + http.get("/config", "config.get").json(200, undefined, "status"), + http + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json(200, (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, "status"), + http + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.get("/config/providers", "config.providers").json(), + http.get("/project", "project.list").json(200, array, "status"), + http.get("/project/current", "project.current").json(200, (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, "status"), + http + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json(200, (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check(isRecord(body.commands) && body.commands.start === "bun --version", "project update should return patched command") + }, "status"), + http + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json(200, (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, "status"), + http.get("/provider", "provider.list").json(), + http.get("/provider/auth", "provider.auth").json(), + http + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .status(400), + http + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .status(400), + http.get("/permission", "permission.list").json(200, array), + http + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "bad" } })) + .status(400), + http + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "once" } })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.get("/question", "question.list").json(200, array), + http + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: "Yes" } })) + .status(400), + http + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: [["Yes"]] } })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.get("/file/status", "file.status").json(200, array), + http + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/event", "event.stream") + .stream() + .status(200, (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status"), + http.get("/mcp", "mcp.status").json(), + http + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json(200, (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, "status"), + http + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ path: "/mcp", headers: ctx.headers(), body: { name: "httpapi-invalid", config: { type: "invalid" } } })) + .status(400), + http + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(400, (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, "status"), + http + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(400, (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, "status"), + http + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), headers: ctx.headers(), body: { code: 1 } })) + .status(400), + http + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.get("/pty/shells", "pty.shells").json(200, array), + http.get("/pty", "pty.list").json(200, array), + http + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json(200, (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, "status"), + http + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.get("/experimental/console", "experimental.console.get").json(), + http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ path: "/experimental/console/switch", headers: ctx.headers(), body: { accountID: "httpapi-account", orgID: "httpapi-org" } })) + .status(400, undefined, "none"), + http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), headers: ctx.headers() })) + .status(200), + http + .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .at((ctx) => ({ path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), headers: ctx.headers(), body: {} })) + .status(400), + http + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers() })) + .json(200, array, "status"), + http.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.get("/experimental/worktree", "worktree.list").json(200, array), + http + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status"), + http + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ path: "/experimental/worktree/reset", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.get("/experimental/session", "experimental.session.list").json(200, array), + http.get("/experimental/resource", "experimental.resource.list").json(), + http.post("/sync/history", "sync.history.list").at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })).json(200, array), + http + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http.post("/sync/start", "sync.start").mutating().preserveDatabase().json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http.post("/instance/dispose", "instance.dispose").mutating().json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write(path.join(exerciseDataDirectory, "auth.json"), JSON.stringify({ test: { type: "api", key: "remove-me" } })), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check(body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), "seeded session should be listed") + }), + http + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json(200, (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, "status"), + http + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { title: "After rename" } })) + .json(200, (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, "status"), + http + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), body: { title: 1 } })) + .status(400), + http + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check(body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), "children should include seeded child") + }), + http + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), "message should include seeded part") + }), + http + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, "status"), + http + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), headers: ctx.headers(), body: {} })) + .json(200, (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, "status"), + http + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json(200, (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), "shell should return a tool part") + }, "status"), + http + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- script/httpapi-exercise.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check(isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, "revert should record reverted message") + }, "status"), + http + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, "status"), + http + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { sessionID: ctx.state.id, permissionID: "per_httpapi_deprecated" }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, "status"), + http + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, "status"), + http + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json(200, (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, "status"), + http.post("/global/upgrade", "global.upgrade").global().at(() => ({ path: "/global/upgrade", body: { target: 1 } })).status(400), +] + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi())) + const selected = scenarios.filter((scenario) => matches(options, scenario)) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) + + const results = options.mode === "coverage" ? selected.map(coverageResult) : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) +}) + +function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend("effect", scenario) + const legacy = yield* runBackend("legacy", scenario) + yield* compare(scenario, effect, legacy) + }) + } + + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const effect = yield* call("effect", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, effect) + if (options.mode === "parity" && scenario.compare !== "none") { + const legacy = yield* call("legacy", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, legacy) + yield* compare(scenario, effect, legacy) + } + }), + ) +} + +function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const result = yield* call(backend, scenario, ctx) + yield* scenario.expect(ctx, ctx.state, result) + return result + }), + ) +} + +function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { + return Effect.acquireRelease( + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((context) => Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => + run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => + run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => + run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} + +function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { + return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture)) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = undefined + if (appCache[backend]) return appCache[backend] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache.legacy = { + request: (input, init) => legacy.request(input, init), + }) + } + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }))), + ), + { disableLogger: true }, + ).handler + return (appCache.effect = { + request(input: string | URL | Request, init?: RequestInit) { + return handler(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} + +const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all([exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => fs.rm(file, { force: true }).catch(() => undefined))) + } + if (!preserveExerciseGlobalRoot) await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) + +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) + +function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .sort() +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + } +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return scenario.name.includes(options.include) || scenario.path.includes(options.include) || scenario.method.includes(options.include.toUpperCase()) +} + +function printHeader(options: Options, effectRoutes: string[], honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[]) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) + console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + ) + console.log("") +} + +function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log(`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + continue + } + if (result.status === "skip") { + console.log(`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`) + continue + } + console.log(`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sort(item)])) +} + +function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 48399a5f4..d2be01521 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -26,13 +26,17 @@ export function nextTuiRequest() { return request.next() } +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + export function submitTuiResponse(body: unknown) { response.push(body) } export async function callTui(ctx: Context) { const body = await ctx.req.json() - request.push({ + submitTuiRequest({ path: ctx.req.path, body, }) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index de4683b75..06cb99f97 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -122,6 +122,7 @@ export const Client = lazy(() => { }) export function close() { + if (!Client.loaded()) return Client().$client.close() Client.reset() } diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 86967e11a..d9abf18a5 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -14,5 +14,7 @@ export function lazy(fn: () => T) { value = undefined } + result.loaded = () => loaded + return result } diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 00564a17b..41372b15a 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s ```typescript import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) describe("my service", () => { - it.live("does the thing", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const svc = yield* MyService.Service - const out = yield* svc.run() - expect(out).toEqual("ok") - }), - ), + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), ) }) ``` @@ -111,6 +108,7 @@ describe("my service", () => { - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context. - Most integration-style tests in this package use `it.live(...)`. ### Effect Fixtures @@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. -Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. +Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path: + +```typescript +import { TestInstance } from "../fixture/fixture" + +it.instance("uses the temp directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) +``` + +Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime. ### Style @@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst - Keep the test body inside `Effect.gen(function* () { ... })`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. -- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. +- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 101d3be72..377c54109 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -2,9 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node) const it = testEffect(live) describe("Bus (Effect-native)", () => { - it.live("publish + subscribe stream delivers events", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() + it.instance("publish + subscribe stream delivers events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Deferred.await(done) - expect(received).toEqual([1, 2]) - }), - ), + expect(received).toEqual([1, 2]) + }), ) - it.live("subscribe filters by event type", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() + it.instance("subscribe filters by event type", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Pong, { message: "ignored" }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done) - expect(pings).toEqual([42]) - }), - ), + expect(pings).toEqual([42]) + }), ) - it.live("subscribeAll receives all types", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() + it.instance("subscribeAll receives all types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const types: string[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribeAll(), (evt) => - Effect.sync(() => { - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribeAll(), (evt) => + Effect.sync(() => { + types.push(evt.type) + if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done) - expect(types).toContain("test.effect.ping") - expect(types).toContain("test.effect.pong") - }), - ), + expect(types).toContain("test.effect.ping") + expect(types).toContain("test.effect.pong") + }), ) - it.live("multiple subscribers each receive the event", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() + it.instance("multiple subscribers each receive the event", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(doneA) + yield* Deferred.await(doneB) - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ), + expect(a).toEqual([99]) + expect(b).toEqual([99]) + }), ) it.live("subscribeAll stream sees InstanceDisposed on disposal", () => diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index e6c8aebcb..970365f53 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -6,6 +6,7 @@ import path from "path" import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" @@ -184,6 +185,21 @@ export function provideTmpdirInstance( }) } +export class TestInstance extends Context.Service()("@test/Instance") {} + +export const withTmpdirInstance = + (options?: { git?: boolean; config?: Partial }) => + (self: Effect.Effect) => + Effect.gen(function* () { + const directory = yield* tmpdirScoped(options) + return yield* InstanceStore.Service.use((store) => + store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))), + ) + }).pipe( + Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) + export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, options?: { git?: boolean; config?: (url: string) => Partial }, diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 131ec5cc6..2fbf5ca11 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -3,8 +3,24 @@ import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" +import type { Config } from "@/config/config" +import { TestInstance, withTmpdirInstance } from "../fixture/fixture" type Body = Effect.Effect | (() => Effect.Effect) +type InstanceOptions = { git?: boolean; config?: Partial } + +function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, + testOptions?: number | TestOptions, +): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { + if (typeof options === "number") return { instanceOptions: undefined, testOptions: options } + if (isInstanceOptions(options)) return { instanceOptions: options, testOptions } + return { instanceOptions: undefined, testOptions: options } +} const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) @@ -38,7 +54,37 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - return { effect, live } + const instance = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + instance.only = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + instance.skip = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + return { effect, live, instance } } // Test environment with TestClock and TestConsole diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 694a37e99..461fb88f2 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,65 +1,64 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray; tool?: Question.Tool }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) +const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) +const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Question.Tool +}) { + const question = yield* Question.Service + return yield* question.ask(input) +}) -const reply = (input: { requestID: QuestionID; answers: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input))) +const listEffect = Question.Service.use((svc) => svc.list()) -const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) +const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: { + requestID: QuestionID + answers: ReadonlyArray +}) { + const question = yield* Question.Service + yield* question.reply(input) +}) + +const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) { + const question = yield* Question.Service + yield* question.reject(id) +}) afterEach(async () => { await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ -async function rejectAll() { - const pending = await list() - for (const req of pending) { - await reject(req.id) - } -} - -test("ask - returns pending promise", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - expect(promise).toBeInstanceOf(Promise) - await rejectAll() - await promise.catch(() => {}) - }, - }) +const rejectAll = Effect.gen(function* () { + yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true }) }) -test("ask - adds to pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +const waitForPending = (count: number) => + Effect.gen(function* () { + for (let i = 0; i < 100; i++) { + const pending = yield* listEffect + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) + }) + +it.instance("ask - remains pending until answered", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", @@ -68,30 +67,81 @@ test("ask - adds to pending list", async () => { { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) - const pending = await list() - expect(pending.length).toBe(1) - expect(pending[0].questions).toEqual(questions) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) +it.instance("ask - adds to pending list", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + expect(pending[0].questions).toEqual(questions) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) // reply tests -test("reply - resolves the pending ask with answers", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +it.instance("reply - resolves the pending ask with answers", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + const requestID = pending[0].id + + yield* replyEffect({ + requestID, + answers: [["Option 1"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), + { git: true }, +) + +it.instance("reply - removes from pending list", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", @@ -100,366 +150,260 @@ test("reply - resolves the pending ask with answers", async () => { { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - const pending = await list() - const requestID = pending[0].id + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Option 1"]], + }) + yield* Fiber.join(fiber) - await reply({ - requestID, - answers: [["Option 1"]], - }) + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) - const answers = await promise - expect(answers).toEqual([["Option 1"]]) - }, - }) -}) - -test("reply - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - expect(pending.length).toBe(1) - - await reply({ - requestID: pending[0].id, - answers: [["Option 1"]], - }) - await promise - - const after = await list() - expect(after.length).toBe(0) - }, - }) -}) - -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reply({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }) - // Should not throw - }, - }) -}) +it.instance("reply - does nothing for unknown requestID", () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), + { git: true }, +) // reject tests -test("reject - throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - await reject(pending[0].id) - - await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) - }, - }) -}) - -test("reject - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - expect(pending.length).toBe(1) - - await reject(pending[0].id) - promise.catch(() => {}) // Ignore rejection - - const after = await list() - expect(after.length).toBe(0) - }, - }) -}) - -test("reject - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reject(QuestionID.make("que_unknown")) - // Should not throw - }, - }) -}) - -// multiple questions tests - -test("ask - handles multiple questions", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +it.instance("reject - throws RejectedError", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", options: [ - { label: "Build", description: "Build the project" }, - { label: "Test", description: "Run tests" }, + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, ], }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) + + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), + { git: true }, +) + +it.instance("reject - removes from pending list", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { - question: "Which environment?", - header: "Env", + question: "What would you like to do?", + header: "Action", options: [ - { label: "Dev", description: "Development" }, - { label: "Prod", description: "Production" }, + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - const pending = await list() + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - await reply({ - requestID: pending[0].id, - answers: [["Build"], ["Dev"]], - }) + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) - const answers = await promise - expect(answers).toEqual([["Build"], ["Dev"]]) - }, - }) -}) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true }) + +// multiple questions tests + +it.instance("ask - handles multiple questions", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Build", description: "Build the project" }, + { label: "Test", description: "Run tests" }, + ], + }, + { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Build"], ["Dev"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), + { git: true }, +) // list tests -test("list - returns all pending requests", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const p1 = ask({ - sessionID: SessionID.make("ses_test1"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }) +it.instance("list - returns all pending requests", () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_test1"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(Effect.forkScoped) - const p2 = ask({ - sessionID: SessionID.make("ses_test2"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_test2"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(Effect.forkScoped) - const pending = await list() - expect(pending.length).toBe(2) - await rejectAll() - p1.catch(() => {}) - p2.catch(() => {}) - }, - }) -}) + const pending = yield* waitForPending(2) + expect(pending.length).toBe(2) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), + { git: true }, +) -test("list - returns empty when no pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await list() - expect(pending.length).toBe(0) - }, - }) -}) +it.instance("list - returns empty when no pending", () => + Effect.gen(function* () { + const pending = yield* listEffect + expect(pending.length).toBe(0) + }), + { git: true }, +) -test("questions stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) +it.live("questions stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) - const p1 = Instance.provide({ - directory: one.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_one"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }), - }) + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(provideInstance(one), Effect.forkScoped) - const p2 = Instance.provide({ - directory: two.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_two"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }), - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(provideInstance(two), Effect.forkScoped) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => list(), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => list(), - }) + const onePending = yield* waitForPending(1).pipe(provideInstance(one)) + const twoPending = yield* waitForPending(1).pipe(provideInstance(two)) - expect(onePending.length).toBe(1) - expect(twoPending.length).toBe(1) - expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) - expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) - await Instance.provide({ - directory: one.path, - fn: () => reject(onePending[0].id), - }) - await Instance.provide({ - directory: two.path, - fn: () => reject(twoPending[0].id), - }) + yield* rejectEffect(onePending[0].id).pipe(provideInstance(one)) + yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two)) - await p1.catch(() => {}) - await p2.catch(() => {}) -}) + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), +) -test("pending question rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("pending question rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_dispose"), - questions: [ - { - question: "Dispose me?", - header: "Dispose", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceRuntime.disposeInstance(Instance.current) - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) - expect(await result).toBeInstanceOf(Question.RejectedError) -}) +it.live("pending question rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) -test("pending question rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_reload"), - questions: [ - { - question: "Reload me?", - header: "Reload", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceRuntime.reloadInstance({ directory: tmp.path }) - }, - }) - - expect(await result).toBeInstanceOf(Question.RejectedError) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) diff --git a/packages/opencode/test/server/global-bus.ts b/packages/opencode/test/server/global-bus.ts new file mode 100644 index 000000000..c8d0f9219 --- /dev/null +++ b/packages/opencode/test/server/global-bus.ts @@ -0,0 +1,34 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Cause, Effect } from "effect" + +export function waitGlobalBusEvent(input: { + timeout?: number + message?: string + predicate: (event: GlobalEvent) => boolean +}) { + return Effect.callback((resume) => { + const cleanup = () => GlobalBus.off("event", handler) + + const handler = (event: GlobalEvent) => { + try { + if (!input.predicate(event)) return + cleanup() + resume(Effect.succeed(event)) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(cleanup) + }).pipe( + Effect.timeout(input.timeout ?? 10_000), + Effect.mapError((error) => + Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error, + ), + ) +} + +export const waitGlobalBusEventPromise = (input: Parameters[0]) => + Effect.runPromise(waitGlobalBusEvent(input)) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 7d269b6be..16e8975ea 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 0185af2df..5f36a3274 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" @@ -11,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -31,20 +31,9 @@ function createSession(input?: Session.CreateInput) { } async function waitReady(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for worktree.ready")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for worktree.ready", + predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index f311de2b4..7a889aea0 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,6 +1,5 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -19,6 +18,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -95,24 +95,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => Layer.build, ) -const waitDisposedEvent = Effect.promise( - () => - new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed") return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve({ directory: event.directory, workspace: event.workspace }) - } - - GlobalBus.on("event", onEvent) - }), -) +const waitDisposedEvent = waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", +}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) const serveDisposeProbe = () => HttpRouter.serve( diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 22a56ba8a..b5f0805e4 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } @@ -117,13 +105,9 @@ describe("instance HttpApi", () => { test("serves instance dispose through Hono bridge", async () => { await using tmp = await tmpdir() - const disposed = new Promise((resolve) => { - const onEvent = (event: { directory?: string; payload: { type?: string } }) => { - if (event.payload.type !== "server.instance.disposed") return - GlobalBus.off("event", onEvent) - resolve(event.directory) - } - GlobalBus.on("event", onEvent) + const disposed = waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", }) const response = await app().request(InstancePaths.dispose, { @@ -133,6 +117,6 @@ describe("instance HttpApi", () => { expect(response.status).toBe(200) expect(await response.json()).toBe(true) - expect(await disposed).toBe(tmp.path) + expect((await disposed).directory).toBe(tmp.path) }) }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 1fd3ce2b3..1b9e1c150 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "../../src/bus/global" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" @@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -23,14 +23,9 @@ function app(experimental = true) { } function nextCommandExecute() { - return new Promise((resolve) => { - const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { - if (event.payload.type !== TuiEvent.CommandExecute.type) return - GlobalBus.off("event", listener) - resolve(event.payload.properties?.command) - } - GlobalBus.on("event", listener) - }) + return waitGlobalBusEventPromise({ + predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, + }).then((event) => event.payload.properties?.command) } async function expectTrue(path: string, headers: Record, body?: unknown) { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 028436d29..94f401afd 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( @@ -33,49 +33,47 @@ const ctx = { } describe("tool.glob", () => { - it.live("matches files from a directory path", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) - yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const result = yield* glob.execute( + it.instance("matches files from a directory path", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(test.directory, "a.ts")) + expect(result.output).not.toContain(path.join(test.directory, "b.txt")) + }), + ) + + it.instance("rejects exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( { pattern: "*.ts", - path: dir, + path: file, }, ctx, ) - expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(dir, "a.ts")) - expect(result.output).not.toContain(path.join(dir, "b.txt")) - }), - ), - ) - - it.live("rejects exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "a.ts") - yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const exit = yield* glob - .execute( - { - pattern: "*.ts", - path: file, - }, - ctx, - ) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") - } - }), - ), + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), ) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index c807d1281..4b0da7c69 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { provideInstance, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" @@ -54,61 +54,58 @@ describe("tool.grep", () => { }), ) - it.live("no matches returns correct output", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "xyznonexistentpatternxyz123", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBe(0) - expect(result.output).toBe("No files found") - }), - ), + it.instance("no matches returns correct output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBe(0) + expect(result.output).toBe("No files found") + }), ) - it.live("finds matches in tmp instance", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBeGreaterThan(0) - }), - ), + it.instance("finds matches in tmp instance", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + }), ) - it.live("supports exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line2", - path: file, - }, - ctx, - ) - expect(result.metadata.matches).toBe(1) - expect(result.output).toContain(file) - expect(result.output).toContain("Line 2: line2") - }), - ), + it.instance("supports exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), ) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 662073a8c..3f2cba894 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest }) describe("tool.question", () => { - it.live("should successfully execute with valid question parameters", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite color?", - header: "Color", - options: [ - { label: "Red", description: "The color of passion" }, - { label: "Blue", description: "The color of sky" }, - ], - multiple: false, - }, - ] + it.instance("should successfully execute with valid question parameters", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Red"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Red"]] }) - const result = yield* Fiber.join(fiber) - expect(result.title).toBe("Asked 1 question") - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.title).toBe("Asked 1 question") + }), ) - it.live("should now pass with a header longer than 12 but less than 30 chars", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite animal?", - header: "This Header is Over 12", - options: [{ label: "Dog", description: "Man's best friend" }], - }, - ] + it.instance("should now pass with a header longer than 12 but less than 30 chars", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) - const result = yield* Fiber.join(fiber) - expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }), ) // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 3fa61401e..695d96ec2 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => { }) describe("tool.read truncation", () => { - it.live("truncates large file by bytes and sets truncated metadata", () => + it.instance("truncates large file by bytes and sets truncated metadata", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const base = yield* load(path.join(FIXTURES_DIR, "models-api.json")) const target = 60 * 1024 const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) - yield* put(path.join(dir, "large.json"), content) + yield* put(path.join(test.directory, "large.json"), content) - const result = yield* exec(dir, { filePath: path.join(dir, "large.json") }) + const result = yield* run({ filePath: path.join(test.directory, "large.json") }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Output capped at") expect(result.output).toContain("Use offset=") }), ) - it.live("truncates by line count when limit is specified", () => + it.instance("truncates by line count when limit is specified", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - yield* put(path.join(dir, "many-lines.txt"), lines) + yield* put(path.join(test.directory, "many-lines.txt"), lines) - const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 }) + const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Showing lines 1-10 of 100") expect(result.output).toContain("Use offset=11") @@ -286,12 +286,12 @@ describe("tool.read truncation", () => { }), ) - it.live("does not truncate small file", () => + it.instance("does not truncate small file", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* put(path.join(dir, "small.txt"), "hello world") + const test = yield* TestInstance + yield* put(path.join(test.directory, "small.txt"), "hello world") - const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") }) + const result = yield* run({ filePath: path.join(test.directory, "small.txt") }) expect(result.metadata.truncated).toBe(false) expect(result.output).toContain("End of file") }), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index f9ac07831..c33981ddf 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -57,136 +56,133 @@ afterEach(async () => { }) describe("tool.registry", () => { - it.live("loads tools from .opencode/tool (singular)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tool (singular)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tool = path.join(opencode, "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools from .opencode/tools (plural)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tools (plural)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools with external dependencies without crashing", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + it.instance("loads tools with external dependencies without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), - ) + }, + }), + ), + ) - const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("cowsay") - }), - ), + const cowsay = path.join(opencode, "node_modules", "cowsay") + yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("cowsay") + }), ) }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 4931d2a54..8bba52a4b 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* ( describe("tool.write", () => { describe("new file creation", () => { - it.live("writes content to new file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "newfile.txt") - const result = yield* run({ filePath: filepath, content: "Hello, World!" }) + it.instance("writes content to new file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, content: "Hello, World!" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(false) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(false) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("Hello, World!") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("Hello, World!") + }), ) - it.live("creates parent directories if needed", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "nested", "deep", "file.txt") - yield* run({ filePath: filepath, content: "nested content" }) + it.instance("creates parent directories if needed", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "deep", "file.txt") + yield* run({ filePath: filepath, content: "nested content" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("nested content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("nested content") + }), ) - it.live("handles relative paths by resolving to instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* run({ filePath: "relative.txt", content: "relative content" }) + it.instance("handles relative paths by resolving to instance directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* run({ filePath: "relative.txt", content: "relative content" }) - const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) - expect(content).toBe("relative content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8")) + expect(content).toBe("relative content") + }), ) }) describe("existing file overwrite", () => { - it.live("overwrites existing file content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "existing.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new content" }) + it.instance("overwrites existing file content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new content" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(true) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(true) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("new content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("new content") + }), ) - it.live("preserves BOM when overwriting existing files", () => - provideTmpdirInstance((dir) => + it.instance("preserves BOM when overwriting existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ) + + it.instance( + "restores BOM after formatter strips it", + () => Effect.gen(function* () { - const filepath = path.join(dir, "existing.cs") + const test = yield* TestInstance + const filepath = path.join(test.directory, "formatted.cs") const bom = String.fromCharCode(0xfeff) yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) @@ -127,165 +140,138 @@ describe("tool.write", () => { expect(content.charCodeAt(0)).toBe(0xfeff) expect(content.slice(1)).toBe("using Up;\n") }), - ), - ) - - it.live("restores BOM after formatter strips it", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "formatted.cs") - const bom = String.fromCharCode(0xfeff) - yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) - - yield* run({ filePath: filepath, content: "using Up;\n" }) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }), - { - config: { - formatter: { - stripbom: { - extensions: [".cs"], - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", - "$FILE", - ], - }, + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], }, }, }, - ), + }, ) - it.live("returns diff in metadata for existing files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new" }) + it.instance("returns diff in metadata for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new" }) - expect(result.metadata).toHaveProperty("filepath", filepath) - expect(result.metadata).toHaveProperty("exists", true) - }), - ), + expect(result.metadata).toHaveProperty("filepath", filepath) + expect(result.metadata).toHaveProperty("exists", true) + }), ) }) describe("file permissions", () => { - it.live("sets file permissions when writing sensitive data", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "sensitive.json") - yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) + it.instance("sets file permissions when writing sensitive data", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "sensitive.json") + yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) - if (process.platform !== "win32") { - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.mode & 0o777).toBe(0o644) - } - }), - ), + if (process.platform !== "win32") { + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.mode & 0o777).toBe(0o644) + } + }), ) }) describe("content types", () => { - it.live("writes JSON content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "data.json") - const data = { key: "value", nested: { array: [1, 2, 3] } } - yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) + it.instance("writes JSON content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.json") + const data = { key: "value", nested: { array: [1, 2, 3] } } + yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(JSON.parse(content)).toEqual(data) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(JSON.parse(content)).toEqual(data) + }), ) - it.live("writes binary-safe content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "binary.bin") - const content = "Hello\x00World\x01\x02\x03" - yield* run({ filePath: filepath, content }) + it.instance("writes binary-safe content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "binary.bin") + const content = "Hello\x00World\x01\x02\x03" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) - it.live("writes empty content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "empty.txt") - yield* run({ filePath: filepath, content: "" }) + it.instance("writes empty content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "empty.txt") + yield* run({ filePath: filepath, content: "" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("") - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.size).toBe(0) - }), - ), + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.size).toBe(0) + }), ) - it.live("writes multi-line content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "multiline.txt") - const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") - yield* run({ filePath: filepath, content: lines }) + it.instance("writes multi-line content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "multiline.txt") + const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + yield* run({ filePath: filepath, content: lines }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe(lines) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe(lines) + }), ) - it.live("handles different line endings", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "crlf.txt") - const content = "Line 1\r\nLine 2\r\nLine 3" - yield* run({ filePath: filepath, content }) + it.instance("handles different line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "crlf.txt") + const content = "Line 1\r\nLine 2\r\nLine 3" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) }) describe("error handling", () => { - it.live("throws error when OS denies write access", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const readonlyPath = path.join(dir, "readonly.txt") - yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) - yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) - expect(exit._tag).toBe("Failure") - }), - ), + it.instance("throws error when OS denies write access", () => + Effect.gen(function* () { + const test = yield* TestInstance + const readonlyPath = path.join(test.directory, "readonly.txt") + yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) + yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) + const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), ) }) describe("title generation", () => { - it.live("returns relative path as title", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "src", "components", "Button.tsx") - yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) + it.instance("returns relative path as title", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "src", "components", "Button.tsx") + yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) - const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) - expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) - }), - ), + const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) + expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) + }), ) }) }) From a6464062b7b28a3b0e0637166c73eadef1ebe878 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:32:24 +0000 Subject: [PATCH 070/174] chore: generate --- packages/opencode/script/httpapi-exercise.ts | 931 ++++++++++++------ packages/opencode/test/lib/effect.ts | 18 +- .../opencode/test/question/question.test.ts | 418 ++++---- 3 files changed, 853 insertions(+), 514 deletions(-) diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index f0faa2760..1681f2e21 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -32,7 +32,9 @@ import type { Project } from "../src/project/project" import path from "path" const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL -const exerciseGlobalRoot = process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +const exerciseGlobalRoot = + process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") @@ -42,7 +44,9 @@ const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencod const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB -const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +const exerciseDatabasePath = + process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) process.env.OPENCODE_DB = exerciseDatabasePath Flag.OPENCODE_DB = exerciseDatabasePath @@ -167,21 +171,21 @@ const original = { } type Runtime = { - PublicApi: typeof import("../src/server/routes/instance/httpapi/public")["PublicApi"] - ExperimentalHttpApiServer: typeof import("../src/server/routes/instance/httpapi/server")["ExperimentalHttpApiServer"] - Server: typeof import("../src/server/server")["Server"] - AppLayer: typeof import("../src/effect/app-runtime")["AppLayer"] - InstanceRef: typeof import("../src/effect/instance-ref")["InstanceRef"] - Instance: typeof import("../src/project/instance")["Instance"] - InstanceStore: typeof import("../src/project/instance-store")["InstanceStore"] - Session: typeof import("../src/session/session")["Session"] - Todo: typeof import("../src/session/todo")["Todo"] - Worktree: typeof import("../src/worktree")["Worktree"] - Project: typeof import("../src/project/project")["Project"] + PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../src/server/server"))["Server"] + AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../src/session/session"))["Session"] + Todo: (typeof import("../src/session/todo"))["Todo"] + Worktree: (typeof import("../src/worktree"))["Worktree"] + Project: (typeof import("../src/project/project"))["Project"] Tui: typeof import("../src/server/routes/instance/tui") - disposeAllInstances: typeof import("../test/fixture/fixture")["disposeAllInstances"] - tmpdir: typeof import("../test/fixture/fixture")["tmpdir"] - resetDatabase: typeof import("../test/fixture/db")["resetDatabase"] + disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] } let runtimePromise: Promise | undefined @@ -276,7 +280,11 @@ class ScenarioBuilder { ) } - status(status = 200, inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, compare: Comparison = "status") { + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) @@ -287,19 +295,20 @@ class ScenarioBuilder { /** Assert JSON status/content-type plus an optional synchronous body check. */ json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { - return this.jsonEffect( - status, - inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, - compare, - ) + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) } /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ - jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json") { + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) if (inspect) yield* inspect(result.body, ctx) }), ) @@ -321,7 +330,10 @@ class ScenarioBuilder { return builder } - private done(compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect): ActiveScenario { + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { const state = this.state return { kind: "active", @@ -357,52 +369,80 @@ const pending = (method: Method, path: string, name: string, reason: string): To }) function route(template: string, params: Record) { - return Object.entries(params).reduce((next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template) + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) } const scenarios: Scenario[] = [ - http.get("/global/health", "global.health").global().json(200, (body) => { - object(body) - check(body.healthy === true, "server should report healthy") - }), + http + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), http .get("/global/event", "global.event") .global() .stream() - .status(200, (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") - check(result.text.includes("server.connected"), "global event should emit initial connection event") - }), - "status"), + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), http.get("/global/config", "global.config.get").global().json(), http .patch("/global/config", "global.config.update") .global() .seeded(() => Effect.promise(() => - Bun.write(path.join(exerciseConfigDirectory, "opencode.jsonc"), JSON.stringify({ username: "httpapi-global" }, null, 2)), + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), ), ) .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - object(body) - check(body.username === "httpapi-global", "global config update should return patched config") - const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text()) - check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") - }), - "status"), - http.post("/global/dispose", "global.dispose").global().mutating().json(200, (body) => { - check(body === true, "global dispose should return true") - }, "status"), + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), http.get("/path", "path.get").json(200, (body, ctx) => { object(body) check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") }), http.get("/vcs", "vcs.get").json(), - http.get("/vcs/diff", "vcs.diff").at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })).json(200, array), + http + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), http.get("/command", "command.list").json(200, array, "status"), http.get("/agent", "app.agents").json(200, array, "status"), http.get("/skill", "app.skills").json(200, array, "status"), @@ -413,20 +453,28 @@ const scenarios: Scenario[] = [ .patch("/config", "config.update") .mutating() .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) - .json(200, (body) => { - object(body) - check(body.username === "httpapi-local", "local config update should return patched config") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), http .patch("/config", "config.update.invalid") .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) .status(400), http.get("/config/providers", "config.providers").json(), http.get("/project", "project.list").json(200, array, "status"), - http.get("/project/current", "project.current").json(200, (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") - }, "status"), + http.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), http .patch("/project/{projectID}", "project.update") .mutating() @@ -436,55 +484,93 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { name: "HTTP API Project", commands: { start: "bun --version" } }, })) - .json(200, (body) => { - object(body) - check(body.name === "HTTP API Project", "project update should return patched name") - check(isRecord(body.commands) && body.commands.start === "bun --version", "project update should return patched command") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), http .post("/project/git/init", "project.initGit") .mutating() .inProject({ git: false }) - .json(200, (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "git init should return current project") - check(body.vcs === "git", "git init should mark the project as git-backed") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), http.get("/provider", "provider.list").json(), http.get("/provider/auth", "provider.auth").json(), http .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") - .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) .status(400), http .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") - .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) .status(400), http.get("/permission", "permission.list").json(200, array), http .post("/permission/{requestID}/reply", "permission.reply.invalid") - .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "bad" } })) + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) .status(400), http .post("/permission/{requestID}/reply", "permission.reply") - .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "once" } })) + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) .json(200, (body) => { check(body === true, "permission reply should return true even when request is no longer pending") }), http.get("/question", "question.list").json(200, array), http .post("/question/{requestID}/reply", "question.reply.invalid") - .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: "Yes" } })) + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) .status(400), http .post("/question/{requestID}/reply", "question.reply") - .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: [["Yes"]] } })) + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) .json(200, (body) => { check(body === true, "question reply should return true even when request is no longer pending") }), http .post("/question/{requestID}/reject", "question.reject") - .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) .json(200, (body) => { check(body === true, "question reject should return true even when request is no longer pending") }), @@ -517,7 +603,10 @@ const scenarios: Scenario[] = [ http .get("/find/file", "find.files") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, headers: ctx.headers() })) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) .json(200, array), http .get("/find/symbol", "find.symbols") @@ -527,12 +616,15 @@ const scenarios: Scenario[] = [ http .get("/event", "event.stream") .stream() - .status(200, (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") - check(result.text.includes("server.connected"), "event should emit initial connection event") - }), - "status"), + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), http.get("/mcp", "mcp.status").json(), http .post("/mcp", "mcp.add") @@ -542,22 +634,34 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, })) - .json(200, (body) => { - object(body) - object(body["httpapi-disabled"]) - check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") - }, "status"), + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), http .post("/mcp", "mcp.add.invalid") - .at((ctx) => ({ path: "/mcp", headers: ctx.headers(), body: { name: "httpapi-invalid", config: { type: "invalid" } } })) + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) .status(400), http .post("/mcp/{name}/auth", "mcp.auth.start") .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(400, (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth response should include error") - }, "status"), + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), http .delete("/mcp/{name}/auth", "mcp.auth.remove") .mutating() @@ -568,14 +672,25 @@ const scenarios: Scenario[] = [ }), http .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") - .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(400, (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") - }, "status"), + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), http .post("/mcp/{name}/auth/callback", "mcp.auth.callback") - .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), headers: ctx.headers(), body: { code: 1 } })) + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) .status(400), http .post("/mcp/{name}/connect", "mcp.connect") @@ -597,12 +712,16 @@ const scenarios: Scenario[] = [ .post("/pty", "pty.create") .mutating() .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) - .json(200, (body, ctx) => { - object(body) - check(body.title === "HTTP API PTY", "PTY create should return requested title") - check(body.command === "/bin/sh", "PTY create should use controlled shell command") - check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), http .post("/pty", "pty.create.invalid") .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) @@ -635,7 +754,11 @@ const scenarios: Scenario[] = [ http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), http .post("/experimental/console/switch", "experimental.console.switchOrg") - .at((ctx) => ({ path: "/experimental/console/switch", headers: ctx.headers(), body: { accountID: "httpapi-account", orgID: "httpapi-org" } })) + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) .status(400, undefined, "none"), http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), @@ -647,15 +770,25 @@ const scenarios: Scenario[] = [ http .delete("/experimental/workspace/{id}", "experimental.workspace.remove") .mutating() - .at((ctx) => ({ path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) .status(200), http .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") - .at((ctx) => ({ path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), headers: ctx.headers(), body: {} })) + .at((ctx) => ({ + path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + body: {}, + })) .status(400), http .get("/experimental/tool", "tool.list") - .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers() })) + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) .json(200, array, "status"), http.get("/experimental/tool/ids", "tool.ids").json(200, array), http.get("/experimental/worktree", "worktree.list").json(200, array), @@ -663,13 +796,16 @@ const scenarios: Scenario[] = [ .post("/experimental/worktree", "worktree.create") .mutating() .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(typeof body.directory === "string", "created worktree should include directory") - yield* ctx.worktreeRemove(body.directory) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), http .post("/experimental/worktree", "worktree.create.invalid") .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) @@ -686,7 +822,11 @@ const scenarios: Scenario[] = [ .post("/experimental/worktree/reset", "worktree.reset") .mutating() .seeded((ctx) => ctx.worktree({ name: "api-reset" })) - .at((ctx) => ({ path: "/experimental/worktree/reset", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) .jsonEffect(200, (body, ctx) => Effect.gen(function* () { check(body === true, "worktree reset should return true") @@ -695,17 +835,27 @@ const scenarios: Scenario[] = [ ), http.get("/experimental/session", "experimental.session.list").json(200, array), http.get("/experimental/resource", "experimental.resource.list").json(), - http.post("/sync/history", "sync.history.list").at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })).json(200, array), + http + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), http .post("/sync/replay", "sync.replay") .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) .status(400), - http.post("/sync/start", "sync.start").mutating().preserveDatabase().json(200, (body) => { - check(body === true, "sync start should return true when no workspace sessions exist") - }), - http.post("/instance/dispose", "instance.dispose").mutating().json(200, (body) => { - check(body === true, "instance dispose should return true") - }), + http + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), http .post("/log", "app.log") .global() @@ -730,7 +880,10 @@ const scenarios: Scenario[] = [ .global() .seeded(() => Effect.promise(() => - Bun.write(path.join(exerciseDataDirectory, "auth.json"), JSON.stringify({ test: { type: "api", key: "remove-me" } })), + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), ), ) .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) @@ -748,7 +901,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) .json(200, (body, ctx) => { array(body) - check(body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), "seeded session should be listed") + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) }), http .get("/session/status", "session.status") @@ -758,11 +914,15 @@ const scenarios: Scenario[] = [ .post("/session", "session.create") .mutating() .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) - .json(200, (body, ctx) => { - object(body) - check(body.title === "Created session", "created session should use requested title") - check(body.directory === ctx.directory, "created session should use scenario directory") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), http .get("/session/{sessionID}", "session.get") .seeded((ctx) => ctx.session({ title: "Get me" })) @@ -774,21 +934,36 @@ const scenarios: Scenario[] = [ }), http .get("/session/{sessionID}", "session.get.missing") - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) .status(404), http .patch("/session/{sessionID}", "session.update") .mutating() .seeded((ctx) => ctx.session({ title: "Before rename" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { title: "After rename" } })) - .json(200, (body) => { - object(body) - check(body.title === "After rename", "updated session should use new title") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), http .patch("/session/{sessionID}", "session.update.invalid") .mutating() - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), body: { title: 1 } })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) .status(400), http .delete("/session/{sessionID}", "session.delete") @@ -810,10 +985,16 @@ const scenarios: Scenario[] = [ return { parent, child } }), ) - .at((ctx) => ({ path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) .json(200, (body, ctx) => { array(body) - check(body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), "children should include seeded child") + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) }), http .get("/session/{sessionID}/todo", "session.todo") @@ -825,7 +1006,10 @@ const scenarios: Scenario[] = [ return { session, todos } }), ) - .at((ctx) => ({ path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) .json(200, (body, ctx) => { check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") }), @@ -861,7 +1045,10 @@ const scenarios: Scenario[] = [ .json(200, (body, ctx) => { object(body) check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), "message should include seeded part") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) }), http .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") @@ -882,10 +1069,14 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { ...ctx.state.message.part, text: "after" }, })) - .json(200, (body) => { - object(body) - check(body.type === "text" && body.text === "after", "updated part should be returned") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), http .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") .mutating() @@ -938,11 +1129,19 @@ const scenarios: Scenario[] = [ .post("/session/{sessionID}/fork", "session.fork") .mutating() .seeded((ctx) => ctx.session({ title: "Fork source" })) - .at((ctx) => ({ path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), headers: ctx.headers(), body: {} })) - .json(200, (body) => { - object(body) - check(typeof body.id === "string", "fork should return a session") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), http .post("/session/{sessionID}/abort", "session.abort") .mutating() @@ -953,7 +1152,10 @@ const scenarios: Scenario[] = [ }), http .post("/session/{sessionID}/abort", "session.abort.missing") - .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) .json(200, (body) => { check(body === true, "missing session abort should remain a no-op success") }), @@ -1002,14 +1204,20 @@ const scenarios: Scenario[] = [ parts: [{ type: "text", text: "hello llm" }], }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/prompt_async", "session.prompt_async") .preserveDatabase() @@ -1053,13 +1261,16 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { command: "init", arguments: "", model: "test/test-model" }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/shell", "session.shell") .preserveDatabase() @@ -1070,11 +1281,18 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, })) - .json(200, (body) => { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), "shell should return a tool part") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), http .post("/session/{sessionID}/summarize", "session.summarize") .preserveDatabase() @@ -1122,17 +1340,20 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { providerID: "test", modelID: "test-model", auto: false }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "summarize should return true") - const messages = yield* ctx.messages(ctx.state.id) - check( - messages.some((message) => message.info.role === "assistant" && message.info.summary === true), - "summarize should create a summary assistant message", - ) - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/revert", "session.revert") .mutating() @@ -1148,25 +1369,42 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { messageID: ctx.state.message.info.id }, })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.session.id, "revert should return the session") - check(isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, "revert should record reverted message") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), http .post("/session/{sessionID}/unrevert", "session.unrevert") .mutating() .seeded((ctx) => ctx.session({ title: "Unrevert session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unrevert should return the session") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), http .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) .at((ctx) => ({ - path: route("/session/{sessionID}/permissions/{permissionID}", { sessionID: ctx.state.id, permissionID: "per_httpapi_deprecated" }), + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), headers: ctx.headers(), body: { response: "once" }, })) @@ -1178,19 +1416,27 @@ const scenarios: Scenario[] = [ .mutating() .seeded((ctx) => ctx.session({ title: "Share session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "share should return the session") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), http .delete("/session/{sessionID}/share", "session.unshare") .mutating() .seeded((ctx) => ctx.session({ title: "Unshare session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unshare should return the session") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), http .post("/tui/append-prompt", "tui.appendPrompt") .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) @@ -1238,13 +1484,21 @@ const scenarios: Scenario[] = [ .get("/tui/control/next", "tui.control.next") .mutating() .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) - .json(200, (body) => { - object(body) - check(body.path === "/tui/exercise", "control next should return queued path") - object(body.body) - check(body.body.text === "queued", "control next should return queued body") - }, "status"), - http.post("/global/upgrade", "global.upgrade").global().at(() => ({ path: "/global/upgrade", body: { target: 1 } })).status(400), + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http + .post("/global/upgrade", "global.upgrade") + .global() + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), ] const main = Effect.gen(function* () { @@ -1259,12 +1513,18 @@ const main = Effect.gen(function* () { printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) - const results = options.mode === "coverage" ? selected.map(coverageResult) : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) printResults(results, missing, extra) - if (results.some((result) => result.status === "fail")) return yield* Effect.fail(new Error("one or more scenarios failed")) - if (options.failOnSkip && results.some((result) => result.status === "skip")) return yield* Effect.fail(new Error("one or more scenarios are skipped")) - if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) }) function runScenario(options: Options) { @@ -1322,102 +1582,107 @@ function withContext(scenario: ActiveScenario, use: (ctx: SeededContext Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), ).pipe( - Effect.flatMap((context) => Effect.gen(function* () { - const modules = yield* Effect.promise(() => runtime()) - const path = context.dir?.path - const instance = path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - Effect.catchCause((cause) => - Effect.sleep("100 millis").pipe( - Effect.andThen( - modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), + Effect.flatMap((context) => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), ), + Effect.catchCause(() => Effect.failCause(cause)), ), - Effect.catchCause(() => Effect.failCause(cause)), - ), - ), - ) - : undefined - const run = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) - const directory = () => { - if (!context.dir?.path) throw new Error("scenario needs a project directory") - return context.dir.path - } - const llm = () => { - if (!context.llm) throw new Error("scenario needs fake LLM") - return context.llm - } - const base: ScenarioContext = { - directory: context.dir?.path, - headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), - file: (name, content) => - Effect.promise(() => { - return Bun.write(`${directory()}/${name}`, content) - }).pipe(Effect.asVoid), - session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), - sessionGet: (sessionID) => - run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), - ), - project: () => - Effect.sync(() => { - if (!instance) throw new Error("scenario needs a project directory") - return instance.project - }), - message: (sessionID, input) => - Effect.gen(function* () { - const info: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: "build", - model: { - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), - }, - } - const part: MessageV2.TextPart = { - id: PartID.ascending(), - sessionID, - messageID: info.id, - type: "text", - text: input?.text ?? "hello", - } - yield* run( - modules.Session.Service.use((svc) => - Effect.gen(function* () { - yield* svc.updateMessage(info) - yield* svc.updatePart(part) - }), ), ) - return { info, part } + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), + ...extra, }), - messages: (sessionID) => - run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), - todos: (sessionID, todos) => - run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => - run(modules.Worktree.Service.use((svc) => svc.create(input))), - worktreeRemove: (directory) => - run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), - llmText: (value) => Effect.suspend(() => llm().text(value)), - llmWait: (count) => Effect.suspend(() => llm().wait(count)), - tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), - } - const state = yield* scenario.seed(base) - return yield* use({ ...base, state }) - }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), Effect.ensuring(scenario.reset ? resetState : Effect.void), ) } -function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { if (!project.llm || !llmUrl) return { git: project.git, config: project.config } const fake = fakeLlmConfig(llmUrl) return { @@ -1475,7 +1740,9 @@ function controlledPtyInput(title: string | undefined) { } function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { - return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture)) + return Effect.promise(async () => + capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + ) } const appCache: Partial> = {} @@ -1494,13 +1761,20 @@ function app(modules: Runtime, backend: Backend) { const handler = HttpRouter.toWebHandler( modules.ExperimentalHttpApiServer.routes.pipe( - Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }))), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), + ), + ), ), { disableLogger: true }, ).handler return (appCache.effect = { request(input: string | URL | Request, init?: RequestInit) { - return handler(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context) + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) }, }) } @@ -1545,16 +1819,23 @@ async function captureStream(response: Response) { const cleanupExercisePaths = Effect.promise(async () => { const fs = await import("fs/promises") if (!preserveExerciseDatabase) { - await Promise.all([exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => fs.rm(file, { force: true }).catch(() => undefined))) + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) } - if (!preserveExerciseGlobalRoot) await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) }) function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { return Effect.sync(() => { - if (effect.status !== legacy.status) throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) if (scenario.compare === "status") return - if (stable(effect.body) !== stable(legacy.body)) throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) }) } @@ -1570,7 +1851,9 @@ const resetState = Effect.promise(async () => { function routeKeys(spec: OpenApiSpec) { return Object.entries(spec.paths ?? {}) - .flatMap(([path, item]) => OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) .sort() } @@ -1602,10 +1885,21 @@ function option(args: string[], name: string) { function matches(options: Options, scenario: Scenario) { if (!options.include) return true - return scenario.name.includes(options.include) || scenario.path.includes(options.include) || scenario.method.includes(options.include.toUpperCase()) + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) } -function printHeader(options: Options, effectRoutes: string[], honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[]) { +function printHeader( + options: Options, + effectRoutes: string[], + honoRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], +) { console.log(`${color.cyan}HttpApi exerciser${color.reset}`) console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) @@ -1618,14 +1912,20 @@ function printHeader(options: Options, effectRoutes: string[], honoRoutes: strin function printResults(results: Result[], missing: string[], extra: Scenario[]) { for (const result of results) { if (result.status === "pass") { - console.log(`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) continue } if (result.status === "skip") { - console.log(`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`) + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) continue } - console.log(`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) console.log(`${color.red}${indent(result.message)}${color.reset}`) } if (missing.length > 0) { @@ -1634,7 +1934,8 @@ function printResults(results: Result[], missing: string[], extra: Scenario[]) { } if (extra.length > 0) { console.log("\nExtra scenarios") - for (const scenario of extra) console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) } console.log( `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, @@ -1661,7 +1962,11 @@ function stable(value: unknown): string { function sort(value: unknown): unknown { if (Array.isArray(value)) return value.map(sort) if (!value || typeof value !== "object") return value - return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sort(item)])) + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) } function array(value: unknown): asserts value is unknown[] { diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 2fbf5ca11..e454fa7e4 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -61,7 +61,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } instance.only = ( @@ -71,7 +75,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test.only( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } instance.skip = ( @@ -81,7 +89,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test.skip( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } return { effect, live, instance } diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 461fb88f2..9e577ec3c 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -54,11 +54,36 @@ const waitForPending = (count: number) => return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) }) -it.instance("ask - remains pending until answered", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ +it.instance( + "ask - remains pending until answered", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance( + "ask - adds to pending list", + () => + Effect.gen(function* () { + const questions = [ { question: "What would you like to do?", header: "Action", @@ -67,81 +92,29 @@ it.instance("ask - remains pending until answered", () => { label: "Option 2", description: "Second option" }, ], }, - ], - }).pipe(Effect.forkScoped) + ] - expect(yield* waitForPending(1)).toHaveLength(1) - yield* rejectAll - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - }), - { git: true }, -) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) -it.instance("ask - adds to pending list", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) - expect(pending[0].questions).toEqual(questions) - yield* rejectAll - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - }), + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + expect(pending[0].questions).toEqual(questions) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), { git: true }, ) // reply tests -it.instance("reply - resolves the pending ask with answers", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(1) - const requestID = pending[0].id - - yield* replyEffect({ - requestID, - answers: [["Option 1"]], - }) - - expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) - }), - { git: true }, -) - -it.instance("reply - removes from pending list", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ +it.instance( + "reply - resolves the pending ask with answers", + () => + Effect.gen(function* () { + const questions = [ { question: "What would you like to do?", header: "Action", @@ -150,170 +123,219 @@ it.instance("reply - removes from pending list", () => { label: "Option 2", description: "Second option" }, ], }, - ], - }).pipe(Effect.forkScoped) + ] - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) - yield* replyEffect({ - requestID: pending[0].id, - answers: [["Option 1"]], - }) - yield* Fiber.join(fiber) + const pending = yield* waitForPending(1) + const requestID = pending[0].id - const after = yield* listEffect - expect(after.length).toBe(0) - }), + yield* replyEffect({ + requestID, + answers: [["Option 1"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), { git: true }, ) -it.instance("reply - does nothing for unknown requestID", () => - replyEffect({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }), +it.instance( + "reply - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Option 1"]], + }) + yield* Fiber.join(fiber) + + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) + +it.instance( + "reply - does nothing for unknown requestID", + () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), { git: true }, ) // reject tests -it.instance("reject - throws RejectedError", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "reject - throws RejectedError", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) - yield* rejectEffect(pending[0].id) + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) - const exit = yield* Fiber.await(fiber) - expect(exit._tag).toBe("Failure") - if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") - }), + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), { git: true }, ) -it.instance("reject - removes from pending list", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "reject - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - yield* rejectEffect(pending[0].id) - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - const after = yield* listEffect - expect(after.length).toBe(0) - }), + const after = yield* listEffect + expect(after.length).toBe(0) + }), { git: true }, ) -it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true }) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { + git: true, +}) // multiple questions tests -it.instance("ask - handles multiple questions", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Build", description: "Build the project" }, - { label: "Test", description: "Run tests" }, - ], - }, - { - question: "Which environment?", - header: "Env", - options: [ - { label: "Dev", description: "Development" }, - { label: "Prod", description: "Production" }, - ], - }, - ] +it.instance( + "ask - handles multiple questions", + () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Build", description: "Build the project" }, + { label: "Test", description: "Run tests" }, + ], + }, + { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, + ], + }, + ] - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) + const pending = yield* waitForPending(1) - yield* replyEffect({ - requestID: pending[0].id, - answers: [["Build"], ["Dev"]], - }) + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Build"], ["Dev"]], + }) - expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) - }), + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), { git: true }, ) // list tests -it.instance("list - returns all pending requests", () => - Effect.gen(function* () { - const fiber1 = yield* askEffect({ - sessionID: SessionID.make("ses_test1"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "list - returns all pending requests", + () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_test1"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(Effect.forkScoped) - const fiber2 = yield* askEffect({ - sessionID: SessionID.make("ses_test2"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }).pipe(Effect.forkScoped) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_test2"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(2) - expect(pending.length).toBe(2) - yield* rejectAll - expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") - expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") - }), + const pending = yield* waitForPending(2) + expect(pending.length).toBe(2) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), { git: true }, ) -it.instance("list - returns empty when no pending", () => - Effect.gen(function* () { - const pending = yield* listEffect - expect(pending.length).toBe(0) - }), +it.instance( + "list - returns empty when no pending", + () => + Effect.gen(function* () { + const pending = yield* listEffect + expect(pending.length).toBe(0) + }), { git: true }, ) From 7d91d3b1ed3d5385d9f2e5a6976d6ac32f98cf18 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:39:20 -0400 Subject: [PATCH 071/174] Normalize instance lifecycle wiring (#25501) --- packages/opencode/src/cli/bootstrap.ts | 3 +- packages/opencode/src/cli/cmd/agent.ts | 5 +- packages/opencode/src/cli/cmd/github.ts | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 13 +- packages/opencode/src/cli/cmd/providers.ts | 4 +- .../src/cli/cmd/tui/plugin/runtime.ts | 6 +- packages/opencode/src/cli/cmd/tui/worker.ts | 4 +- packages/opencode/src/effect/app-runtime.ts | 7 +- .../opencode/src/project/instance-layer.ts | 11 + .../opencode/src/project/instance-runtime.ts | 31 +- .../opencode/src/project/instance-store.ts | 27 +- packages/opencode/src/project/instance.ts | 7 - .../opencode/src/project/with-instance.ts | 10 + .../src/server/routes/instance/config.ts | 37 +- .../server/routes/instance/httpapi/server.ts | 6 +- .../src/server/routes/instance/middleware.ts | 4 +- packages/opencode/src/server/workspace.ts | 4 +- packages/opencode/src/worktree/index.ts | 27 +- .../test/acp/event-subscription.test.ts | 21 +- packages/opencode/test/agent/agent.test.ts | 77 ++-- .../agent/plugin-agent-regression.test.ts | 3 +- .../opencode/test/bus/bus-integration.test.ts | 3 +- packages/opencode/test/bus/bus.test.ts | 3 +- packages/opencode/test/config/config.test.ts | 105 +++--- .../test/control-plane/workspace.test.ts | 3 +- packages/opencode/test/file/fsmonitor.test.ts | 5 +- packages/opencode/test/file/index.test.ts | 109 +++--- .../opencode/test/file/path-traversal.test.ts | 23 +- packages/opencode/test/file/watcher.test.ts | 5 +- packages/opencode/test/fixture/fixture.ts | 3 +- packages/opencode/test/lsp/client.test.ts | 25 +- packages/opencode/test/mcp/headers.test.ts | 7 +- packages/opencode/test/mcp/lifecycle.test.ts | 3 +- .../test/mcp/oauth-auto-connect.test.ts | 9 +- .../opencode/test/mcp/oauth-browser.test.ts | 7 +- .../opencode/test/permission-task.test.ts | 13 +- .../opencode/test/permission/next.test.ts | 3 +- .../instance-bootstrap-regression.test.ts | 3 +- .../opencode/test/project/instance.test.ts | 124 +++---- packages/opencode/test/project/vcs.test.ts | 5 +- .../opencode/test/project/worktree.test.ts | 5 +- .../test/provider/amazon-bedrock.test.ts | 61 ++- .../opencode/test/provider/gitlab-duo.test.ts | 27 +- .../opencode/test/provider/provider.test.ts | 347 +++++++----------- .../test/pty/pty-output-isolation.test.ts | 7 +- .../opencode/test/pty/pty-session.test.ts | 5 +- packages/opencode/test/pty/pty-shell.test.ts | 7 +- .../opencode/test/question/question.test.ts | 3 +- .../test/server/global-session-list.test.ts | 13 +- .../test/server/httpapi-experimental.test.ts | 5 +- .../server/httpapi-instance-context.test.ts | 4 +- .../opencode/test/server/httpapi-mcp.test.ts | 3 +- .../test/server/httpapi-provider.test.ts | 3 +- .../opencode/test/server/httpapi-sdk.test.ts | 3 +- .../test/server/httpapi-session.test.ts | 5 +- .../opencode/test/server/httpapi-sync.test.ts | 3 +- .../test/server/session-actions.test.ts | 3 +- .../opencode/test/server/session-list.test.ts | 41 ++- .../test/server/session-messages.test.ts | 9 +- .../test/server/session-select.test.ts | 7 +- .../opencode/test/session/compaction.test.ts | 39 +- packages/opencode/test/session/llm.test.ts | 17 +- .../test/session/messages-pagination.test.ts | 85 ++--- .../opencode/test/session/session.test.ts | 9 +- .../structured-output-integration.test.ts | 3 +- .../opencode/test/snapshot/snapshot.test.ts | 115 +++--- .../opencode/test/tool/apply_patch.test.ts | 49 +-- packages/opencode/test/tool/edit.test.ts | 37 +- .../test/tool/external-directory.test.ts | 15 +- packages/opencode/test/tool/shell.test.ts | 83 ++--- packages/opencode/test/tool/webfetch.test.ts | 7 +- 71 files changed, 852 insertions(+), 936 deletions(-) create mode 100644 packages/opencode/src/project/instance-layer.ts create mode 100644 packages/opencode/src/project/with-instance.ts diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 81a085d68..fa39ecb17 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,8 +1,9 @@ import { Instance } from "../project/instance" import { InstanceRuntime } from "../project/instance-runtime" +import { WithInstance } from "../project/with-instance" export async function bootstrap(directory: string, cb: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { try { diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a1a440eaa..11a6c7f43 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -10,6 +10,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import { EOL } from "os" import type { Argv } from "yargs" @@ -61,7 +62,7 @@ const AgentCreateCommand = cmd({ describe: "model to use in the format of provider/model", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { const cliPath = args.path @@ -236,7 +237,7 @@ const AgentListCommand = cmd({ command: "list", describe: "list all available agents", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a75dc3163..e707526df 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -20,6 +20,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { ModelsDev } from "@/provider/models" import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { bootstrap } from "../bootstrap" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" @@ -203,7 +204,7 @@ export const GithubInstallCommand = cmd({ command: "install", describe: "install the GitHub agent", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d244549ff..e4d7bd922 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -114,7 +115,7 @@ export const McpListCommand = cmd({ aliases: ["ls"], describe: "list MCP servers and their status", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -186,7 +187,7 @@ export const McpAuthCommand = cmd({ }) .command(McpAuthListCommand), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -318,7 +319,7 @@ export const McpAuthListCommand = cmd({ aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -357,7 +358,7 @@ export const McpLogoutCommand = cmd({ type: "string", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -448,7 +449,7 @@ export const McpAddCommand = cmd({ command: "add", describe: "add an MCP server", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -618,7 +619,7 @@ export const McpDebugCommand = cmd({ demandOption: true, }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index c383e79ce..ca6452618 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -13,7 +13,7 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -303,7 +303,7 @@ export const ProvidersLoginCommand = cmd({ type: "string", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2ef533324..73193d142 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { readPackageThemes, readPluginId, @@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) return true } - const ready = await Instance.provide({ + const ready = await WithInstance.provide({ directory: state.directory, fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), }).catch((error) => { @@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) { } runtime = next try { - await Instance.provide({ + await WithInstance.provide({ directory: cwd, fn: async () => { const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e4fbeb2fb..775f321bb 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,8 +1,8 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "@/project/instance" import { InstanceRuntime } from "@/project/instance-runtime" +import { WithInstance } from "@/project/with-instance" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,7 +77,7 @@ export const rpc = { return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { - await Instance.provide({ + await WithInstance.provide({ directory: input.directory, fn: async () => { await upgrade().catch(() => {}) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 901738646..e8c8025ea 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -40,7 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceRuntime } from "@/project/instance-runtime" +import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -93,17 +93,16 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, - InstanceRuntime.layer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Pty.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, -).pipe(Layer.provideMerge(Observability.layer)) +).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts new file mode 100644 index 000000000..a7e2bfcb7 --- /dev/null +++ b/packages/opencode/src/project/instance-layer.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect" +import { InstanceStore } from "./instance-store" + +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts index a30bf5610..c8803847a 100644 --- a/packages/opencode/src/project/instance-runtime.ts +++ b/packages/opencode/src/project/instance-runtime.ts @@ -1,27 +1,16 @@ -import { makeRuntime } from "@/effect/run-service" +import { AppRuntime } from "@/effect/app-runtime" import { type InstanceContext } from "./instance-context" import { InstanceStore, type LoadInput } from "./instance-store" -import { Effect, Layer } from "effect" -// Production InstanceStore wiring plus a bridge for Promise/ALS callers that -// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself -// low-level while still giving legacy Hono and CLI paths the production -// bootstrap implementation. Delete the Promise helpers once those callers are -// migrated to Effect boundaries that provide InstanceStore directly. -// Keep the bootstrap implementation import lazy: Instance is imported broadly, -// and importing the app bootstrap graph at module load can trigger ESM cycles. -export const layer = Layer.unwrap( - Effect.promise(async () => { - const { InstanceBootstrap } = await import("./bootstrap") - return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) - }), -) +// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service. +// Delete this module once those callers are migrated to Effect boundaries that +// provide InstanceStore directly. -const runtime = makeRuntime(InstanceStore.Service, layer) - -export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input)) -export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) -export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) -export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) +export const disposeInstance = (ctx: InstanceContext) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) +export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 41adcbc7c..4fa1c3dff 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -8,26 +8,18 @@ import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - /** - * Additional setup to run after the default InstanceBootstrap. - * Mainly used by tests for env-var setup or file writes that need the instance ALS context. - */ - init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: ( - input: LoadInput, - effect: Effect.Effect, - ) => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -44,7 +36,7 @@ export const layer: Layer.Layer() - const boot = (input: LoadInput & { directory: string }) => + const boot = (input: LoadInput & { directory: string }) => Effect.gen(function* () { const ctx: InstanceContext = input.project && input.worktree @@ -61,7 +53,6 @@ export const layer: Layer.Layer(directory: string, input: LoadInput, entry: Entry) => + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => Effect.gen(function* () { const exit = yield* Effect.exit(boot({ ...input, directory })) if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) @@ -108,7 +99,7 @@ export const layer: Layer.Layer(input: LoadInput): Effect.Effect => { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -126,7 +117,7 @@ export const layer: Layer.Layer(input: LoadInput): Effect.Effect => { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -180,7 +171,7 @@ export const layer: Layer.Layer(input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 81977affc..a54291cf0 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,15 +1,8 @@ -import { Effect } from "effect" import { context, type InstanceContext } from "./instance-context" -import { InstanceRuntime } from "./instance-runtime" export type { InstanceContext } from "./instance-context" -export type { LoadInput } from "./instance-store" export const Instance = { - async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init }) - return context.provide(ctx, async () => input.fn()) - }, get current() { return context.use() }, diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts new file mode 100644 index 000000000..b5b0e7c07 --- /dev/null +++ b/packages/opencode/src/project/with-instance.ts @@ -0,0 +1,10 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { context } from "./instance-context" +import { InstanceStore } from "./instance-store" + +export async function provide(input: { directory: string; fn: () => R }): Promise { + const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory }))) + return context.provide(ctx, () => input.fn()) +} + +export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 96a7e756d..949734f81 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -6,7 +6,11 @@ import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.config" }) export const ConfigRoutes = lazy(() => new Hono() @@ -52,15 +56,28 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info.zod), - async (c) => - jsonRequest("ConfigRoutes.update", c, function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - const store = yield* InstanceStore.Service - yield* cfg.update(config) - yield* store.dispose(yield* InstanceState.context) - return config - }), + async (c) => { + const result = await runRequest( + "ConfigRoutes.update", + c, + Effect.gen(function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return { config, ctx: yield* InstanceState.context } + }), + ) + const response = c.json(result.config) + void runRequest( + "ConfigRoutes.update.dispose", + c, + InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ), + ) + return response + }, ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ce1b21372..0b4bc252c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -18,7 +18,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { InstanceRuntime } from "@/project/instance-runtime" +import { InstanceLayer } from "@/project/instance-layer" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -152,7 +152,6 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - InstanceRuntime.layer, MCP.defaultLayer, ModelsDev.defaultLayer, Permission.defaultLayer, @@ -179,12 +178,13 @@ export function createRoutes(corsOptions?: CorsOptions) { ToolRegistry.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Bus.layer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) } diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 494459500..23707faf7 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -20,7 +20,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler return WorkspaceContext.provide({ workspaceID, async fn() { - return Instance.provide({ + return WithInstance.provide({ directory, async fn() { return next() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index dbf693e8f..f5f667222 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -6,7 +6,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "@/effect/app-runtime" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" import { Effect } from "effect" @@ -97,7 +97,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => - Instance.provide({ + WithInstance.provide({ directory: target.directory, async fn() { return next() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2e9b6736f..43453b561 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,8 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { InstanceLayer } from "@/project/instance-layer" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -159,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service + | AppFileSystem.Service + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Git.Service + | Project.Service + | InstanceStore.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -169,6 +175,7 @@ export const layer: Layer.Layer< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const gitSvc = yield* Git.Service const project = yield* Project.Service + const store = yield* InstanceStore.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -251,13 +258,10 @@ export const layer: Layer.Layer< return } - const booted = yield* Effect.promise(() => - Instance.provide({ - directory: info.directory, - fn: () => undefined, - }) - .then(() => true) - .catch((error) => { + const booted = yield* store.load({ directory: info.directory }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.sync(() => { const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { @@ -268,6 +272,7 @@ export const layer: Layer.Layer< }) return false }), + ), ) if (!booted) return @@ -579,7 +584,7 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( +export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), @@ -587,4 +592,6 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) + export * as Worktree from "." diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index bce5e9459..9a92fc507 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" type SessionUpdateParams = Parameters[0] @@ -262,7 +263,7 @@ function createFakeAgent() { describe("acp.agent event subscription", () => { test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, updates, stop } = createFakeAgent() @@ -297,7 +298,7 @@ describe("acp.agent event subscription", () => { test("does not emit user_message_chunk for live prompt parts", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -337,7 +338,7 @@ describe("acp.agent event subscription", () => { test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, chunks, stop } = createFakeAgent() @@ -389,7 +390,7 @@ describe("acp.agent event subscription", () => { test("does not create additional event subscriptions on repeated loadSession()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, calls, stop } = createFakeAgent() @@ -411,7 +412,7 @@ describe("acp.agent event subscription", () => { test("permission.asked events are handled and replied", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -450,7 +451,7 @@ describe("acp.agent event subscription", () => { test("permission prompt on session A does not block message updates for session B", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -537,7 +538,7 @@ describe("acp.agent event subscription", () => { test("streams running bash output snapshots and de-dupes identical snapshots", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -571,7 +572,7 @@ describe("acp.agent event subscription", () => { test("emits synthetic pending before first running update for any tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -616,7 +617,7 @@ describe("acp.agent event subscription", () => { test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() @@ -675,7 +676,7 @@ describe("acp.agent event subscription", () => { test("clears bash snapshot marker on pending state", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 44ed0692a..6996e54b4 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" @@ -23,7 +24,7 @@ afterEach(async () => { test("returns default native agents when no config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await load(tmp.path, (svc) => svc.list()) @@ -41,7 +42,7 @@ test("returns default native agents when no config", async () => { test("build agent has correct default properties", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -56,7 +57,7 @@ test("build agent has correct default properties", async () => { test("plan agent denies edits except .opencode/plans/*", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const plan = await load(tmp.path, (svc) => svc.get("plan")) @@ -71,7 +72,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -87,7 +88,7 @@ test("explore agent denies edit and write", async () => { test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -103,7 +104,7 @@ test("explore agent asks for external directories and allows whitelisted externa test("general agent denies todo tools", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const general = await load(tmp.path, (svc) => svc.get("general")) @@ -117,7 +118,7 @@ test("general agent denies todo tools", async () => { test("compaction agent denies all permissions", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const compaction = await load(tmp.path, (svc) => svc.get("compaction")) @@ -143,7 +144,7 @@ test("custom agent from config creates new agent", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) @@ -172,7 +173,7 @@ test("custom agent config overrides native agent properties", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -195,7 +196,7 @@ test("agent disable removes agent from list", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -221,7 +222,7 @@ test("agent permission config merges with defaults", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -242,7 +243,7 @@ test("global permission config applies to all agents", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -261,7 +262,7 @@ test("agent steps/maxSteps config sets steps property", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -280,7 +281,7 @@ test("agent mode can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -297,7 +298,7 @@ test("agent name can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -314,7 +315,7 @@ test("agent prompt can be set from config", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -334,7 +335,7 @@ test("unknown agent properties are placed into options", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -357,7 +358,7 @@ test("agent options merge correctly", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -382,7 +383,7 @@ test("multiple custom agents can be defined", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) @@ -411,7 +412,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) @@ -423,7 +424,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn test("Agent.get returns undefined for non-existent agent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) @@ -434,7 +435,7 @@ test("Agent.get returns undefined for non-existent agent", async () => { test("default permission includes doom_loop and external_directory as ask", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -446,7 +447,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn test("webfetch is allowed by default", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -468,7 +469,7 @@ test("legacy tools config converts to permissions", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -490,7 +491,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -508,7 +509,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -521,7 +522,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally test("global tmp directory children are allowed for external_directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -546,7 +547,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -569,7 +570,7 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -601,7 +602,7 @@ description: Permission skill. process.env.OPENCODE_TEST_HOME = tmp.path try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -617,7 +618,7 @@ description: Permission skill. test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -632,7 +633,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { default_agent: "plan", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -652,7 +653,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -667,7 +668,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { default_agent: "explore", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') @@ -681,7 +682,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = default_agent: "compaction", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') @@ -695,7 +696,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn default_agent: "does_not_exist", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( @@ -713,7 +714,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -732,7 +733,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // build and plan are disabled, no primary-capable agents remain diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 89e8a6640..72e538aa3 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "url" import { AppRuntime } from "../../src/effect/app-runtime" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" afterEach(async () => { @@ -39,7 +40,7 @@ test("plugin-registered agents appear in Agent.list", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 7e2138ea8..3e3d7a3e9 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,12 +3,13 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus integration: acquireRelease subscriber pattern", () => { diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index b24b79b33..876cb1ed7 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { @@ -11,7 +12,7 @@ const TestEvent = { } function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus", () => { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c4cbd788..0a522b085 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -7,6 +7,7 @@ import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" @@ -113,7 +114,7 @@ async function check(map: (dir: string) => string) { $schema: "https://opencode.ai/config.json", snapshot: false, }) - await Instance.provide({ + await WithInstance.provide({ directory: map(tmp.path), fn: async () => { const cfg = await load() @@ -131,7 +132,7 @@ async function check(map: (dir: string) => string) { test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -150,7 +151,7 @@ test("loads JSON config file", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -169,7 +170,7 @@ test("loads shell config field", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -191,7 +192,7 @@ test("updates config and preserves empty shell sentinel", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await save({ shell: "" }) @@ -269,7 +270,7 @@ test("loads formatter boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -287,7 +288,7 @@ test("loads lsp boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -324,7 +325,7 @@ test("ignores legacy tui keys in opencode config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -349,7 +350,7 @@ test("loads JSONC config file", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -377,7 +378,7 @@ test("jsonc overrides json in the same directory", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -400,7 +401,7 @@ test("handles environment variable substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -432,7 +433,7 @@ test("preserves env variables when adding $schema to config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -529,7 +530,7 @@ test("handles file inclusion substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -548,7 +549,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -604,7 +605,7 @@ test("handles agent configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -635,7 +636,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -665,7 +666,7 @@ test("handles command configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -690,7 +691,7 @@ test("migrates autoshare to share field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -717,7 +718,7 @@ test("migrates mode field to agent field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -749,7 +750,7 @@ Test agent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -782,7 +783,7 @@ Ordered permissions`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -820,7 +821,7 @@ Nested agent prompt`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -869,7 +870,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -914,7 +915,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -934,7 +935,7 @@ Nested command template`, test("updates config and writes to file", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } @@ -948,7 +949,7 @@ test("updates config and writes to file", async () => { test("gets config directories", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const dirs = await listDirs() @@ -978,7 +979,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as process.env.OPENCODE_CONFIG_DIR = tmp.extra try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await load() @@ -1013,7 +1014,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { ) try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) @@ -1146,7 +1147,7 @@ Helper subagent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1185,7 +1186,7 @@ test("merges instructions arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1224,7 +1225,7 @@ test("deduplicates duplicate instructions from global and local configs", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1359,7 +1360,7 @@ test("migrates legacy tools config to permissions - allow", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1390,7 +1391,7 @@ test("migrates legacy tools config to permissions - deny", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1420,7 +1421,7 @@ test("migrates legacy write tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1452,7 +1453,7 @@ test("managed settings override user settings", async () => { share: "disabled", }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1480,7 +1481,7 @@ test("managed settings override project settings", async () => { disabled_providers: ["openai"], }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1500,7 +1501,7 @@ test("missing managed settings file is not an error", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1527,7 +1528,7 @@ test("migrates legacy edit tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1556,7 +1557,7 @@ test("migrates legacy patch tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1588,7 +1589,7 @@ test("migrates mixed legacy tools config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1623,7 +1624,7 @@ test("merges legacy tools with existing permission config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1660,7 +1661,7 @@ test("permission config preserves user key order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1743,7 +1744,7 @@ test("project config can override MCP server enabled status", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1799,7 +1800,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1850,7 +1851,7 @@ test("local .opencode config can override MCP from project config", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2139,7 +2140,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2170,7 +2171,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const directories = await listDirs() @@ -2194,7 +2195,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) @@ -2236,7 +2237,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // The relative instruction should be skipped without error @@ -2296,7 +2297,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path - await Instance.provide({ + await WithInstance.provide({ directory: projectTmp.path, fn: async () => { const config = await load() @@ -2331,7 +2332,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2365,7 +2366,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index ddd10f2e0..10a05e3b1 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -14,6 +14,7 @@ import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { SessionID, MessageID, PartID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" @@ -101,7 +102,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return Instance.provide({ + return WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 699e713c2..f345cd085 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -30,7 +31,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await status() @@ -55,7 +56,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await read("tracked.txt") diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index bf5e7a175..cdd2e211c 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,6 +5,7 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "Hello World", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => { test("reads with Filesystem.exists() check", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Non-existent file should return empty content @@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "empty.txt") await fs.writeFile(filepath, "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("empty.txt") @@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "multiline.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("multiline.txt") @@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => { const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await fs.writeFile(filepath, binaryContent) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("image.png") @@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "binary.so") await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("binary.so") @@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.json") await fs.writeFile(filepath, '{"key": "value"}', "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain("application/json") @@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, `test.${ext}`) await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain(mime) @@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => { test("reads .ignore file similarly", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ignorePath = path.join(tmp.path, ".ignore") @@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => { test("handles missing .gitignore gracefully", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => { test("reads untracked files via Filesystem.readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const untrackedPath = path.join(tmp.path, "untracked.txt") @@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "readonly.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") @@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => { test("handles errors in Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") @@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => { const _filepath = path.join(tmp.path, "broken.png") // Don't create the file - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // read() handles missing images gracefully @@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.ts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.ts") @@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.mts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.mts") @@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.sh") await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.sh") @@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "Dockerfile") await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("Dockerfile") @@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "simple text", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.jpg") await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.jpg") @@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.rm(filepath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => { await fs.rm(path.join(tmp.path, "remove.txt")) await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for non-git project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for clean repo", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => { for (let i = 0; i < 512; i++) modified[i] = i % 256 await fs.writeFile(filepath, modified) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") await fs.mkdir(path.join(tmp.path, "build")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list("sub") @@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../outside")).rejects.toThrow("Access denied") @@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir() await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -692,7 +693,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -706,7 +707,7 @@ describe("file/index Filesystem patterns", () => { test("search works before explicit init", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await search({ query: "main", type: "file" }) @@ -718,7 +719,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -738,7 +739,7 @@ describe("file/index Filesystem patterns", () => { test("fuzzy matches file names", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -752,7 +753,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -769,7 +770,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only directories", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -786,7 +787,7 @@ describe("file/index Filesystem patterns", () => { test("respects limit", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -800,7 +801,7 @@ describe("file/index Filesystem patterns", () => { test("query starting with dot prefers hidden files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -815,7 +816,7 @@ describe("file/index Filesystem patterns", () => { test("search refreshes after init when files change", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -839,7 +840,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified content\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("file.txt") @@ -863,7 +864,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(filepath, "after\n", "utf-8") await $`git add .`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("staged.txt") @@ -880,7 +881,7 @@ describe("file/index Filesystem patterns", () => { await $`git add .`.cwd(tmp.path).quiet() await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("clean.txt") @@ -900,7 +901,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: one.path, fn: async () => { await init() @@ -911,7 +912,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: two.path, fn: async () => { await init() @@ -927,7 +928,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -941,7 +942,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 3a5ce2323..5b59929ea 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" @@ -55,7 +56,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") @@ -66,7 +67,7 @@ describe("File.read path traversal protection", () => { test("rejects deeply nested traversal", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( @@ -83,7 +84,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("valid.txt") @@ -97,7 +98,7 @@ describe("File.list path traversal protection", () => { test("rejects ../ traversal attempting to list /etc", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") @@ -112,7 +113,7 @@ describe("File.list path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await list("subdir") @@ -126,7 +127,7 @@ describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) @@ -140,7 +141,7 @@ describe("containsPath", () => { const subdir = path.join(tmp.path, "packages", "lib") await fs.mkdir(subdir, { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib @@ -156,7 +157,7 @@ describe("containsPath", () => { test("returns false for path outside both directory and worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath("/etc/passwd", Instance.current)).toBe(false) @@ -168,7 +169,7 @@ describe("containsPath", () => { test("returns false for path with .. escaping worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) @@ -179,7 +180,7 @@ describe("containsPath", () => { test("handles directory === worktree (running from repo root)", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) @@ -192,7 +193,7 @@ describe("containsPath", () => { test("non-git project does not allow arbitrary paths via worktree='/'", async () => { await using tmp = await tmpdir() // no git: true - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index e183f673f..7e47c5135 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -9,6 +9,7 @@ import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ function withWatcher(directory: string, body: Effect.Effect) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( @@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => { await withWatcher(tmp.path, Effect.void) // Now write a file — no watcher should be listening - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 970365f53..d47620f62 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -25,8 +25,9 @@ const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect. testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { - const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init })) + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) try { + if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) return await Instance.restore(ctx, () => input.fn()) } finally { await runTestInstanceStore((store) => store.dispose(ctx)) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f3c7893d9..7d9f5a715 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" function spawnFakeServer() { @@ -25,7 +26,7 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -48,7 +49,7 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -71,7 +72,7 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -94,7 +95,7 @@ describe("LSPClient interop", () => { test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -121,7 +122,7 @@ describe("LSPClient interop", () => { gamma: true, } - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -150,7 +151,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "first\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -193,7 +194,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -239,7 +240,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -286,7 +287,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -334,7 +335,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -387,7 +388,7 @@ describe("LSPClient interop", () => { await Bun.write(file, "class C {}\n") await Bun.write(related, "class D {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -451,7 +452,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 175717d05..5bc8f803d 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -48,6 +48,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -73,7 +74,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options @@ -112,7 +113,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( test("headers are passed to transports when oauth is explicitly disabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 @@ -150,7 +151,7 @@ test("headers are passed to transports when oauth is explicitly disabled", async test("no requestInit when headers are not provided", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 2ba487f3f..10547c9f0 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -172,6 +172,7 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") // --- Helper --- @@ -193,7 +194,7 @@ function withInstance( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 8b29f6d1e..3cf677421 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,6 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") test("first connect to OAuth server shows needs_auth instead of failed", async () => { @@ -132,7 +133,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await Effect.runPromise( @@ -162,7 +163,7 @@ test("state() generates a new state when none is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -203,7 +204,7 @@ test("state() returns existing state when one is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -252,7 +253,7 @@ test("authenticate() stores a connected client when auth completes without redir }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise( diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 3a6df02a1..20cb90a18 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,6 +106,7 @@ const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -127,7 +128,7 @@ test("BrowserOpenFailed event is published when open() throws", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = true @@ -183,7 +184,7 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false @@ -237,7 +238,7 @@ test("open() is called with the authorization URL", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index d4f9192c7..64b93bb8b 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" +import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" @@ -158,7 +159,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -183,7 +184,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -208,7 +209,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -235,7 +236,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -273,7 +274,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -304,7 +305,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 4d66784d8..1c3d6fc56 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,6 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { disposeAllInstances, @@ -1006,7 +1007,7 @@ it.live("pending permission rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts index bb8d43e01..c01450549 100644 --- a/packages/opencode/test/project/instance-bootstrap-regression.test.ts +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -5,6 +5,7 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -50,7 +51,7 @@ async function bootstrapFixture() { test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { await using tmp = await bootstrapFixture() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => "ok", }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index bc8809af9..655e381b9 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -5,17 +5,23 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) const it = testEffect( Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), ) afterEach(async () => { + bootstrapRun = Effect.void await disposeAllInstances() }) @@ -32,18 +38,16 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init with InstanceRef provided", () => + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service let initializedDirectory: string | undefined - yield* store.load({ - directory: dir, - init: Effect.gen(function* () { - initializedDirectory = (yield* InstanceRef)?.directory - }), + bootstrapRun = Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory }) + yield* store.load({ directory: dir }) expect(initializedDirectory).toBe(dir) expect(() => Instance.current).toThrow() @@ -56,18 +60,11 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let initialized = 0 - const first = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - const second = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), + bootstrapRun = Effect.sync(() => { + initialized++ }) + const first = yield* store.load({ directory: dir }) + const second = yield* store.load({ directory: dir }) expect(second).toBe(first) expect(initialized).toBe(1) @@ -82,27 +79,19 @@ describe("InstanceStore", () => { const release = Promise.withResolvers() let initialized = 0 - const first = yield* store - .load({ - directory: dir, - init: Effect.promise(async () => { - initialized++ - started.resolve() - await release.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }) + const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => started.promise) - const second = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) expect(initialized).toBe(1) release.resolve() @@ -119,27 +108,21 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let attempts = 0 - const failed = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - throw new Error("init failed") - }), - }) - .pipe( - Effect.as(false), - Effect.catchCause(() => Effect.succeed(true)), - ) + bootstrapRun = Effect.sync(() => { + attempts++ + throw new Error("init failed") + }) + const failed = yield* store.load({ directory: dir }).pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) expect(failed).toBe(true) - const ctx = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - }), + bootstrapRun = Effect.sync(() => { + attempts++ }) + const ctx = yield* store.load({ directory: dir }) expect(ctx.directory).toBe(dir) expect(attempts).toBe(2) @@ -173,15 +156,11 @@ describe("InstanceStore", () => { yield* Effect.addFinalizer(() => Effect.sync(off)) const first = yield* store.load({ directory: dir }) - const reload = yield* store - .reload({ - directory: dir, - init: Effect.promise(async () => { - reloading.resolve() - await releaseReload.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + reloading.resolve() + await releaseReload.promise + }) + const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => reloading.promise) const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) @@ -242,12 +221,12 @@ describe("InstanceStore", () => { }), ) - it.live("keeps Instance.provide as the legacy ALS wrapper", () => + it.live("provides legacy Promise callers with instance ALS", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const directory = yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: dir, fn: () => Instance.directory, }), @@ -258,21 +237,4 @@ describe("InstanceStore", () => { }), ) - it.live("does not install legacy ALS around Effect init", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - - const directory = yield* Effect.promise(() => - Instance.provide({ - directory: dir, - init: Effect.sync(() => { - expect(() => Instance.current).toThrow() - }), - fn: () => Instance.directory, - }), - ) - - expect(directory).toBe(dir) - }), - ) }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 0d0e46fe4..6fb0e251d 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -7,6 +7,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { GlobalBus } from "../../src/bus/global" import { Vcs } from "@/project/vcs" @@ -18,7 +19,7 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe // --------------------------------------------------------------------------- async function withVcs(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -36,7 +37,7 @@ async function withVcs(directory: string, body: () => Promise) { } function withVcsOnly(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 60c66981d..a89fda6ca 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" @@ -138,7 +139,7 @@ describe("Worktree", () => { expect(props.branch).toBe(info.branch) yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: info.directory, fn: () => InstanceRuntime.disposeInstance(Instance.current), }), @@ -163,7 +164,7 @@ describe("Worktree", () => { yield* Effect.promise(() => ready) yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: info.directory, fn: () => InstanceRuntime.disposeInstance(Instance.current), }), diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 43b23dafa..c35a03d78 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -5,6 +5,7 @@ import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" import { Global } from "@opencode-ai/core/global" @@ -43,13 +44,11 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -68,13 +67,11 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -123,14 +120,12 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -169,13 +164,11 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -201,12 +194,10 @@ test("Bedrock: includes custom endpoint in options when specified", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( @@ -234,15 +225,13 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -277,12 +266,10 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix @@ -314,12 +301,10 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -350,12 +335,10 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -386,12 +369,10 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 84478a34c..8bb3b9634 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -9,6 +9,7 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" // import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" // import { Global } from "@opencode-ai/core/global" @@ -25,7 +26,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-gitlab-token") @@ -56,7 +57,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -95,7 +96,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -130,7 +131,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -162,7 +163,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") @@ -193,7 +194,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "env-token") @@ -216,7 +217,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -252,7 +253,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -277,7 +278,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -301,7 +302,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -349,7 +350,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -372,7 +373,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -396,7 +397,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 924f42888..cdb9d2057 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -5,6 +5,7 @@ import path from "path" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" @@ -80,12 +81,10 @@ test("provider loaded from env variable", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders @@ -114,7 +113,7 @@ test("provider loaded from config with apiKey option", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -135,12 +134,10 @@ test("disabled_providers excludes provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -159,13 +156,11 @@ test("enabled_providers restricts to only listed providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -189,12 +184,10 @@ test("model whitelist filters models for provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -220,12 +213,10 @@ test("model blacklist excludes specific models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -255,12 +246,10 @@ test("custom model alias via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() @@ -301,7 +290,7 @@ test("custom provider with npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -358,7 +347,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -392,12 +381,10 @@ test("env variable takes precedence, config merges options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "env-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged @@ -418,12 +405,10 @@ test("getModel returns model for valid provider/model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") @@ -445,12 +430,10 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) @@ -467,7 +450,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() @@ -498,12 +481,10 @@ test("defaultModel returns first available model when no config set", async () = ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() @@ -523,12 +504,10 @@ test("defaultModel respects config model setting", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") @@ -565,7 +544,7 @@ test("provider with baseURL from config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -603,7 +582,7 @@ test("model cost defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -638,12 +617,10 @@ test("model options are merged from existing model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") @@ -667,12 +644,10 @@ test("provider removed when all models filtered out", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -690,12 +665,10 @@ test("closest finds model by partial match", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") @@ -715,7 +688,7 @@ test("closest returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await closest(ProviderID.make("nonexistent"), ["model"]) @@ -745,12 +718,10 @@ test("getModel uses realIdByKey for aliased models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -791,7 +762,7 @@ test("provider api field sets model api.url", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -831,7 +802,7 @@ test("explicit baseURL overrides api field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -860,12 +831,10 @@ test("model inherits properties from existing database model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") @@ -888,12 +857,10 @@ test("disabled_providers prevents loading even with env var", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-openai-key") const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -912,13 +879,11 @@ test("enabled_providers with empty array allows no providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) }, @@ -942,12 +907,10 @@ test("whitelist and blacklist can be combined", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -984,7 +947,7 @@ test("model modalities default correctly", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1027,7 +990,7 @@ test("model with custom cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1051,12 +1014,10 @@ test("getSmallModel returns appropriate small model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") @@ -1076,12 +1037,10 @@ test("getSmallModel respects config small_model override", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") @@ -1124,13 +1083,11 @@ test("multiple providers can be configured simultaneously", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() @@ -1169,7 +1126,7 @@ test("provider with custom npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1203,12 +1160,10 @@ test("model alias name defaults to alias key when id differs", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, @@ -1243,12 +1198,10 @@ test("provider with multiple env var options only includes apiKey when single en ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("MULTI_ENV_KEY_1", "test-key") - }).pipe(Effect.asVoid), fn: async () => { + set("MULTI_ENV_KEY_1", "test-key") const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set @@ -1285,12 +1238,10 @@ test("provider with single env var includes apiKey automatically", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("SINGLE_ENV_KEY", "my-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("SINGLE_ENV_KEY", "my-api-key") const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key @@ -1322,12 +1273,10 @@ test("model cost overrides existing cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) @@ -1372,7 +1321,7 @@ test("completely new provider not in database can be configured", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1401,14 +1350,12 @@ test("disabled_providers and enabled_providers interaction", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1446,7 +1393,7 @@ test("model with tool_call false", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1481,7 +1428,7 @@ test("model defaults tool_call to true when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1520,7 +1467,7 @@ test("model headers are preserved", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1559,13 +1506,11 @@ test("provider env fallback - second env var used if first missing", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() @@ -1584,12 +1529,10 @@ test("getModel returns consistent results", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) @@ -1625,7 +1568,7 @@ test("provider name defaults to id when not in database", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1645,12 +1588,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here @@ -1673,12 +1614,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here @@ -1701,7 +1640,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const provider = await getProvider(ProviderID.make("nonexistent")) @@ -1721,12 +1660,10 @@ test("getProvider returns provider info", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") @@ -1745,12 +1682,10 @@ test("closest returns undefined when no partial match found", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, @@ -1768,12 +1703,10 @@ test("closest checks multiple query terms in order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() @@ -1808,7 +1741,7 @@ test("model limit defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1840,12 +1773,10 @@ test("provider options are deeply merged", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1878,12 +1809,10 @@ test("custom model inherits npm package from models.dev provider config", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() @@ -1913,12 +1842,10 @@ test("custom model inherits api.url from models.dev provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENROUTER_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENROUTER_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2046,12 +1973,10 @@ test("model variants are generated for reasoning models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2084,12 +2009,10 @@ test("model variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2127,12 +2050,10 @@ test("model variants can be customized via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2166,12 +2087,10 @@ test("disabled key is stripped from variant config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() @@ -2204,12 +2123,10 @@ test("all variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2242,12 +2159,10 @@ test("variant config merges with generated variants", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2280,12 +2195,10 @@ test("variants filtered in second pass for database models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() @@ -2329,7 +2242,7 @@ test("custom model with variants enabled and disabled", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -2384,12 +2297,10 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") @@ -2429,12 +2340,10 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2455,14 +2364,12 @@ test("cloudflare-ai-gateway loads with env variables", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, @@ -2487,14 +2394,12 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ @@ -2542,7 +2447,7 @@ test("plugin config providers persist after instance dispose", async () => { }, }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise( @@ -2559,7 +2464,7 @@ test("plugin config providers persist after instance dispose", async () => { await disposeAllInstances() - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => list(), }) @@ -2590,13 +2495,11 @@ test("plugin config enabled and disabled providers are honored", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -2616,7 +2519,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2639,7 +2542,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) @@ -2660,7 +2563,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2694,7 +2597,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }), ) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 9ef9741ba..662042b64 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" @@ -10,7 +11,7 @@ describe("pty", () => { test("does not leak output when websocket objects are reused", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -60,7 +61,7 @@ describe("pty", () => { test("does not leak output when Bun recycles websocket objects before re-connect", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -105,7 +106,7 @@ describe("pty", () => { test("treats in-place socket data mutation as the same connection", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d65835..8c5d804b7 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -3,6 +3,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" @@ -27,7 +28,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -68,7 +69,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 7b8b4d67c..00e965d25 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" import { tmpdir } from "../fixture/fixture" @@ -17,7 +18,7 @@ describe("pty shell args", () => { "does not add login args to pwsh", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -47,7 +48,7 @@ describe("pty shell args", () => { "adds login args to bash", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -78,7 +79,7 @@ describe("pty configured shell", () => { await using dir = await tmpdir({ config: { shell: Shell.name(configured) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 9e577ec3c..4e2c8ef9b 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -398,7 +399,7 @@ it.live("pending question rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index a5ab7b8f3..936808951 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import z from "zod" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -28,11 +29,11 @@ describe("session.listGlobal", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) - const firstSession = await Instance.provide({ + const firstSession = await WithInstance.provide({ directory: first.path, fn: async () => svc.create({ title: "first-session" }), }) - const secondSession = await Instance.provide({ + const secondSession = await WithInstance.provide({ directory: second.path, fn: async () => svc.create({ title: "second-session" }), }) @@ -58,12 +59,12 @@ describe("session.listGlobal", () => { test("excludes archived sessions by default", async () => { await using tmp = await tmpdir({ git: true }) - const archived = await Instance.provide({ + const archived = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "archived-session" }), }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), }) @@ -82,12 +83,12 @@ describe("session.listGlobal", () => { test("supports cursor pagination", async () => { await using tmp = await tmpdir({ git: true }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 5f36a3274..8684edf13 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" @@ -126,12 +127,12 @@ describe("experimental HttpApi", () => { test("serves global session list through Hono bridge", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 7a889aea0..410dbe742 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,8 +10,8 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceRuntime } from "../../src/project/instance-runtime" import { Instance } from "../../src/project/instance" +import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -41,7 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceRuntime.layer, + InstanceLayer.layer, Project.defaultLayer, Workspace.defaultLayer, ), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 396d04feb..f442df577 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,6 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -59,7 +60,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) ) yield* Effect.addFinalizer(() => Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 8118aa784..c45a81838 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,6 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -91,7 +92,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 771fb5701..ce774ccfd 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,6 +5,7 @@ import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -226,7 +227,7 @@ function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 02d590f91..70fe2d81b 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -9,6 +9,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -44,7 +45,7 @@ function pathFor(path: string, params: Record) { function createSession(directory: string, input?: Session.CreateInput) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession(Session.Service.use((svc) => svc.create(input))), }), @@ -54,7 +55,7 @@ function createSession(directory: string, input?: Session.CreateInput) { function createTextMessage(directory: string, sessionID: SessionID, text: string) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession( diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index d022c3797..b85658ea1 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" @@ -38,7 +39,7 @@ describe("sync HttpApi", () => { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const info = spyOn(Log.create({ service: "server.sync" }), "info") - const session = await Instance.provide({ + const session = await WithInstance.provide({ directory: tmp.path, fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 43f188e74..1ccc9bc8e 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" @@ -31,7 +32,7 @@ afterEach(async () => { describe("session action routes", () => { test("abort route returns success", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 7d479a73b..20478dde8 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -40,20 +41,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -73,20 +74,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -106,22 +107,22 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "current" }), }) - const deeper = await Instance.provide({ + const deeper = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), fn: async () => svc.create({ title: "deeper" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -146,14 +147,14 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "legacy-current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "legacy-sibling" }), }) @@ -175,7 +176,7 @@ describe("session.list", () => { test("filters root sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root-session" }) @@ -192,7 +193,7 @@ describe("session.list", () => { test("filters by start time", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "new-session" }) @@ -206,7 +207,7 @@ describe("session.list", () => { test("filters by search term", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "unique-search-term-abc" }) @@ -223,7 +224,7 @@ describe("session.list", () => { test("respects limit parameter", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "session-1" }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e70847baf..e3c5e8313 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -76,7 +77,7 @@ describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -105,7 +106,7 @@ describe("session messages endpoint", () => { test("keeps full-history responses when limit is omitted", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -126,7 +127,7 @@ describe("session messages endpoint", () => { test("rejects invalid cursors and missing sessions", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -147,7 +148,7 @@ describe("session messages endpoint", () => { test("does not truncate large legacy limit requests", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index b3230d4b8..13edca145 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -30,7 +31,7 @@ afterEach(async () => { describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -56,7 +57,7 @@ describe("tui.selectSession endpoint", () => { test("should return 404 when session does not exist", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -78,7 +79,7 @@ describe("tui.selectSession endpoint", () => { test("should return 400 when session ID format is invalid", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f3f7cbaef..df83adb8d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,6 +10,7 @@ import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -792,7 +793,7 @@ describe("session.compaction.prune", () => { describe("session.compaction.process", () => { test("throws when parent is not a user message", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -822,7 +823,7 @@ describe("session.compaction.process", () => { test("publishes compacted event on continue", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -872,7 +873,7 @@ describe("session.compaction.process", () => { test("marks summary message as errored on compact result", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -910,7 +911,7 @@ describe("session.compaction.process", () => { test("adds synthetic continue prompt when auto is enabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -951,7 +952,7 @@ describe("session.compaction.process", () => { test("persists tail_start_id for retained recent turns", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -998,7 +999,7 @@ describe("session.compaction.process", () => { test("shrinks retained tail to fit preserve token budget", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1047,7 +1048,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1096,7 +1097,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1218,7 +1219,7 @@ describe("session.compaction.process", () => { test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1261,7 +1262,7 @@ describe("session.compaction.process", () => { test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1309,7 +1310,7 @@ describe("session.compaction.process", () => { test("falls back to overflow guidance when no replayable turn exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1369,7 +1370,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1443,7 +1444,7 @@ describe("session.compaction.process", () => { const ready = defer() await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1545,7 +1546,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1587,7 +1588,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1639,7 +1640,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1707,7 +1708,7 @@ describe("session.compaction.process", () => { stub.push(reply("summary one")) stub.push(reply("summary two")) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1779,7 +1780,7 @@ describe("session.compaction.process", () => { test("ignores previous summaries when sizing the retained tail", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index c648d62be..7b9608483 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,6 +6,7 @@ import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@/provider/models" @@ -338,7 +339,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -425,7 +426,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -515,7 +516,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -629,7 +630,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -745,7 +746,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -864,7 +865,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -982,7 +983,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) @@ -1223,7 +1224,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 17370bbe6..35b67f7a0 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -123,7 +124,7 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai describe("MessageV2.page", () => { test("returns sync result", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -139,7 +140,7 @@ describe("MessageV2.page", () => { }) test("pages backward with opaque cursors", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -167,7 +168,7 @@ describe("MessageV2.page", () => { }) test("returns items in chronological order within a page", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -182,7 +183,7 @@ describe("MessageV2.page", () => { }) test("returns empty items for session with no messages", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -198,7 +199,7 @@ describe("MessageV2.page", () => { }) test("throws NotFoundError for non-existent session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const fake = "non-existent-session" as SessionID @@ -208,7 +209,7 @@ describe("MessageV2.page", () => { }) test("handles exact limit boundary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -225,7 +226,7 @@ describe("MessageV2.page", () => { }) test("limit of 1 returns single newest message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -242,7 +243,7 @@ describe("MessageV2.page", () => { }) test("hydrates multiple parts per message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -266,7 +267,7 @@ describe("MessageV2.page", () => { }) test("accepts cursors from fractional timestamps", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -284,7 +285,7 @@ describe("MessageV2.page", () => { }) test("messages with same timestamp are ordered by id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -304,7 +305,7 @@ describe("MessageV2.page", () => { }) test("does not return messages from other sessions", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -326,7 +327,7 @@ describe("MessageV2.page", () => { }) test("large limit returns all messages without cursor", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -346,7 +347,7 @@ describe("MessageV2.page", () => { describe("MessageV2.stream", () => { test("yields items newest first", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -361,7 +362,7 @@ describe("MessageV2.stream", () => { }) test("yields nothing for empty session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -375,7 +376,7 @@ describe("MessageV2.stream", () => { }) test("yields single message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -391,7 +392,7 @@ describe("MessageV2.stream", () => { }) test("hydrates parts for each yielded message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -409,7 +410,7 @@ describe("MessageV2.stream", () => { }) test("handles sets exceeding internal page size", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -426,7 +427,7 @@ describe("MessageV2.stream", () => { }) test("is a sync generator", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -447,7 +448,7 @@ describe("MessageV2.stream", () => { describe("MessageV2.parts", () => { test("returns parts for a message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -464,7 +465,7 @@ describe("MessageV2.parts", () => { }) test("returns empty array for message with no parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -479,7 +480,7 @@ describe("MessageV2.parts", () => { }) test("returns multiple parts in order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -512,7 +513,7 @@ describe("MessageV2.parts", () => { }) test("returns empty for non-existent message id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { await svc.create({}) @@ -523,7 +524,7 @@ describe("MessageV2.parts", () => { }) test("parts contain sessionID and messageID", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -541,7 +542,7 @@ describe("MessageV2.parts", () => { describe("MessageV2.get", () => { test("returns message with hydrated parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -560,7 +561,7 @@ describe("MessageV2.get", () => { }) test("throws NotFoundError for non-existent message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -575,7 +576,7 @@ describe("MessageV2.get", () => { }) test("scopes by session id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -593,7 +594,7 @@ describe("MessageV2.get", () => { }) test("returns message with multiple parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -616,7 +617,7 @@ describe("MessageV2.get", () => { }) test("returns assistant message with correct role", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -642,7 +643,7 @@ describe("MessageV2.get", () => { }) test("returns message with zero parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -660,7 +661,7 @@ describe("MessageV2.get", () => { describe("MessageV2.filterCompacted", () => { test("returns all messages when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -677,7 +678,7 @@ describe("MessageV2.filterCompacted", () => { }) test("stops at compaction boundary and returns chronological order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -721,7 +722,7 @@ describe("MessageV2.filterCompacted", () => { }) test("does not break on compaction part without matching summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -739,7 +740,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant with error even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -764,7 +765,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant without finish even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -785,7 +786,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains original tail when compaction stores tail_start_id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -841,7 +842,7 @@ describe("MessageV2.filterCompacted", () => { }) test("fork remaps compaction tail_start_id for filterCompacted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -907,7 +908,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains an assistant tail when compaction starts inside a turn", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -971,7 +972,7 @@ describe("MessageV2.filterCompacted", () => { }) test("prefers latest compaction boundary when repeated compactions exist", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1093,7 +1094,7 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { test("page hydration matches get for each message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1112,7 +1113,7 @@ describe("MessageV2 consistency", () => { }) test("parts from get match standalone parts call", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1128,7 +1129,7 @@ describe("MessageV2 consistency", () => { }) test("stream collects same messages as exhaustive page iteration", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("MessageV2 consistency", () => { }) test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 99f20b44d..bb69e459b 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import { Bus } from "../../src/bus" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -34,7 +35,7 @@ function updatePart(part: T) { describe("session.created event", () => { test("should emit session.created event when session is created", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { let eventReceived = false @@ -63,7 +64,7 @@ describe("session.created event", () => { }) test("session.created event should be emitted before session.updated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const events: string[] = [] @@ -95,7 +96,7 @@ describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const info = await create({}) @@ -166,7 +167,7 @@ describe("Session", () => { test("remove works without an instance", async () => { await using tmp = await tmpdir({ git: true }) - const info = await Instance.provide({ + const info = await WithInstance.provide({ directory: tmp.path, fn: () => create({ title: "remove-without-instance" }), }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index bdf95caed..da2ffb793 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -5,6 +5,7 @@ import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" const projectRoot = path.join(__dirname, "../..") @@ -15,7 +16,7 @@ const hasApiKey = !!process.env.ANTHROPIC_API_KEY // Helper to run test within Instance context async function withInstance(fn: () => Promise): Promise { - return Instance.provide({ + return WithInstance.provide({ directory: projectRoot, fn, }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index c3216e1c5..99ddfe72d 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -47,7 +48,7 @@ function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effe test("tracks deleted files correctly", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -62,7 +63,7 @@ test("tracks deleted files correctly", async () => { test("revert should remove new files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -86,7 +87,7 @@ test("revert should remove new files", async () => { test("revert in subdirectory", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -113,7 +114,7 @@ test("revert in subdirectory", async () => { test("multiple file operations", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -145,7 +146,7 @@ test("multiple file operations", async () => { test("empty directory handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -160,7 +161,7 @@ test("empty directory handling", async () => { test("binary file handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -184,7 +185,7 @@ test("binary file handling", async () => { test("symlink handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -199,7 +200,7 @@ test("symlink handling", async () => { test("file under size limit handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -214,7 +215,7 @@ test("file under size limit handling", async () => { test("large added files are skipped", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -231,7 +232,7 @@ test("large added files are skipped", async () => { test("nested directory revert", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -256,7 +257,7 @@ test("nested directory revert", async () => { test("special characters in filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -276,7 +277,7 @@ test("special characters in filenames", async () => { test("revert with empty patches", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should not crash with empty patches @@ -290,7 +291,7 @@ test("revert with empty patches", async () => { test("patch with invalid hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -309,7 +310,7 @@ test("patch with invalid hash", async () => { test("revert non-existent file", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -333,7 +334,7 @@ test("revert non-existent file", async () => { test("unicode filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -373,7 +374,7 @@ test("unicode filenames", async () => { test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const chineseFile = fwd(tmp.path, "文件.txt") @@ -402,7 +403,7 @@ test.skip("unicode filenames modification and restore", async () => { test("unicode filenames in subdirectories", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -428,7 +429,7 @@ test("unicode filenames in subdirectories", async () => { test("very long filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -455,7 +456,7 @@ test("very long filenames", async () => { test("hidden files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -475,7 +476,7 @@ test("hidden files", async () => { test("nested symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -495,7 +496,7 @@ test("nested symlinks", async () => { test("file permissions and ownership changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -516,7 +517,7 @@ test("file permissions and ownership changes", async () => { test("circular symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -547,7 +548,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -576,7 +577,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" test("gitignore changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -600,7 +601,7 @@ test("gitignore changes", async () => { test("files tracked in snapshot but now gitignored are filtered out", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // First, create a file and snapshot it @@ -634,7 +635,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () = test("gitignore updated between track calls filters from diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // a.txt is already committed from bootstrap - track it in snapshot @@ -669,7 +670,7 @@ test("gitignore updated between track calls filters from diff", async () => { test("git info exclude changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -695,7 +696,7 @@ test("git info exclude changes", async () => { test("git info exclude keeps global excludes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const global = `${tmp.path}/global.ignore` @@ -731,7 +732,7 @@ test("git info exclude keeps global excludes", async () => { test("concurrent file operations during patch", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -763,7 +764,7 @@ test("snapshot state isolation between projects", async () => { await using tmp1 = await bootstrap() await using tmp2 = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp1.path, fn: async () => { const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) @@ -773,7 +774,7 @@ test("snapshot state isolation between projects", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp2.path, fn: async () => { const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) @@ -793,14 +794,14 @@ test("patch detects changes in secondary worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -825,7 +826,7 @@ test("revert only removes files in invoking worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() @@ -834,7 +835,7 @@ test("revert only removes files in invoking worktree", async () => { const primaryFile = `${tmp.path}/worktree.txt` await Filesystem.write(primaryFile, "primary content") - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -869,14 +870,14 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -903,7 +904,7 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( test("track with no changes returns same hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -922,7 +923,7 @@ test("track with no changes returns same hash", async () => { test("diff function with various changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -943,7 +944,7 @@ test("diff function with various changes", async () => { test("restore function", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -977,7 +978,7 @@ test("restore function", async () => { test("revert should not delete files that existed but were deleted in snapshot", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1007,7 +1008,7 @@ test("revert should not delete files that existed but were deleted in snapshot", test("revert preserves file that existed in snapshot when deleted then recreated", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/existing.txt`, "original content") @@ -1044,7 +1045,7 @@ test("revert preserves file that existed in snapshot when deleted then recreated test("diffFull sets status based on git change type", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") @@ -1090,7 +1091,7 @@ test("diffFull sets status based on git change type", async () => { test("diffFull with new file additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1115,7 +1116,7 @@ test("diffFull with new file additions", async () => { test("diffFull with a large interleaved mixed diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) @@ -1178,7 +1179,7 @@ test("diffFull with a large interleaved mixed diff", async () => { test("diffFull preserves git diff order across batch boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) @@ -1204,7 +1205,7 @@ test("diffFull preserves git diff order across batch boundaries", async () => { test("diffFull with file modifications", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1230,7 +1231,7 @@ test("diffFull with file modifications", async () => { test("diffFull with file deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1255,7 +1256,7 @@ test("diffFull with file deletions", async () => { test("diffFull with multiple line additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1281,7 +1282,7 @@ test("diffFull with multiple line additions", async () => { test("diffFull with addition and deletion", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1313,7 +1314,7 @@ test("diffFull with addition and deletion", async () => { test("diffFull with multiple additions and deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1355,7 +1356,7 @@ test("diffFull with multiple additions and deletions", async () => { test("diffFull with no changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1372,7 +1373,7 @@ test("diffFull with no changes", async () => { test("diffFull with binary file changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1395,7 +1396,7 @@ test("diffFull with binary file changes", async () => { test("diffFull with whitespace changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") @@ -1419,7 +1420,7 @@ test("diffFull with whitespace changes", async () => { test("revert with overlapping files across patches uses first patch hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Write initial content and snapshot @@ -1453,7 +1454,7 @@ test("revert with overlapping files across patches uses first patch hash", async test("revert preserves patch order when the same hash appears again", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await $`mkdir -p ${tmp.path}/foo`.quiet() @@ -1490,7 +1491,7 @@ test("revert preserves patch order when the same hash appears again", async () = test("revert handles large mixed batches across chunk boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c4cccc6eb..fd24b557b 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises" import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -97,7 +98,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const modifyPath = path.join(fixture.path, "modify.txt") @@ -149,7 +150,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -179,7 +180,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi.txt") @@ -199,7 +200,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const bom = String.fromCharCode(0xfeff) @@ -228,7 +229,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "insert_only.txt") @@ -247,7 +248,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "no_newline.txt") @@ -269,7 +270,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -292,7 +293,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -317,7 +318,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "duplicate.txt") @@ -335,7 +336,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" @@ -351,7 +352,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" @@ -365,7 +366,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const dirPath = path.join(fixture.path, "dir") @@ -382,7 +383,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" @@ -396,7 +397,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "modify.txt") @@ -414,7 +415,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = @@ -432,7 +433,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "tail.txt") @@ -450,7 +451,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "two_chunks.txt") @@ -468,7 +469,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi_ctx.txt") @@ -486,7 +487,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "eof_anchor.txt") @@ -508,7 +509,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `cat <<'EOF' @@ -529,7 +530,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `< { const target = path.join(fixture.path, "trailing_ws.txt") @@ -570,7 +571,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "leading_ws.txt") @@ -590,7 +591,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "unicode.txt") diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 2c381ad04..23ae0e909 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -73,7 +74,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "newfile.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -102,7 +103,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -131,7 +132,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -156,7 +157,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "new.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -191,7 +192,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "existing.txt") await fs.writeFile(filepath, "old content here", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -220,7 +221,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -250,7 +251,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -275,7 +276,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -300,7 +301,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "actual content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -325,7 +326,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -352,7 +353,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -387,7 +388,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -413,7 +414,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -439,7 +440,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -464,7 +465,7 @@ describe("tool.edit", () => { const dirpath = path.join(tmp.path, "adir") await fs.mkdir(dirpath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -489,7 +490,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -558,7 +559,7 @@ describe("tool.edit", () => { }, }) - return await Instance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -702,7 +703,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ea1d340ce..591491817 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Effect } from "effect" import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -38,7 +39,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp", fn: async () => { await assertExternalDirectory(ctx) @@ -51,7 +52,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for paths inside Instance.directory", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) @@ -68,7 +69,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside/file.txt" const expected = glob(path.join(path.dirname(target), "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target) @@ -88,7 +89,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside" const expected = glob(path.join(target, "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target, { kind: "directory" }) @@ -104,7 +105,7 @@ describe("tool.assertExternalDirectory", () => { test("skips prompting when bypass=true", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) @@ -131,7 +132,7 @@ describe("tool.assertExternalDirectory", () => { .replaceAll("\\", "/") .toLowerCase() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, alt) @@ -152,7 +153,7 @@ describe("tool.assertExternalDirectory", () => { const root = path.parse(tmp.path).root const target = path.join(root, "boot.ini") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, target) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index e68d16ba8..9b5c17c22 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -6,6 +6,7 @@ import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" @@ -140,7 +141,7 @@ const mustTruncate = (result: { describe("tool.shell", () => { each("basic", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -163,7 +164,7 @@ describe("tool.shell", () => { await using tmp = await tmpdir({ config: { shell: "fish" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -190,7 +191,7 @@ describe("tool.shell", () => { describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -213,7 +214,7 @@ describe("tool.shell permissions", () => { each("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -239,7 +240,7 @@ describe("tool.shell permissions", () => { test( `parses PowerShell conditionals for permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -269,7 +270,7 @@ describe("tool.shell permissions", () => { `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -297,7 +298,7 @@ describe("tool.shell permissions", () => { } each("asks for external_directory permission for wildcard external paths", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -333,7 +334,7 @@ describe("tool.shell permissions", () => { await Bun.write(path.join(dir, "outside.txt"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -366,7 +367,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -396,7 +397,7 @@ describe("tool.shell permissions", () => { test( `asks for nested PowerShell command permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -428,7 +429,7 @@ describe("tool.shell permissions", () => { `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -458,7 +459,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -489,7 +490,7 @@ describe("tool.shell permissions", () => { `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -519,7 +520,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -553,7 +554,7 @@ describe("tool.shell permissions", () => { const prev = process.env[key] delete process.env[key] try { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -588,7 +589,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -617,7 +618,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -649,7 +650,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -681,7 +682,7 @@ describe("tool.shell permissions", () => { test( `treats Set-Location like cd for permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -712,7 +713,7 @@ describe("tool.shell permissions", () => { test( `does not add nested PowerShell expressions to permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -741,7 +742,7 @@ describe("tool.shell permissions", () => { test( "asks for external_directory permission for cmd file commands [cmd]", withShell(cmdShell, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -766,7 +767,7 @@ describe("tool.shell permissions", () => { each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -791,7 +792,7 @@ describe("tool.shell permissions", () => { each("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -821,7 +822,7 @@ describe("tool.shell permissions", () => { const err = new Error("stop after permission") await using outerTmp = await tmpdir() await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -857,7 +858,7 @@ describe("tool.shell permissions", () => { test( "uses Git Bash /tmp semantics for external workdir", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -889,7 +890,7 @@ describe("tool.shell permissions", () => { test( "uses Git Bash /tmp semantics for external file paths", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -926,7 +927,7 @@ describe("tool.shell permissions", () => { }, }) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -959,7 +960,7 @@ describe("tool.shell permissions", () => { await Bun.write(path.join(dir, "tmpfile"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -981,7 +982,7 @@ describe("tool.shell permissions", () => { each("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1004,7 +1005,7 @@ describe("tool.shell permissions", () => { each("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -1026,7 +1027,7 @@ describe("tool.shell permissions", () => { each("matches redirects in permission pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -1049,7 +1050,7 @@ describe("tool.shell permissions", () => { each("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1065,7 +1066,7 @@ describe("tool.shell permissions", () => { describe("tool.shell abort", () => { test("preserves output when aborted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1099,7 +1100,7 @@ describe("tool.shell abort", () => { }, 15_000) test("terminates command on timeout", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1121,7 +1122,7 @@ describe("tool.shell abort", () => { }, 15_000) test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1142,7 +1143,7 @@ describe("tool.shell abort", () => { }) test("returns non-zero exit code", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1161,7 +1162,7 @@ describe("tool.shell abort", () => { }) test("streams metadata updates progressively", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -1192,7 +1193,7 @@ describe("tool.shell abort", () => { describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1214,7 +1215,7 @@ describe("tool.shell truncation", () => { }) test("truncates output exceeding byte limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1236,7 +1237,7 @@ describe("tool.shell truncation", () => { }) test("does not truncate small output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1256,7 +1257,7 @@ describe("tool.shell truncation", () => { }) test("full output is saved to file when truncated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index dbde4ed5b..6c7f6aba7 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -41,7 +42,7 @@ describe("tool.webfetch", () => { await withFetch( () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) @@ -69,7 +70,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) @@ -89,7 +90,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "text/plain; charset=utf-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) From 80f2b13a55035517860cc85d45b00634b5a4c7cd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:40:21 +0000 Subject: [PATCH 072/174] chore: generate --- .../opencode/src/project/with-instance.ts | 4 +- .../opencode/test/project/instance.test.ts | 1 - packages/sdk/js/src/v2/gen/types.gen.ts | 180 +++--- packages/sdk/openapi.json | 520 +++++++++--------- 4 files changed, 353 insertions(+), 352 deletions(-) diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts index b5b0e7c07..b7b5360c7 100644 --- a/packages/opencode/src/project/with-instance.ts +++ b/packages/opencode/src/project/with-instance.ts @@ -3,7 +3,9 @@ import { context } from "./instance-context" import { InstanceStore } from "./instance-store" export async function provide(input: { directory: string; fn: () => R }): Promise { - const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory }))) + const ctx = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: input.directory })), + ) return context.provide(ctx, () => input.fn()) } diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 655e381b9..99b0f0666 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -236,5 +236,4 @@ describe("InstanceStore", () => { expect(() => Instance.current).toThrow() }), ) - }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index af29de17f..31bd40ab4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,35 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array -} - -export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} - export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -40,6 +11,21 @@ export type EventServerInstanceDisposed = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -201,53 +187,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -463,6 +402,35 @@ export type EventCommandExecuted = { } } +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + time: { + created: number + updated: number + initialized?: number + } + sandboxes: Array +} + +export type EventProjectUpdated = { + type: "project.updated" + properties: Project +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -470,6 +438,38 @@ export type EventVcsBranchUpdated = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1111,8 +1111,9 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventProjectUpdated | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1122,12 +1123,6 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1142,7 +1137,12 @@ export type GlobalEvent = { | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2060,8 +2060,9 @@ export type File = { } export type Event = - | EventProjectUpdated | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2071,12 +2072,6 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2091,7 +2086,12 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 680771e18..208346325 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7592,88 +7592,6 @@ }, "components": { "schemas": { - "Project": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { - "type": "string", - "const": "git" - }, - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "updated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "initialized": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["created", "updated"] - }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["id", "worktree", "time", "sandboxes"] - }, - "Event.project.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "project.updated" - }, - "properties": { - "$ref": "#/components/schemas/Project" - } - }, - "required": ["type", "properties"] - }, "Event.server.instance.disposed": { "type": "object", "properties": { @@ -7693,6 +7611,48 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -8155,144 +8115,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -8793,6 +8615,88 @@ }, "required": ["type", "properties"] }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "const": "git" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "description": "Startup script to run when creating a new workspace (worktree)", + "type": "string" + } + } + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "initialized": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["created", "updated"] + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"] + }, + "Event.project.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "project.updated" + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["type", "properties"] + }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8811,6 +8715,102 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10958,10 +10958,13 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -10990,24 +10993,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11050,9 +11035,24 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,10 +13253,13 @@ "Event": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13285,24 +13288,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13345,9 +13330,24 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From 68b3448b09fd72858d5ca7f01ade0dd29fa87adf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:42:09 -0400 Subject: [PATCH 073/174] refactor(cli): drop redundant explicit Effect.ensuring(store.dispose) (#25503) --- packages/opencode/src/cli/cmd/debug/agent.ts | 4 +- packages/opencode/src/cli/cmd/debug/config.ts | 11 +-- packages/opencode/src/cli/cmd/debug/file.ts | 47 +++---------- packages/opencode/src/cli/cmd/debug/lsp.ts | 43 ++++-------- .../opencode/src/cli/cmd/debug/ripgrep.ts | 66 ++++++++--------- packages/opencode/src/cli/cmd/debug/skill.ts | 13 +--- .../opencode/src/cli/cmd/debug/snapshot.ts | 29 ++------ packages/opencode/src/cli/cmd/export.ts | 7 +- packages/opencode/src/cli/cmd/import.ts | 7 +- packages/opencode/src/cli/cmd/session.ts | 70 ++++++++----------- packages/opencode/src/cli/cmd/stats.ts | 4 +- 11 files changed, 95 insertions(+), 206 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 831ca08b6..1a3f79396 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -11,7 +11,6 @@ import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { effectCmd, fail } from "../../effect-cmd" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import type { InstanceContext } from "@/project/instance" export const AgentCommand = effectCmd({ @@ -35,8 +34,7 @@ export const AgentCommand = effectCmd({ handler: Effect.fn("Cli.debug.agent")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args, ctx).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args, ctx) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index 8102fcfb8..15bd1c1a9 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -2,20 +2,13 @@ import { EOL } from "os" import { Effect } from "effect" import { Config } from "@/config/config" import { effectCmd } from "../../effect-cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.config")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const config = yield* Config.Service.use((cfg) => cfg.get()) - process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const config = yield* Config.Service.use((cfg) => cfg.get()) + process.stdout.write(JSON.stringify(config, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 1e2eb13bb..d9bb252ea 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -4,8 +4,6 @@ import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" const FileSearchCommand = effectCmd({ command: "search ", @@ -17,13 +15,8 @@ const FileSearchCommand = effectCmd({ description: "Search query", }), handler: Effect.fn("Cli.debug.file.search")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) - process.stdout.write(results.join(EOL) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) + process.stdout.write(results.join(EOL) + EOL) }), }) @@ -37,13 +30,8 @@ const FileReadCommand = effectCmd({ description: "File path to read", }), handler: Effect.fn("Cli.debug.file.read")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const content = yield* File.Service.use((svc) => svc.read(args.path)) - process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const content = yield* File.Service.use((svc) => svc.read(args.path)) + process.stdout.write(JSON.stringify(content, null, 2) + EOL) }), }) @@ -52,13 +40,8 @@ const FileStatusCommand = effectCmd({ describe: "show file status information", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.file.status")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const status = yield* File.Service.use((svc) => svc.status()) - process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const status = yield* File.Service.use((svc) => svc.status()) + process.stdout.write(JSON.stringify(status, null, 2) + EOL) }), }) @@ -72,13 +55,8 @@ const FileListCommand = effectCmd({ description: "File path to list", }), handler: Effect.fn("Cli.debug.file.list")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const files = yield* File.Service.use((svc) => svc.list(args.path)) - process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const files = yield* File.Service.use((svc) => svc.list(args.path)) + process.stdout.write(JSON.stringify(files, null, 2) + EOL) }), }) @@ -92,13 +70,8 @@ const FileTreeCommand = effectCmd({ default: process.cwd(), }), handler: Effect.fn("Cli.debug.file.tree")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) - console.log(JSON.stringify(tree, null, 2)) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index b822a98bc..b40b42318 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -4,8 +4,6 @@ import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const LSPCommand = cmd({ command: "lsp", @@ -20,18 +18,13 @@ const DiagnosticsCommand = effectCmd({ describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ) - process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) }), }) @@ -40,14 +33,9 @@ export const SymbolsCommand = effectCmd({ describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - using _ = Log.Default.time("symbols") - const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + using _ = Log.Default.time("symbols") + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), }) @@ -56,13 +44,8 @@ export const DocumentSymbolsCommand = effectCmd({ describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - using _ = Log.Default.time("document-symbols") - const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + using _ = Log.Default.time("document-symbols") + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index f0be70448..ca95c1d55 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -4,7 +4,6 @@ import { Ripgrep } from "../../../file/ripgrep" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const RipgrepCommand = cmd({ command: "rg", @@ -23,13 +22,10 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie( - Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), - ) - process.stdout.write(tree + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const tree = yield* Effect.orDie( + Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), + ) + process.stdout.write(tree + EOL) }), }) @@ -53,22 +49,19 @@ const FilesCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.files")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const rg = yield* Ripgrep.Service - const files = yield* rg - .files({ - cwd: ctx.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - Effect.orDie, - ) - process.stdout.write(files.join(EOL) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, + ) + process.stdout.write(files.join(EOL) + EOL) }), }) @@ -93,19 +86,16 @@ const SearchCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.search")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const results = yield* Effect.orDie( - Ripgrep.Service.use((svc) => - svc.search({ - cwd: ctx.directory, - pattern: args.pattern, - glob: args.glob as string[] | undefined, - limit: args.limit, - }), - ), - ) - process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const results = yield* Effect.orDie( + Ripgrep.Service.use((svc) => + svc.search({ + cwd: ctx.directory, + pattern: args.pattern, + glob: args.glob as string[] | undefined, + limit: args.limit, + }), + ), + ) + process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index e23410a69..3b120da3c 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -2,21 +2,14 @@ import { EOL } from "os" import { Effect } from "effect" import { Skill } from "../../../skill" import { effectCmd } from "../../effect-cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.skill")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const skills = yield* skill.all() - process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const skill = yield* Skill.Service + const skills = yield* skill.all() + process.stdout.write(JSON.stringify(skills, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 1675f175d..e37e63dc4 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -2,8 +2,6 @@ import { Effect } from "effect" import { Snapshot } from "../../../snapshot" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const SnapshotCommand = cmd({ command: "snapshot", @@ -16,13 +14,8 @@ const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", handler: Effect.fn("Cli.debug.snapshot.track")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.track()) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) }), }) @@ -36,13 +29,8 @@ const PatchCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) }), }) @@ -56,12 +44,7 @@ const DiffCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) }), }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 5ff282b54..bf73ce941 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -6,8 +6,6 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" import { Effect } from "effect" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -234,10 +232,7 @@ export const ExportCommand = effectCmd({ type: "boolean", }), handler: Effect.fn("Cli.export")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args) }), }) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 8d1937666..419e81379 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -5,7 +5,6 @@ import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" @@ -88,13 +87,9 @@ export const ImportCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.import")(function* (args) { - // effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant. const ctx = yield* InstanceRef if (!ctx) return yield* Effect.die("InstanceRef not provided") - const store = yield* InstanceStore.Service - // Ensure store.dispose runs disposers and emits server.instance.disposed - // on every exit path: success, early return, typed failure, defect, interrupt. - return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* runImport(args.file, ctx.project.id) }), }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index dbf27ccc6..08c0df929 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -12,8 +12,6 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -59,17 +57,12 @@ export const SessionDeleteCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.session.delete")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const svc = yield* Session.Service - const sessionID = SessionID.make(args.sessionID) - // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. - yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) - yield* svc.remove(sessionID) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const svc = yield* Session.Service + const sessionID = SessionID.make(args.sessionID) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }), }) @@ -90,39 +83,34 @@ export const SessionListCommand = effectCmd({ default: "table", }), handler: Effect.fn("Cli.session.list")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - if (sessions.length === 0) return + if (sessions.length === 0) return - const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) - const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" - if (shouldPaginate) { - yield* Effect.promise(async () => { - const proc = Process.spawn(pagerCmd(), { - stdin: "pipe", - stdout: "inherit", - stderr: "inherit", - }) - - if (!proc.stdin) { - console.log(output) - return - } - - proc.stdin.write(output) - proc.stdin.end() - await proc.exited + if (shouldPaginate) { + yield* Effect.promise(async () => { + const proc = Process.spawn(pagerCmd(), { + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", }) - } else { - console.log(output) - } - }).pipe(Effect.ensuring(store.dispose(ctx))) + + if (!proc.stdin) { + console.log(output) + return + } + + proc.stdin.write(output) + proc.stdin.end() + await proc.exited + }) + } else { + console.log(output) + } }), }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 966eb5f66..8bf7b2345 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { @@ -70,8 +69,7 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args, ctx.project) }), }) From 9293cddb3a79e505e701ee173f98ebd84473b206 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:43:16 +0000 Subject: [PATCH 074/174] chore: generate --- packages/opencode/src/cli/cmd/debug/ripgrep.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index ca95c1d55..8d1cbd2b1 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -22,9 +22,7 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const tree = yield* Effect.orDie( - Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), - ) + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) }), }) From e709dc34fb795dfa35d49d67673baa7b0f56dac8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:43:23 -0400 Subject: [PATCH 075/174] feat: default HTTP API backend to on for dev/beta channels --- packages/core/src/flag/flag.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a3b8133b6..ed52f90e6 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -81,7 +86,14 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], - OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"), + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + OPENCODE_EXPERIMENTAL_HTTPAPI: + truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || + (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), // Evaluated at access time (not module load) because tests, the CLI, and From e98c291866f4b3e48caa3dbeb39386dd884a45bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 21:44:06 -0400 Subject: [PATCH 076/174] feat(cli): add instance: false opt-out to effectCmd (#25507) --- packages/opencode/src/cli/cmd/serve.ts | 19 ++++++----- packages/opencode/src/cli/effect-cmd.ts | 42 +++++++++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 5f3211aa1..a8a7234d9 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,21 +1,24 @@ +import { Effect } from "effect" import { Server } from "../../server/server" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -export const ServeCommand = cmd({ +export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // need for an ambient project InstanceContext at startup. + instance: false, + handler: Effect.fn("Cli.serve")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 6785e0b61..94ad0232c 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -18,6 +18,34 @@ export class CliError extends Schema.TaggedErrorClass()("CliError", { export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** + * Whether the command needs a project InstanceContext. Defaults to true. + * + * `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})` + * so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via + * `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy + * `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin + * init + LSP/File/etc forks) eagerly. + * + * `false`: skip the instance entirely. Saves the InstanceBootstrap work and + * suppresses the `server.instance.disposed` IPC event. The handler runs + * directly under AppRuntime — it can yield any `AppServices` but must not + * yield `InstanceRef` (it'd be undefined, causing a defect). + * + * Use `false` for commands that don't read project state (e.g. `models`, + * `serve`, `web`, `account`, `db`, `upgrade`). + */ + instance?: boolean + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +} + /** * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. @@ -35,15 +63,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's * `Command.make(...)` won't touch any handler bodies. */ -export const effectCmd = (opts: { - command: string | readonly string[] - aliases?: string | readonly string[] - describe: string | false - builder?: (yargs: Argv) => Argv - /** Defaults to process.cwd(). Override for commands that take a directory positional. */ - directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect -}) => +export const effectCmd = (opts: EffectCmdOpts) => cmd<{}, Args>({ command: opts.command, aliases: opts.aliases, @@ -52,6 +72,10 @@ export const effectCmd = (opts: { async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args + if (opts.instance === false) { + await AppRuntime.runPromise(opts.handler(args)) + return + } const directory = opts.directory?.(args) ?? process.cwd() await AppRuntime.runPromise( InstanceStore.Service.use((store) => From 1409a0715cd9f0bd92b9c1b736055791f336324c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 21:59:35 -0400 Subject: [PATCH 077/174] refactor(cli): convert web + account to effectCmd (instance: false) (#25512) --- packages/opencode/src/cli/cmd/account.ts | 47 +++++++++++++----------- packages/opencode/src/cli/cmd/web.ts | 19 ++++++---- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 38c28032c..e0755577b 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { Account } from "@/account/account" import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd } from "../effect-cmd" import * as Prompt from "../effect/prompt" import open from "open" @@ -172,60 +172,65 @@ const openEffect = Effect.fn("open")(function* () { yield* Prompt.outro("Opened " + url) }) -export const LoginCommand = cmd({ +export const LoginCommand = effectCmd({ command: "login ", describe: false, + instance: false, builder: (yargs) => yargs.positional("url", { describe: "server URL", type: "string", demandOption: true, }), - async handler(args) { + handler: Effect.fn("Cli.account.login")(function* (args) { UI.empty() - await AppRuntime.runPromise(loginEffect(args.url)) - }, + yield* Effect.orDie(loginEffect(args.url)) + }), }) -export const LogoutCommand = cmd({ +export const LogoutCommand = effectCmd({ command: "logout [email]", describe: false, + instance: false, builder: (yargs) => yargs.positional("email", { describe: "account email to log out from", type: "string", }), - async handler(args) { + handler: Effect.fn("Cli.account.logout")(function* (args) { UI.empty() - await AppRuntime.runPromise(logoutEffect(args.email)) - }, + yield* Effect.orDie(logoutEffect(args.email)) + }), }) -export const SwitchCommand = cmd({ +export const SwitchCommand = effectCmd({ command: "switch", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.switch")(function* () { UI.empty() - await AppRuntime.runPromise(switchEffect()) - }, + yield* Effect.orDie(switchEffect()) + }), }) -export const OrgsCommand = cmd({ +export const OrgsCommand = effectCmd({ command: "orgs", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.orgs")(function* () { UI.empty() - await AppRuntime.runPromise(orgsEffect()) - }, + yield* Effect.orDie(orgsEffect()) + }), }) -export const OpenCommand = cmd({ +export const OpenCommand = effectCmd({ command: "open", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.open")(function* () { UI.empty() - await AppRuntime.runPromise(openEffect()) - }, + yield* Effect.orDie(openEffect()) + }), }) export const ConsoleCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 19ee38ff5..f20381a01 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,7 @@ +import { Effect } from "effect" import { Server } from "../../server/server" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import open from "open" @@ -28,16 +29,19 @@ function getNetworkIPs() { return results } -export const WebCommand = cmd({ +export const WebCommand = effectCmd({ command: "web", builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // ambient project InstanceContext needed at startup. + instance: false, + handler: Effect.fn("Cli.web")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) UI.empty() @@ -75,7 +79,6 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) From a3bc5d35b0f8f542d4531193b8816bc8b55363e3 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 2 May 2026 22:09:48 -0400 Subject: [PATCH 078/174] Refactor v2 session events as schemas (#24512) --- packages/core/src/flag/flag.ts | 1 + packages/core/src/util/log.ts | 2 + .../migration.sql | 17 + .../snapshot.json | 1481 +++++ .../snapshot.json | 176 +- .../20260501142318_next_venus/migration.sql | 2 + .../20260501142318_next_venus/snapshot.json | 1511 +++++ packages/opencode/src/bus/bus-event.ts | 2 + packages/opencode/src/bus/global.ts | 14 +- packages/opencode/src/bus/index.ts | 25 +- packages/opencode/src/cli/cmd/tui/app.tsx | 45 +- .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 298 + .../tui/feature-plugins/system/session-v2.tsx | 1087 +++ .../src/cli/cmd/tui/plugin/internal.ts | 3 + packages/opencode/src/server/routes/global.ts | 3 + .../src/server/routes/instance/event.ts | 8 +- .../src/server/routes/instance/httpapi/api.ts | 2 + .../server/routes/instance/httpapi/event.ts | 4 +- .../routes/instance/httpapi/groups/v2.ts | 14 + .../instance/httpapi/groups/v2/message.ts | 69 + .../instance/httpapi/groups/v2/session.ts | 140 + .../instance/httpapi/handlers/global.ts | 5 +- .../routes/instance/httpapi/handlers/v2.ts | 6 + .../instance/httpapi/handlers/v2/message.ts | 60 + .../instance/httpapi/handlers/v2/session.ts | 115 + .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/index.ts | 128 +- packages/opencode/src/session/compaction.ts | 24 +- packages/opencode/src/session/processor.ts | 143 +- .../opencode/src/session/projectors-next.ts | 204 + packages/opencode/src/session/projectors.ts | 5 +- packages/opencode/src/session/prompt.ts | 119 +- packages/opencode/src/session/session.sql.ts | 25 +- packages/opencode/src/session/session.ts | 29 + packages/opencode/src/sync/index.ts | 8 +- packages/opencode/src/util/effect-zod.ts | 2 +- packages/opencode/src/v2/event.ts | 53 + packages/opencode/src/v2/schema.ts | 10 + .../opencode/src/v2/session-entry-stepper.ts | 261 - packages/opencode/src/v2/session-entry.ts | 220 - packages/opencode/src/v2/session-event.ts | 687 +- .../src/v2/session-message-updater.ts | 411 ++ packages/opencode/src/v2/session-message.ts | 178 + packages/opencode/src/v2/session-prompt.ts | 36 + packages/opencode/src/v2/session.ts | 302 +- packages/opencode/src/v2/tool-output.ts | 18 + .../test/acp/event-subscription.test.ts | 1 + .../opencode/test/cli/tui/use-event.test.tsx | 2 + packages/opencode/test/preload.ts | 3 +- .../test/server/httpapi-bridge.test.ts | 9 +- .../test/server/httpapi-event.test.ts | 15 +- .../test/server/httpapi-session.test.ts | 43 +- .../opencode/test/session/compaction.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 44 + .../session/session-entry-stepper.test.ts | 916 --- packages/opencode/test/sync/index.test.ts | 2 +- .../test/v2/session-message-updater.test.ts | 203 + packages/sdk/js/script/build.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 3615 +++++----- packages/sdk/js/src/v2/gen/types.gen.ts | 5853 ++++++++++------- specs/v2/session-concepts-gap.md | 131 + 62 files changed, 12801 insertions(+), 6015 deletions(-) create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/migration.sql create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json create mode 100644 packages/opencode/migration/20260501142318_next_venus/migration.sql create mode 100644 packages/opencode/migration/20260501142318_next_venus/snapshot.json create mode 100644 packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts create mode 100644 packages/opencode/src/session/projectors-next.ts create mode 100644 packages/opencode/src/v2/event.ts create mode 100644 packages/opencode/src/v2/schema.ts delete mode 100644 packages/opencode/src/v2/session-entry-stepper.ts delete mode 100644 packages/opencode/src/v2/session-entry.ts create mode 100644 packages/opencode/src/v2/session-message-updater.ts create mode 100644 packages/opencode/src/v2/session-message.ts create mode 100644 packages/opencode/src/v2/session-prompt.ts create mode 100644 packages/opencode/src/v2/tool-output.ts delete mode 100644 packages/opencode/test/session/session-entry-stepper.test.ts create mode 100644 packages/opencode/test/v2/session-message-updater.test.ts create mode 100644 specs/v2/session-concepts-gap.md diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ed52f90e6..0daae5580 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -95,6 +95,7 @@ export const Flag = { truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), + OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index a61c15f7a..e1962aed4 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql new file mode 100644 index 000000000..d5efe5f9e --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_message` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `type` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint +CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint +CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint +CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint +DROP TABLE `session_entry`; \ No newline at end of file diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json new file mode 100644 index 000000000..bb6d06237 --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -0,0 +1,1481 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "61f807f9-6398-4067-be05-804acc2561bc", + "prevIds": [ + "66cbe0d7-def0-451b-b88a-7608513a9b44" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index d79324fed..1f3bc493c 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,7 +2,9 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "prevIds": [ + "61f807f9-6398-4067-be05-804acc2561bc" + ], "ddl": [ { "name": "account_state", @@ -37,7 +39,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +600,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +610,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +620,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +630,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +640,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +650,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -1051,9 +1053,13 @@ "table": "event" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1062,9 +1068,13 @@ "table": "account_state" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1073,9 +1083,13 @@ "table": "workspace" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1084,9 +1098,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1095,9 +1113,13 @@ "table": "part" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1106,20 +1128,28 @@ "table": "permission" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1128,9 +1158,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1139,9 +1173,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1150,9 +1188,13 @@ "table": "session_share" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1161,98 +1203,128 @@ "table": "event" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1322,9 +1394,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1340,9 +1412,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1354,9 +1426,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/opencode/migration/20260501142318_next_venus/migration.sql new file mode 100644 index 000000000..e0ffe7823 --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint +ALTER TABLE `session` ADD `model` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json new file mode 100644 index 000000000..e594de2f0 --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -0,0 +1,1511 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", + "prevIds": [ + "aaa2ebeb-caa4-478d-8365-4fc595d16856" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index cf9fcfbee..3250c166a 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -24,6 +24,7 @@ export function payloads() { .map(([type, def]) => { return z .object({ + id: z.string(), type: z.literal(type), properties: zodObject(def.properties), }) @@ -39,6 +40,7 @@ export function effectPayloads() { .entries() .map(([type, def]) => Schema.Struct({ + id: Schema.String, type: Schema.Literal(type), properties: def.properties, }).annotate({ identifier: `Event.${type}` }), diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index b5392a81b..3cfd45362 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "events" +import { Identifier } from "@/id/id" export type GlobalEvent = { directory?: string @@ -7,6 +8,15 @@ export type GlobalEvent = { payload: any } -export const GlobalBus = new EventEmitter<{ +class GlobalBusEmitter extends EventEmitter<{ event: [GlobalEvent] -}>() +}> { + override emit(eventName: "event", event: GlobalEvent): boolean { + if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { + event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") + } + return super.emit(eventName, event) + } +} + +export const GlobalBus = new GlobalBusEmitter() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 9ee8e6fb0..449694a53 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" const log = Log.create({ service: "bus" }) @@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define( ) type Payload = { + id: string type: D["type"] properties: BusProperties } @@ -28,7 +30,11 @@ type State = { } export interface Interface { - readonly publish: (def: D, properties: BusProperties) => Effect.Effect + readonly publish: ( + def: D, + properties: BusProperties, + options?: { id?: string }, + ) => Effect.Effect readonly subscribe: (def: D) => Stream.Stream> readonly subscribeAll: () => Stream.Stream readonly subscribeCallback: ( @@ -53,6 +59,7 @@ export const layer = Layer.effect( // Publish InstanceDisposed before shutting down so subscribers see it yield* PubSub.publish(wildcard, { type: InstanceDisposed.type, + id: createID(), properties: { directory: ctx.directory }, }) yield* PubSub.shutdown(wildcard) @@ -77,10 +84,10 @@ export const layer = Layer.effect( }) } - function publish(def: D, properties: BusProperties) { + function publish(def: D, properties: BusProperties, options?: { id?: string }) { return Effect.gen(function* () { const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } + const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } log.info("publishing", { type: def.type }) const ps = s.typed.get(def.type) @@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export async function publish(def: D, properties: BusProperties) { - return runPromise((svc) => svc.publish(def, properties)) +export function createID() { + return Identifier.create("evt", "ascending") +} + +export async function publish( + def: D, + properties: BusProperties, + options?: { id?: string }, +) { + return runPromise((svc) => svc.publish(def, properties, options)) } export function subscribe(def: D, callback: (event: Payload) => unknown) { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7117ae7d1..ea742f699 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" +import { SyncProviderV2 } from "@tui/context/sync-v2" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" import { useConnected } from "@tui/component/use-connected" @@ -168,27 +169,29 @@ export function tui(input: { > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 79034a01b..a6ba797f3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -750,9 +750,18 @@ export function Prompt(props: PromptProps) { return false } + const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { - const res = await sdk.client.session.create({ workspace: props.workspaceID }) + const res = await sdk.client.session.create({ + workspace: props.workspaceID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) if (res.error) { console.log("Creating a session failed:", res.error) @@ -792,7 +801,6 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode - const variant = local.model.variant.current() const editorSelection = editorContext() const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx new file mode 100644 index 000000000..f82bb4d96 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -0,0 +1,298 @@ +import { useEvent } from "@tui/context/event" +import type { + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, +} from "@opencode-ai/sdk/v2" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +function activeAssistant(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + if (index < 0) return + const assistant = messages[index] + return assistant?.type === "assistant" ? assistant : undefined +} + +function activeCompaction(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "compaction") + if (index < 0) return + const compaction = messages[index] + return compaction?.type === "compaction" ? compaction : undefined +} + +function activeShell(messages: SessionMessage[], callID: string) { + const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + if (index < 0) return + const shell = messages[index] + return shell?.type === "shell" ? shell : undefined +} + +function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) +} + +function latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") +} + +function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + ) +} + +export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ + name: "SyncV2", + init: () => { + const [store, setStore] = createStore<{ + messages: { + [sessionID: string]: SessionMessage[] + } + }>({ + messages: {}, + }) + + const event = useEvent() + const sdk = useSDK() + + function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + setStore( + "messages", + produce((draft) => { + fn((draft[sessionID] ??= [])) + }), + ) + } + + event.subscribe((event) => { + switch (event.type) { + case "session.next.prompted": { + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timestamp }, + }) + }) + break + } + case "session.next.synthetic": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "synthetic", + sessionID: event.properties.sessionID, + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "shell", + callID: event.properties.callID, + command: event.properties.command, + output: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.ended": + update(event.properties.sessionID, (draft) => { + const match = activeShell(draft, event.properties.callID) + if (!match) return + match.output = event.properties.output + match.time.completed = event.properties.timestamp + }) + break + case "session.next.step.started": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + draft.push({ + id: event.id, + type: "assistant", + agent: event.properties.agent, + model: event.properties.model, + content: [], + snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.step.ended": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = event.properties.finish + currentAssistant.cost = event.properties.cost + currentAssistant.tokens = event.properties.tokens + if (event.properties.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + }) + break + case "session.next.text.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ type: "text", text: "" }) + }) + break + case "session.next.text.delta": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text += event.properties.delta + }) + break + case "session.next.text.ended": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text = event.properties.text + }) + break + case "session.next.tool.input.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "tool", + id: event.properties.callID, + name: event.properties.name, + time: { created: event.properties.timestamp }, + state: { status: "pending", input: "" }, + }) + }) + break + case "session.next.tool.input.delta": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status === "pending") match.state.input += event.properties.delta + }) + break + case "session.next.tool.input.ended": + break + case "session.next.tool.called": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (!match) return + match.time.ran = event.properties.timestamp + match.provider = event.properties.provider + match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + }) + break + case "session.next.tool.progress": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state.structured = event.properties.structured + match.state.content = [...event.properties.content] + }) + break + case "session.next.tool.success": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "completed", + input: match.state.input, + structured: event.properties.structured, + content: [...event.properties.content], + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.tool.error": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "error", + error: event.properties.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.reasoning.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "reasoning", + id: event.properties.reasoningID, + text: "", + }) + }) + break + case "session.next.reasoning.delta": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text += event.properties.delta + }) + break + case "session.next.reasoning.ended": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text = event.properties.text + }) + break + case "session.next.retried": + break + case "session.next.compaction.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "compaction", + reason: event.properties.reason, + summary: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.compaction.delta": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (match) match.summary += event.properties.text + }) + break + case "session.next.compaction.ended": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (!match) return + match.summary = event.properties.text + match.include = event.properties.include + }) + break + } + }) + + const result = { + data: store, + session: { + message: { + async sync(sessionID: string) { + const response = await sdk.client.v2.session.messages({ sessionID }) + setStore("messages", sessionID, reconcile(response.data?.items ?? [])) + }, + fromSession(sessionID: string) { + const messages = store.messages[sessionID] + if (!messages) return [] + return messages + }, + }, + }, + } + + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx new file mode 100644 index 000000000..7270a9c3b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -0,0 +1,1087 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { useSyncV2 } from "@tui/context/sync-v2" +import { SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" +import { useTheme } from "@tui/context/theme" +import { useLocal } from "@tui/context/local" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import type { SyntaxStyle } from "@opentui/core" +import { Locale } from "@/util/locale" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import path from "path" +import stripAnsi from "strip-ansi" +import type { + SessionMessage, + SessionMessageAgentSwitched, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageCompaction, + SessionMessageModelSwitched, + SessionMessageShell, + SessionMessageSynthetic, + SessionMessageUser, + ToolFileContent, + ToolTextContent, +} from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" + +const id = "internal:session-v2-debug" +const route = "session.v2.messages" + +function currentSessionID(api: TuiPluginApi) { + const current = api.route.current + if (current.name !== "session") return + const sessionID = current.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +function View(props: { api: TuiPluginApi; sessionID: string }) { + const sync = useSyncV2() + const dimensions = useTerminalDimensions() + const { theme, syntax, subtleSyntax } = useTheme() + const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) + const renderedMessages = createMemo(() => messages().toReversed()) + const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + + createEffect(() => { + void sync.session.message.sync(props.sessionID) + }) + + useKeyboard((event) => { + if (event.name !== "escape") return + event.preventDefault() + event.stopPropagation() + props.api.route.navigate("session", { sessionID: props.sessionID }) + }) + + return ( + + + + + + + + + + {(message, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + ) +} + +function MissingData(props: { label: string; detail: string }) { + const { theme } = useTheme() + return ( + + + MISSING DATA {props.label} + + {props.detail} + + ) +} + +function UserMessage(props: { message: SessionMessageUser; index: number }) { + const { theme } = useTheme() + const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) + return ( + + + + } + > + {props.message.text} + + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + ) +} + +function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { + const { theme } = useTheme() + return ( + + Synthetic + {props.message.text} + + ) +} + +function ShellMessage(props: { message: SessionMessageShell }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + setExpanded((prev) => !prev) : undefined} + > + + $ {props.message.command} + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + ) +} + +function CompactionMessage(props: { message: SessionMessageCompaction }) { + const { theme } = useTheme() + return ( + + + {props.message.summary} + + + ) +} + +function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { + const { theme } = useTheme() + const local = useLocal() + return ( + + + + Switched agent to + {Locale.titlecase(props.message.agent)} + + + ) +} + +function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { + const { theme } = useTheme() + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + return ( + + + + Switched model to + {model()} + + + ) +} + +function UnknownMessage(props: { message: SessionMessage }) { + return +} + +function AssistantMessage(props: { + message: SessionMessageAssistant + last: boolean + syntax: SyntaxStyle + subtleSyntax: SyntaxStyle +}) { + const { theme } = useTheme() + const local = useLocal() + const duration = createMemo(() => { + if (!props.message.time.completed) return 0 + return props.message.time.completed - props.message.time.created + }) + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) + return ( + <> + + {(part) => ( + + + + + + + + + + + + )} + + + + + + + {props.message.error} + + + + + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + + + + ) +} + +function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { + const { theme } = useTheme() + return ( + + + + + + ) +} + +function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) { + const { theme } = useTheme() + const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) + return ( + + + + + + ) +} + +function AssistantTool(props: { part: SessionMessageAssistantTool }) { + const input = createMemo(() => toolInputRecord(props.part.state.input)) + const toolprops = { + get input() { + return input() + }, + get metadata() { + return props.part.provider?.metadata ?? {} + }, + get output() { + return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) + }, + part: props.part, + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +type ToolProps = { + input: Record + metadata: Record + output?: string + part: SessionMessageAssistantTool +} + +function GenericTool(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => props.output?.trim() ?? "") + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const maxLines = 3 + const overflow = createMemo(() => lines().length > maxLines) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, maxLines), "…"].join("\n") + }) + return ( + + {props.part.name} {input(props.input)} + + } + > + setExpanded((prev) => !prev) : undefined} + > + + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + ) +} + +function InlineTool(props: { + icon: string + complete: unknown + pending: string + spinner?: boolean + children: JSX.Element + part: SessionMessageAssistantTool +}) { + const { theme } = useTheme() + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const denied = createMemo(() => { + const message = error() + if (!message) return false + return ( + message.includes("QuestionRejectedError") || + message.includes("rejected permission") || + message.includes("user dismissed") + ) + }) + return ( + + + + {props.children} + + + + ~ {props.pending}} when={props.complete}> + {props.icon} {props.children} + + + + + + {error()} + + + ) +} + +function BlockTool(props: { + title: string + children: JSX.Element + part?: SessionMessageAssistantTool + onClick?: () => void + spinner?: boolean +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + flexShrink={0} + > + + {props.title} + + } + > + {props.title.replace(/^# /, "")} + + {props.children} + + {error()} + + + ) +} + +function Bash(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) + const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) + const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + + + setExpanded((prev) => !prev) : undefined} + > + + $ {command()} + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + + + {command()} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(count) => ( + <> + ({count()} {count() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => + arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), + ) + return ( + <> + + Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} + {input(props.input, ["filePath"])} + + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(matches) => ( + <> + ({matches()} {matches() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} + + ) +} + +function CodeSearch(props: ToolProps) { + return ( + + Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function WebSearch(props: ToolProps) { + return ( + + Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const content = createMemo(() => stringValue(props.input.content) ?? "") + return ( + + + + + + + + + + + + Write {normalizePath(filePath())} + + + + ) +} + +function Edit(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const diff = createMemo(() => stringValue(props.metadata.diff)) + return ( + + + {(diff) => ( + + + 120 ? "split" : "unified"} + filetype={filetype(filePath())} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + + + )} + + + + Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} + +function ApplyPatch(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) + const fileTitle = (file: Record) => { + const type = stringValue(file.type) + const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" + if (type === "delete") return "# Deleted " + relativePath + if (type === "add") return "# Created " + relativePath + if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath + return "← Patched " + relativePath + } + return ( + + 0}> + + {(file) => ( + + + -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} + + } + > + {(patch) => ( + + 120 ? "split" : "unified"} + filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + )} + + + )} + + + + + Patch + + + + ) +} + +function TodoWrite(props: ToolProps) { + const { theme } = useTheme() + const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) + return ( + + 0 && props.part.state.status === "completed"}> + + + + {(todo) => ( + + {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} + + )} + + + + + + + Updating todos... + + + + ) +} + +function Question(props: ToolProps) { + const { theme } = useTheme() + const questions = createMemo(() => + arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), + ) + const answers = createMemo(() => arrayValue(props.metadata.answers)) + return ( + + 0}> + + + + {(question, index) => ( + + {stringValue(question.question)} + {formatAnswer(answers()[index()])} + + )} + + + + + + + Asked {questions().length} question{questions().length === 1 ? "" : "s"} + + + + ) +} + +function Skill(props: ToolProps) { + return ( + + Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" + + ) +} + +function Task(props: ToolProps) { + const content = createMemo(() => { + const description = stringValue(props.input.description) + if (!description) return pendingInput(props.part) + return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` + }) + return ( + + {content()} + + ) +} + +function Diagnostics(props: { diagnostics: unknown; filePath: string }) { + const { theme } = useTheme() + const errors = createMemo(() => { + if (!isRecord(props.diagnostics)) return [] + const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] + return arrayValue(value) + .flatMap((item) => (isRecord(item) ? [item] : [])) + .filter((diagnostic) => diagnostic.severity === 1) + .slice(0, 3) + }) + return ( + + + + {(diagnostic) => Error {stringValue(diagnostic.message)}} + + + + ) +} + +function toolOutput(content?: Array) { + return (content ?? []) + .map((item) => { + if (item.type === "text") return item.text.trim() + return `[file ${item.name ?? item.uri}]` + }) + .filter(Boolean) + .join("\n") +} + +function toolInputRecord(input: string | Record) { + if (typeof input === "string") return {} + return input +} + +function pendingInput(part: SessionMessageAssistantTool) { + if (part.state.status !== "pending") return "" + return part.state.input.trim() +} + +function toolComplete(part: SessionMessageAssistantTool) { + if (part.state.status === "pending") return pendingInput(part) + return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function numberValue(value: unknown) { + return typeof value === "number" ? value : undefined +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function input(input: Record, omit?: string[]) { + const primitives = Object.entries(input).filter(([key, value]) => { + if (omit?.includes(key)) return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + if (primitives.length === 0) return "" + return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` +} + +function normalizePath(input?: string) { + if (!input) return "" + const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) + const relative = path.relative(process.cwd(), absolute) + if (!relative) return "." + if (!relative.startsWith("..")) return relative + return absolute +} + +function filetype(input?: string) { + if (!input) return "none" + const language = LANGUAGE_EXTENSIONS[path.extname(input)] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function todoIcon(status?: string) { + if (status === "completed") return "✓" + if (status === "in_progress") return "~" + if (status === "cancelled") return "✕" + return "☐" +} + +function formatAnswer(answer: unknown) { + if (!Array.isArray(answer)) return "(no answer)" + if (answer.length === 0) return "(no answer)" + return answer.filter((item): item is string => typeof item === "string").join(", ") +} + +const tui: TuiPlugin = async (api) => { + api.route.register([ + { + name: route, + render(input) { + const sessionID = input.params?.sessionID + if (typeof sessionID !== "string") { + return Missing sessionID + } + return + }, + }, + ]) + + api.command.register(() => [ + { + title: "View v2 session messages", + value: route, + category: "Debug", + suggested: api.route.current.name === "session", + enabled: api.route.current.name === "session", + onSelect() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, + }, + ]) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 856ee0ebb..2b0d85919 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import SessionV2Debug from "../feature-plugins/system/session-v2" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { Flag } from "@opencode-ai/core/flag/flag" export type InternalTuiPlugin = TuiPluginModule & { id: string @@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarFiles, SidebarFooter, PluginManager, + ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), ] diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 4a491d95b..da3614d22 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -6,6 +6,7 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" +import { Bus } from "@/bus" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { Installation } from "@/installation" @@ -26,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.connected", properties: {}, }, @@ -37,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.heartbeat", properties: {}, }, diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 474d92b31..52e9bc196 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -42,6 +42,7 @@ export const EventRoutes = () => q.push( JSON.stringify({ + id: Bus.createID(), type: "server.connected", properties: {}, }), @@ -50,9 +51,10 @@ export const EventRoutes = () => // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { q.push( - JSON.stringify({ - type: "server.heartbeat", - properties: {}, + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, }), ) }, 10_000) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 81ea2394c..1cf1584e3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" +import { V2Api } from "./groups/v2" // SSE event schemas built from the same BusEvent/SyncEvent registries that // the Hono spec uses, so both specs emit identical Event/SyncEvent components. @@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ProviderApi) .addHttpApi(SessionApi) .addHttpApi(SyncApi) + .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 25e810753..a5c328ac0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) { const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts new file mode 100644 index 000000000..05da5b720 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -0,0 +1,14 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { MessageGroup } from "./v2/message" +import { SessionGroup } from "./v2/session" + +export const V2Api = HttpApi.make("v2") + .add(SessionGroup) + .add(MessageGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts new file mode 100644 index 000000000..3b0b2fa5b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -0,0 +1,69 @@ +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const MessageGroup = HttpApiGroup.make("v2.message") + .add( + HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { + params: { sessionID: SessionID }, + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionMessagesQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionMessagesResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get v2 session messages", + description: + "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 messages", + description: "Experimental v2 message routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts new file mode 100644 index 000000000..17ddcaeda --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -0,0 +1,140 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Prompt } from "@/v2/session-prompt" +import { SessionV2 } from "@/v2/session" +import { Schema, SchemaGetter } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const SessionGroup = HttpApiGroup.make("v2.session") + .add( + HttpApiEndpoint.get("sessions", "/api/session", { + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspace: WorkspaceID.pipe(Schema.optional), + roots: Schema.Literals(["true", "false"]) + .pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), + ) + .pipe(Schema.optional), + start: Schema.NumberFromString.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + directory: Schema.optional(Schema.Never), + path: Schema.optional(Schema.Never), + workspace: Schema.optional(Schema.Never), + roots: Schema.optional(Schema.Never), + start: Schema.optional(Schema.Never), + search: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionsQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionV2.Info), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionsResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List v2 sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + prompt: Prompt, + delivery: SessionV2.Delivery.pipe(Schema.optional), + }), + success: SessionMessage.Message, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send v2 message", + description: "Create a v2 session message and queue it for the agent loop.", + }), + ), + ) + .add( + HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact v2 session", + description: "Compact a v2 session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for v2 session", + description: "Wait for a v2 session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { + params: { sessionID: SessionID }, + success: Schema.Array(SessionMessage.Message), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get v2 session context", + description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2", + description: "Experimental v2 routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index f9be57f4f..f80869b64 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { EffectBridge } from "@/effect/bridge" +import { Bus } from "@/bus" import { Installation } from "@/installation" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -43,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts new file mode 100644 index 000000000..55cb53458 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -0,0 +1,6 @@ +import { SessionV2 } from "@/v2/session" +import { Layer } from "effect" +import { messageHandlers } from "./v2/message" +import { sessionHandlers } from "./v2/session" + +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts new file mode 100644 index 000000000..3485d80fd --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -0,0 +1,60 @@ +import { SessionMessage } from "@/v2/session-message" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import * as DateTime from "effect/DateTime" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultMessagesLimit = 50 + +const Cursor = Schema.Struct({ + id: SessionMessage.ID, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), +}) + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +const cursor = { + encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from( + JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers.handle( + "messages", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const messages = yield* session.messages({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit ?? DefaultMessagesLimit, + order, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = messages[0] + const last = messages.at(-1) + return { + items: messages, + cursor: { + previous: first ? cursor.encode(first, order, "previous") : undefined, + next: last ? cursor.encode(last, order, "next") : undefined, + }, + } + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts new file mode 100644 index 000000000..558e34dd1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -0,0 +1,115 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultSessionsLimit = 50 + +const SessionCursor = Schema.Struct({ + id: SessionV2.Info.fields.id, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspaceID: WorkspaceID.pipe(Schema.optional), + roots: Schema.Boolean.pipe(Schema.optional), + start: Schema.Finite.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), +}) +type SessionCursor = typeof SessionCursor.Type + +const decodeCursor = Schema.decodeUnknownSync(SessionCursor) + +const sessionCursor = { + encode( + session: SessionV2.Info, + order: "asc" | "desc", + direction: "previous" | "next", + filters: Pick, + ) { + return Buffer.from( + JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers + .handle( + "sessions", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const filters = decoded ?? { + directory: ctx.query.directory, + path: ctx.query.path, + workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + } + const sessions = yield* session.list({ + limit: ctx.query.limit ?? DefaultSessionsLimit, + order, + directory: filters.directory, + path: filters.path, + workspaceID: filters.workspaceID, + roots: filters.roots, + start: filters.start, + search: filters.search, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + items: sessions, + cursor: { + previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined, + next: last ? sessionCursor.encode(last, order, "next", filters) : undefined, + }, + } + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return yield* session.prompt({ + sessionID: ctx.params.sessionID, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, + }) + }), + ) + .handle( + "compact", + Effect.fn(function* (ctx) { + yield* session.compact(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "wait", + Effect.fn(function* (ctx) { + yield* session.wait(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "context", + Effect.fn(function* (ctx) { + return yield* session.context(ctx.params.sessionID) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 0b4bc252c..e53eca3ef 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -64,6 +64,7 @@ import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" +import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" @@ -115,6 +116,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( providerHandlers, sessionHandlers, syncHandlers, + v2Handlers, tuiHandlers, workspaceHandlers, ]), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index f0da2f3d8..3f9f3f660 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -1,7 +1,8 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" +import { Context, Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" @@ -25,10 +26,135 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" +import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" +import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" +import { ExperimentalPaths } from "./httpapi/groups/experimental" +import { FilePaths } from "./httpapi/groups/file" +import { InstancePaths } from "./httpapi/groups/instance" +import { McpPaths } from "./httpapi/groups/mcp" +import { PtyPaths } from "./httpapi/groups/pty" +import { SessionPaths } from "./httpapi/groups/session" +import { SyncPaths } from "./httpapi/groups/sync" +import { TuiPaths } from "./httpapi/groups/tui" +import { WorkspacePaths } from "./httpapi/groups/workspace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { const app = new Hono() + const handler = ExperimentalHttpApiServer.webHandler().handler + const context = Context.empty() as Context.Context + + app.all("/api/*", (c) => handler(c.req.raw, context)) + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) + app.patch("/config", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + app.post("/project/git/init", (c) => handler(c.req.raw, context)) + app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) + app.get(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) + app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) + app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) + app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + } return app .route("/project", ProjectRoutes()) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2f..067d43da2 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,10 +14,13 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context, Schema } from "effect" +import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" const log = Log.create({ service: "session.compaction" }) @@ -556,7 +559,21 @@ export const layer: Layer.Layer< } if (processor.message.error) return "stop" - if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + if (result === "continue") { + const summary = summaryText( + (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { + info: msg, + parts: [], + }, + ) + EventV2.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + } return result }) @@ -583,6 +600,11 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) + EventV2.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) }) return Service.of({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c5..1a32a656d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,9 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -221,6 +224,12 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -248,6 +257,13 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -260,6 +276,13 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -281,13 +304,34 @@ export const layer: Layer.Layer< case "tool-input-delta": return - case "tool-input-end": + case "tool-input-end": { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) return + } case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -331,11 +375,48 @@ export const layer: Layer.Layer< } case "tool-result": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: value.output.metadata, + content: [ + { + type: "text", + text: value.output.output, + }, + ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Error.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* failToolCall(value.toolCallId, value.error) return } @@ -345,6 +426,20 @@ export const layer: Layer.Layer< case "start-step": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: ctx.model.id, + providerID: ctx.model.providerID, + variant: input.assistantMessage.variant, + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -355,18 +450,30 @@ export const layer: Layer.Layer< return case "finish-step": { + const completedSnapshot = yield* snapshot.track() const usage = Session.getUsage({ model: ctx.model, usage: value.usage, metadata: value.providerMetadata, }) + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), reason: value.finishReason, - snapshot: yield* snapshot.track(), + snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", @@ -404,6 +511,13 @@ export const layer: Layer.Layer< } case "text-start": + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -442,6 +556,14 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -568,13 +690,24 @@ export const layer: Layer.Layer< Effect.retry( SessionRetry.policy({ parse, - set: (info) => - status.set(ctx.sessionID, { + set: (info) => { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + return status.set(ctx.sessionID, { type: "retry", attempt: info.attempt, message: info.message, next: info.next, - }), + }) + }, }), ), Effect.catch(halt), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts new file mode 100644 index 000000000..951e3e874 --- /dev/null +++ b/packages/opencode/src/session/projectors-next.ts @@ -0,0 +1,204 @@ +import { and, desc, eq } from "@/storage/db" +import type { Database } from "@/storage/db" +import { SessionMessage } from "@/v2/session-message" +import { SessionMessageUpdater } from "@/v2/session-message-updater" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" +import { SyncEvent } from "@/sync" +import { SessionMessageTable, SessionTable } from "./session.sql" +import type { SessionID } from "./schema" +import { Schema } from "effect" + +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) +type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]> + +function encodeDateTimes(value: unknown): unknown { + if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value) + if (Array.isArray(value)) return value.map(encodeDateTimes) + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)])) + } + return value +} + +function encodeMessageData(value: unknown): SessionMessageData { + return encodeDateTimes(value) as SessionMessageData +} + +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter { + return { + getCurrentAssistant() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) + }, + getCurrentCompaction() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Compaction => message.type === "compaction") + }, + getCurrentShell(callID) { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + }, + updateAssistant(assistant) { + const { id, type, ...data } = assistant + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateCompaction(compaction) { + const { id, type, ...data } = compaction + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateShell(shell) { + const { id, type, ...data } = shell + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + appendMessage(message) { + const { id, type, ...data } = message + db.insert(SessionMessageTable) + .values([ + { + id, + session_id: sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data: encodeMessageData(data), + }, + ]) + .run() + }, + finish() {}, + } +} + +function update(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event) +} + +export default [ + SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + agent: data.agent, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data }) + }), + SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + model: { + id: data.id, + providerID: data.providerID, + variant: data.variant, + }, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data }) + }), + SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) + }), + SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) + }), + SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) + }), + SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) + }), + SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) + }), + SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) + }), + SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) + }), + SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) + }), + SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) + }), + SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data }) + }), +] diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index a3832ebe6..9819ad810 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" +import nextProjectors from "./projectors-next" const log = Log.create({ service: "session.projector" }) @@ -136,4 +137,6 @@ export default [ log.warn("ignored late part update", { partID: id, messageID, sessionID }) } }), + + ...nextProjectors, ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9f1420388..0590fc382 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -54,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import * as DateTime from "effect/DateTime" +import { eq } from "@/storage/db" +import * as Database from "@/storage/db" +import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -785,6 +792,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } yield* sessions.updateMessage(msg) + const callID = ulid() + const started = Date.now() const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), @@ -794,11 +803,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the callID: ulid(), state: { status: "running", - time: { start: Date.now() }, + time: { start: started }, input: { command: input.command }, }, } yield* sessions.updatePart(part) + EventV2.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -813,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } + const completed = Date.now() + EventV2.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) if (!msg.time.completed) { - msg.time.completed = Date.now() + msg.time.completed = completed yield* sessions.updateMessage(msg) } if (part.state.status === "running") { part.state = { status: "completed", - time: { ...part.state.time, end: Date.now() }, + time: { ...part.state.time, end: completed }, input: part.state.input, title: "", metadata: { output, description: "" }, @@ -934,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + if (current?.agent !== info.agent) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }) + } + if ( + current?.model?.providerID !== info.model.providerID || + current.model.id !== info.model.modelID || + current.model.variant !== info.model.variant + ) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant, + }) + } + yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never @@ -1250,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) + const nextPrompt = parts.reduce( + (result, part) => { + if (part.type === "text") { + if (part.synthetic) result.synthetic.push(part.text) + else result.text.push(part.text) + } + if (part.type === "file") { + result.files.push( + new FileAttachment({ + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? new Source({ + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + }) + : undefined, + }), + ) + } + if (part.type === "agent") { + result.agents.push( + new AgentAttachment({ + name: part.name, + source: part.source + ? new Source({ + start: part.source.start, + end: part.source.end, + text: part.source.value, + }) + : undefined, + }), + ) + } + return result + }, + { + text: [] as string[], + files: [] as FileAttachment[], + agents: [] as AgentAttachment[], + synthetic: [] as string[], + }, + ) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Prompted.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, + }) + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } return { info, parts } }, Effect.scoped) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d6..421fa6869 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionEntry } from "../v2/session-entry" +import type { SessionMessage } from "../v2/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( "session", @@ -34,6 +35,12 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), + agent: text(), + model: text({ mode: "json" }).$type<{ + id: string + providerID: string + variant?: string + }>(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -96,22 +103,22 @@ export const TodoTable = sqliteTable( ], ) -export const SessionEntryTable = sqliteTable( - "session_entry", +export const SessionMessageTable = sqliteTable( + "session_message", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type>(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_entry_session_idx").on(table.session_id), - index("session_entry_session_type_idx").on(table.session_id, table.type), - index("session_entry_time_created_idx").on(table.time_created), + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), ], ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e1d0c527a..fedfa8996 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" @@ -78,6 +79,10 @@ export function fromRow(row: SessionRow): Info { path: row.path ?? undefined, parentID: row.parent_id ?? undefined, title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant } + : undefined, version: row.version, summary, share, @@ -102,6 +107,8 @@ export function toRow(info: Info) { directory: info.directory, path: info.path, title: info.title, + agent: info.agent, + model: info.model, version: info.version, share_url: info.share?.url, summary_additions: info.summary?.additions, @@ -160,6 +167,12 @@ const Revert = Schema.Struct({ diff: optionalOmitUndefined(Schema.String), }) +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -171,6 +184,8 @@ export const Info = Schema.Struct({ summary: optionalOmitUndefined(Summary), share: optionalOmitUndefined(Share), title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), version: Schema.String, time: Time, permission: optionalOmitUndefined(Permission.Ruleset), @@ -201,6 +216,8 @@ export const CreateInput = Schema.optional( Schema.Struct({ parentID: Schema.optional(SessionID), title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -272,6 +289,8 @@ const UpdatedInfo = Schema.Struct({ summary: Schema.optional(Schema.NullOr(Summary)), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), @@ -404,6 +423,8 @@ export interface Interface { readonly create: (input?: { parentID?: SessionID title?: string + agent?: string + model?: Schema.Schema.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect @@ -464,6 +485,8 @@ export const layer: Layer.Layer parentID?: SessionID workspaceID?: WorkspaceID directory: string @@ -481,6 +504,8 @@ export const layer: Layer.Layer permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -601,6 +628,8 @@ export const layer: Layer.Layer = EffectSchema.Schem export type SerializedEvent = Event & { type: string } -type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise type PublishContext = { instance?: InstanceContext @@ -255,7 +255,7 @@ export function define< export function project( def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, + func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { return [def, func as ProjectorFunc] } @@ -277,7 +277,7 @@ function process( // idempotent: need to ignore any events already logged Database.transaction((tx) => { - projector(tx, event.data) + projector(tx, event.data, event) if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { tx.insert(EventSequenceTable) @@ -308,7 +308,7 @@ function process( } const result = convertEvent(def.type, event.data) - const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) + const publish = (data: unknown) => ProjectBus.publish(def, data as Properties, { id: event.id }) if (result instanceof Promise) { void result.then(publish) } else { diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76e..1c88712d7 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts new file mode 100644 index 000000000..fde8d4326 --- /dev/null +++ b/packages/opencode/src/v2/event.ts @@ -0,0 +1,53 @@ +import { Identifier } from "@/id/id" +import { SyncEvent } from "@/sync" +import { withStatics } from "@/util/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Schema from "effect/Schema" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type + +export function define(input: { + type: Type + schema: Fields + aggregate: string + version?: number +}) { + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + type: Schema.Literal(input.type), + data: Schema.Struct(input.schema), + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: input.aggregate, + schema: Payload.fields.data, + }) + + return Object.assign(Payload, { + Sync, + version: input.version, + aggregate: input.aggregate, + }) +} + +export function run( + def: Def, + data: SyncEvent.Event["data"], + options?: { publish?: boolean }, +) { + if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return + SyncEvent.run(def, data, options) +} + +export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/schema.ts b/packages/opencode/src/v2/schema.ts new file mode 100644 index 000000000..44587b838 --- /dev/null +++ b/packages/opencode/src/v2/schema.ts @@ -0,0 +1,10 @@ +import { DateTime, Schema, SchemaGetter } from "effect" + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) + +export * as V2Schema from "./schema" diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts deleted file mode 100644 index 3fe4266c0..000000000 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionEntry } from "./session-entry" - -export type MemoryState = { - entries: SessionEntry.Entry[] - pending: SessionEntry.Entry[] -} - -export interface Adapter { - readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined - readonly updateAssistant: (assistant: SessionEntry.Assistant) => void - readonly appendEntry: (entry: SessionEntry.Entry) => void - readonly appendPending: (entry: SessionEntry.Entry) => void - readonly finish: () => Result -} - -export function memory(state: MemoryState): Adapter { - const activeAssistantIndex = () => - state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - return { - getCurrentAssistant() { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = state.entries[index] - return assistant?.type === "assistant" ? assistant : undefined - }, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = state.entries[index] - if (current?.type !== "assistant") return - state.entries[index] = assistant - }, - appendEntry(entry) { - state.entries.push(entry) - }, - appendPending(entry) { - state.pending.push(entry) - }, - finish() { - return state - }, - } -} - -export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { - const currentAssistant = adapter.getCurrentAssistant() - type DraftAssistant = WritableDraft - type DraftTool = WritableDraft - type DraftText = WritableDraft - type DraftReasoning = WritableDraft - - const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => - assistant?.content.findLast( - (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), - ) - - const latestText = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftText => item.type === "text") - - const latestReasoning = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") - - SessionEvent.Event.match(event, { - prompt: (event) => { - const entry = SessionEntry.User.fromEvent(event) - if (currentAssistant) { - adapter.appendPending(entry) - return - } - adapter.appendEntry(entry) - }, - synthetic: (event) => { - adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) - }, - "step.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - }), - ) - } - adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) - }, - "step.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - draft.cost = event.cost - draft.tokens = event.tokens - }), - ) - } - }, - "text.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "text", - text: "", - }) - }), - ) - } - }, - "text.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "text.ended": () => {}, - "tool.input.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "tool", - callID: event.callID, - name: event.name, - time: { - created: event.timestamp, - }, - state: { - status: "pending", - input: "", - }, - }) - }), - ) - } - }, - "tool.input.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match && match.state.status === "pending") match.state.input += event.delta - }), - ) - } - }, - "tool.input.ended": () => {}, - "tool.called": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match) { - match.time.ran = event.timestamp - match.state = { - status: "running", - input: event.input, - } - } - }), - ) - } - }, - "tool.success": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: match.state.input, - output: event.output ?? "", - title: event.title, - metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], - } - } - }), - ) - } - }, - "tool.error": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "error", - error: event.error, - input: match.state.input, - metadata: event.metadata ?? {}, - } - } - }), - ) - } - }, - "reasoning.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "reasoning", - text: "", - }) - }), - ) - } - }, - "reasoning.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "reasoning.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text = event.text - }), - ) - } - }, - retried: (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] - }), - ) - } - }, - compacted: (event) => { - adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) - }, - }) - - return adapter.finish() -} - -export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { - return produce(old, (draft) => { - stepWith(memory(draft as MemoryState), event) - }) -} - -export * as SessionEntryStepper from "./session-entry-stepper" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts deleted file mode 100644 index 66576a688..000000000 --- a/packages/opencode/src/v2/session-entry.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Schema } from "effect" -import { NonNegativeInt } from "@/util/schema" -import { SessionEvent } from "./session-event" - -export const ID = SessionEvent.ID -export type ID = Schema.Schema.Type - -const Base = { - id: SessionEvent.ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -} - -export class User extends Schema.Class("Session.Entry.User")({ - ...Base, - text: SessionEvent.Prompt.fields.text, - files: SessionEvent.Prompt.fields.files, - agents: SessionEvent.Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Prompt) { - return new User({ - id: event.id, - type: "user", - metadata: event.metadata, - text: event.text, - files: event.files, - agents: event.agents, - time: { created: event.timestamp }, - }) - } -} - -export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ - ...SessionEvent.Synthetic.fields, - ...Base, - type: Schema.Literal("synthetic"), -}) { - static fromEvent(event: SessionEvent.Synthetic) { - return new Synthetic({ - ...event, - time: { created: event.timestamp }, - }) - } -} - -export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - title: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), -}) {} - -export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - error: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ - type: Schema.Literal("tool"), - callID: Schema.String, - name: Schema.String, - state: ToolState, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - ran: Schema.DateTimeUtc.pipe(Schema.optional), - completed: Schema.DateTimeUtc.pipe(Schema.optional), - pruned: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ - type: Schema.Literal("text"), - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - text: Schema.String, -}) {} - -export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: NonNegativeInt, - error: SessionEvent.RetryError, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Retried) { - return new AssistantRetry({ - attempt: event.attempt, - error: event.error, - time: { - created: event.timestamp, - }, - }) - } -} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Entry.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - content: AssistantContent.pipe(Schema.Array), - retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, - cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, - }), - }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - completed: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) { - static fromEvent(event: SessionEvent.Step.Started) { - return new Assistant({ - id: event.id, - type: "assistant", - time: { - created: event.timestamp, - }, - content: [], - retries: [], - }) - } -} - -export class Compaction extends Schema.Class("Session.Entry.Compaction")({ - ...SessionEvent.Compacted.fields, - type: Schema.Literal("compaction"), - ...Base, -}) { - static fromEvent(event: SessionEvent.Compacted) { - return new Compaction({ - ...event, - type: "compaction", - time: { created: event.timestamp }, - }) - } -} - -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) - -export type Entry = Schema.Schema.Type - -export type Type = Entry["type"] - -/* -export interface Interface { - readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry - readonly fromSession: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SessionEntry") {} - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const decodeEntry = Schema.decodeUnknownSync(Entry) - - const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) - - const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { - return Database.use((db) => - db - .select() - .from(SessionEntryTable) - .where(eq(SessionEntryTable.session_id, sessionID)) - .orderBy(SessionEntryTable.id) - .all() - .map((row) => decode(row)), - ) - }) - - return Service.of({ - decode, - fromSession, - }) - }), -) -*/ - -export * as SessionEntry from "./session-entry" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index aaf71c8dc..3af5932f0 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,128 +1,119 @@ -import { Identifier } from "@/id/id" -import { NonNegativeInt, withStatics } from "@/util/schema" -import * as DateTime from "effect/DateTime" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@/util/schema" +import { EventV2 } from "./event" +import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" +export { FileAttachment } +import { ToolOutput } from "./tool-output" +import { ModelID, ProviderID } from "@/provider/schema" +import { V2Schema } from "./schema" -export namespace SessionEvent { - export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), - ) - export type ID = Schema.Schema.Type - type Stamp = Schema.Schema.Type - type BaseInput = { - id?: ID - metadata?: Record - timestamp?: Stamp - } +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export type Source = Schema.Schema.Type - const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - } +const Base = { + timestamp: V2Schema.DateTimeUtcFromMillis, + sessionID: SessionID, +} - export class Source extends Schema.Class("Session.Event.Source")({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, - }) {} - - export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), - }) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } - } - - export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), - }) {} - - export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - }) {} - - export class Prompt extends Schema.Class("Session.Event.Prompt")({ +export const AgentSwitched = EventV2.define({ + type: "session.next.agent.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("prompt"), - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - }) { - static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) { - return new Prompt({ - id: input.id ?? ID.create(), - type: "prompt", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - files: input.files, - agents: input.agents, - }) - } - } + agent: Schema.String, + }, +}) +export type AgentSwitched = Schema.Schema.Type - export class Synthetic extends Schema.Class("Session.Event.Synthetic")({ +export const ModelSwitched = EventV2.define({ + type: "session.next.model.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("synthetic"), - text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Synthetic({ - id: input.id ?? ID.create(), - type: "synthetic", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), + }, +}) +export type ModelSwitched = Schema.Schema.Type - export namespace Step { - export class Started extends Schema.Class("Session.Event.Step.Started")({ +export const Prompted = EventV2.define({ + type: "session.next.prompted", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + prompt: Prompt, + }, +}) +export type Prompted = Schema.Schema.Type + +export const Synthetic = EventV2.define({ + type: "session.next.synthetic", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, +}) +export type Synthetic = Schema.Schema.Type + +export namespace Shell { + export const Started = EventV2.define({ + type: "session.next.shell.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.started"), + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.shell.ended", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = Schema.Schema.Type +} + +export namespace Step { + export const Started = EventV2.define({ + type: "session.next.step.started", + aggregate: "sessionID", + schema: { + ...Base, + agent: Schema.String, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, variant: Schema.String.pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) { - return new Started({ - id: input.id ?? ID.create(), - type: "step.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - model: input.model, - }) - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Started = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Step.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.step.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.ended"), - reason: Schema.String, + finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ input: NonNegativeInt, @@ -133,177 +124,118 @@ export namespace SessionEvent { write: NonNegativeInt, }), }), - }) { - static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "step.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - reason: input.reason, - cost: input.cost, - tokens: input.tokens, - }) - } - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Text { - export class Started extends Schema.Class("Session.Event.Text.Started")({ +export namespace Text { + export const Started = EventV2.define({ + type: "session.next.text.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "text.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Text.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.text.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "text.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Text.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.text.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "text.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Reasoning { - export class Started extends Schema.Class("Session.Event.Reasoning.Started")({ +export namespace Reasoning { + export const Started = EventV2.define({ + type: "session.next.reasoning.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "reasoning.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + reasoningID: Schema.String, + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Reasoning.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.reasoning.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.delta"), + reasoningID: Schema.String, delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "reasoning.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Reasoning.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.reasoning.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.ended"), + reasoningID: Schema.String, text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "reasoning.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Tool { - export namespace Input { - export class Started extends Schema.Class("Session.Event.Tool.Input.Started")({ +export namespace Tool { + export namespace Input { + export const Started = EventV2.define({ + type: "session.next.tool.input.started", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, name: Schema.String, - type: Schema.Literal("tool.input.started"), - }) { - static create(input: BaseInput & { callID: string; name: string }) { - return new Started({ - id: input.id ?? ID.create(), - type: "tool.input.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - name: input.name, - }) - } - } + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Tool.Input.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.tool.input.delta", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { callID: string; delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "tool.input.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Tool.Input.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.tool.input.ended", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { callID: string; text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "tool.input.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type + } - export class Called extends Schema.Class("Session.Event.Tool.Called")({ + export const Called = EventV2.define({ + type: "session.next.tool.called", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.called"), callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -311,148 +243,155 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - tool: string - input: Record - provider: Called["provider"] - }, - ) { - return new Called({ - id: input.id ?? ID.create(), - type: "tool.called", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - tool: input.tool, - input: input.input, - provider: input.provider, - }) - } - } + }, + }) + export type Called = Schema.Schema.Type - export class Success extends Schema.Class("Session.Event.Tool.Success")({ + export const Progress = EventV2.define({ + type: "session.next.tool.progress", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.success"), callID: Schema.String, - title: Schema.String, - output: Schema.String.pipe(Schema.optional), - attachments: Schema.Array(FileAttachment).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), + }, + }) + export type Progress = Schema.Schema.Type + + export const Success = EventV2.define({ + type: "session.next.tool.success", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - title: string - output?: string - attachments?: FileAttachment[] - provider: Success["provider"] - }, - ) { - return new Success({ - id: input.id ?? ID.create(), - type: "tool.success", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - title: input.title, - output: input.output, - attachments: input.attachments, - provider: input.provider, - }) - } - } + }, + }) + export type Success = Schema.Schema.Type - export class Error extends Schema.Class("Session.Event.Tool.Error")({ + export const Error = EventV2.define({ + type: "session.next.tool.error", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.error"), callID: Schema.String, - error: Schema.String, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) { - return new Error({ - id: input.id ?? ID.create(), - type: "tool.error", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - error: input.error, - provider: input.provider, - }) - } - } - } + }, + }) + export type Error = Schema.Schema.Type +} - export class Retried extends Schema.Class("Session.Event.Retried")({ +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: NonNegativeInt.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export type RetryError = Schema.Schema.Type + +export const Retried = EventV2.define({ + type: "session.next.retried", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("retried"), attempt: NonNegativeInt, error: RetryError, - }) { - static create(input: BaseInput & { attempt: number; error: RetryError }) { - return new Retried({ - id: input.id ?? ID.create(), - type: "retried", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - attempt: input.attempt, - error: input.error, - }) - } - } + }, +}) +export type Retried = Schema.Schema.Type - export class Compacted extends Schema.Class("Session.Event.Compated")({ - ...Base, - type: Schema.Literal("compacted"), - auto: Schema.Boolean, - overflow: Schema.Boolean.pipe(Schema.optional), - }) { - static create(input: BaseInput & { auto: boolean; overflow?: boolean }) { - return new Compacted({ - id: input.id ?? ID.create(), - type: "compacted", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - auto: input.auto, - overflow: input.overflow, - }) - } - } - - export const Event = Schema.Union( - [ - Prompt, - Synthetic, - Step.Started, - Step.Ended, - Text.Started, - Text.Delta, - Text.Ended, - Tool.Input.Started, - Tool.Input.Delta, - Tool.Input.Ended, - Tool.Called, - Tool.Success, - Tool.Error, - Reasoning.Started, - Reasoning.Delta, - Reasoning.Ended, - Retried, - Compacted, - ], - { - mode: "oneOf", +export namespace Compaction { + export const Started = EventV2.define({ + type: "session.next.compaction.started", + aggregate: "sessionID", + schema: { + ...Base, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, - ).pipe(Schema.toTaggedUnion("type")) - export type Event = Schema.Schema.Type - export type Type = Event["type"] + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.compaction.delta", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, + }) + + export const Ended = EventV2.define({ + type: "session.next.compaction.ended", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + include: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type } + +export const All = Schema.Union( + [ + AgentSwitched, + ModelSwitched, + Prompted, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Text.Started, + Text.Delta, + Text.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Error, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + ], + { + mode: "oneOf", + }, +).pipe(Schema.toTaggedUnion("type")) + +// user +// assistant +// assistant +// assistant +// user +// compaction marker +// -> text +// assistant + +export type Event = Schema.Schema.Type +export type Type = Event["type"] + +export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts new file mode 100644 index 000000000..844f6fe2d --- /dev/null +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -0,0 +1,411 @@ +import { produce, type WritableDraft } from "immer" +import { SessionEvent } from "./session-event" +import { SessionMessage } from "./session-message" + +export type MemoryState = { + messages: SessionMessage.Message[] +} + +export interface Adapter { + readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined + readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined + readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined + readonly updateAssistant: (assistant: SessionMessage.Assistant) => void + readonly updateCompaction: (compaction: SessionMessage.Compaction) => void + readonly updateShell: (shell: SessionMessage.Shell) => void + readonly appendMessage: (message: SessionMessage.Message) => void + readonly finish: () => Result +} + +export function memory(state: MemoryState): Adapter { + const activeAssistantIndex = () => + state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction") + const activeShellIndex = (callID: string) => + state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + + return { + getCurrentAssistant() { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = state.messages[index] + return assistant?.type === "assistant" ? assistant : undefined + }, + getCurrentCompaction() { + const index = activeCompactionIndex() + if (index < 0) return + const compaction = state.messages[index] + return compaction?.type === "compaction" ? compaction : undefined + }, + getCurrentShell(callID) { + const index = activeShellIndex(callID) + if (index < 0) return + const shell = state.messages[index] + return shell?.type === "shell" ? shell : undefined + }, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "assistant") return + state.messages[index] = assistant + }, + updateCompaction(compaction) { + const index = activeCompactionIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "compaction") return + state.messages[index] = compaction + }, + updateShell(shell) { + const index = activeShellIndex(shell.callID) + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "shell") return + state.messages[index] = shell + }, + appendMessage(message) { + state.messages.push(message) + }, + finish() { + return state + }, + } +} + +export function update(adapter: Adapter, event: SessionEvent.Event): Result { + const currentAssistant = adapter.getCurrentAssistant() + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft + + const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => + assistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) + + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") + + const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => + assistant?.content.findLast( + (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID, + ) + + SessionEvent.All.match(event, { + "session.next.agent.switched": (event) => { + adapter.appendMessage( + new SessionMessage.AgentSwitched({ + id: event.id, + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.model.switched": (event) => { + adapter.appendMessage( + new SessionMessage.ModelSwitched({ + id: event.id, + type: "model-switched", + metadata: event.metadata, + model: { + id: event.data.id, + providerID: event.data.providerID, + variant: event.data.variant, + }, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.prompted": (event) => { + adapter.appendMessage( + new SessionMessage.User({ + id: event.id, + type: "user", + metadata: event.metadata, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.synthetic": (event) => { + adapter.appendMessage( + new SessionMessage.Synthetic({ + sessionID: event.data.sessionID, + text: event.data.text, + id: event.id, + type: "synthetic", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.started": (event) => { + adapter.appendMessage( + new SessionMessage.Shell({ + id: event.id, + type: "shell", + metadata: event.metadata, + callID: event.data.callID, + command: event.data.command, + output: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.ended": (event) => { + const currentShell = adapter.getCurrentShell(event.data.callID) + if (currentShell) { + adapter.updateShell( + produce(currentShell, (draft) => { + draft.output = event.data.output + draft.time.completed = event.data.timestamp + }), + ) + } + }, + "session.next.step.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), + ) + } + adapter.appendMessage( + new SessionMessage.Assistant({ + id: event.id, + type: "assistant", + agent: event.data.agent, + model: event.data.model, + time: { created: event.data.timestamp }, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + }), + ) + }, + "session.next.step.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = event.data.finish + draft.cost = event.data.cost + draft.tokens = event.data.tokens + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + }), + ) + } + }, + "session.next.text.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "text", + text: "", + }) + }), + ) + } + }, + "session.next.text.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.text.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.tool.input.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { + created: event.data.timestamp, + }, + state: { + status: "pending", + input: "", + }, + }) + }), + ) + } + }, + "session.next.tool.input.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) + if (match && match.state.status === "pending") match.state.input += event.data.delta + }), + ) + } + }, + "session.next.tool.input.ended": () => {}, + "session.next.tool.called": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match) { + match.provider = event.data.provider + match.time.ran = event.data.timestamp + match.state = { + status: "running", + input: event.data.input, + structured: {}, + content: [], + } + } + }), + ) + } + }, + "session.next.tool.progress": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.state.structured = event.data.structured + match.state.content = [...event.data.content] + } + }), + ) + } + }, + "session.next.tool.success": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "completed", + input: match.state.input, + structured: event.data.structured, + content: [...event.data.content], + } + } + }), + ) + } + }, + "session.next.tool.error": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "error", + error: event.data.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + } + }), + ) + } + }, + "session.next.reasoning.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) + }), + ) + } + }, + "session.next.reasoning.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.reasoning.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.retried": () => {}, + "session.next.compaction.started": (event) => { + adapter.appendMessage( + new SessionMessage.Compaction({ + id: event.id, + type: "compaction", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.compaction.delta": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary += event.data.text + }), + ) + } + }, + "session.next.compaction.ended": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary = event.data.text + draft.include = event.data.include + }), + ) + } + }, + }) + + return adapter.finish() +} + +export * as SessionMessageUpdater from "./session-message-updater" diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts new file mode 100644 index 000000000..8ec99bc20 --- /dev/null +++ b/packages/opencode/src/v2/session-message.ts @@ -0,0 +1,178 @@ +import { Schema } from "effect" +import { Prompt } from "./session-prompt" +import { SessionEvent } from "./session-event" +import { EventV2 } from "./event" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./schema" + +export const ID = EventV2.ID +export type ID = Schema.Schema.Type + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +} + +export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: SessionEvent.AgentSwitched.fields.data.fields.agent, +}) {} + +export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ + ...Base, + type: Schema.Literal("model-switched"), + model: Schema.Struct({ + id: SessionEvent.ModelSwitched.fields.data.fields.id, + providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, + variant: SessionEvent.ModelSwitched.fields.data.fields.variant, + }), +}) {} + +export class User extends Schema.Class("Session.Message.User")({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +}) {} + +export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ + ...Base, + sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, + text: SessionEvent.Synthetic.fields.data.fields.text, + type: Schema.Literal("synthetic"), +}) {} + +export class Shell extends Schema.Class("Session.Message.Shell")({ + ...Base, + type: Schema.Literal("shell"), + callID: SessionEvent.Shell.Started.fields.data.fields.callID, + command: SessionEvent.Shell.Started.fields.data.fields.command, + output: Schema.String, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ + status: Schema.Literal("pending"), + input: Schema.String, +}) {} + +export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: ToolOutput.Structured, + content: ToolOutput.Content.pipe(Schema.Array), +}) {} + +export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, +}) {} + +export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), +}) {} + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = Schema.Schema.Type + +export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), + state: ToolState, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, +}) {} + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = Schema.Schema.Type + +export class Assistant extends Schema.Class("Session.Message.Assistant")({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: SessionEvent.Step.Started.fields.data.fields.model, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(Schema.optional), + end: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + finish: Schema.String.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }).pipe(Schema.optional), + error: Schema.String.pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class Compaction extends Schema.Class("Session.Message.Compaction")({ + type: Schema.Literal("compaction"), + reason: SessionEvent.Compaction.Started.fields.data.fields.reason, + summary: Schema.String, + include: Schema.String.pipe(Schema.optional), + ...Base, +}) {} + +export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) + +export type Message = Schema.Schema.Type + +export type Type = Message["type"] + +export * as SessionMessage from "./session-message" diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts new file mode 100644 index 000000000..86d8e52eb --- /dev/null +++ b/packages/opencode/src/v2/session-prompt.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema" + +export class Source extends Schema.Class("Prompt.Source")({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) + } +} + +export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class("Prompt")({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}) {} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 2bac11f4f..1777b875a 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,69 +1,279 @@ -import { Context, Layer, Schema, Effect } from "effect" -import { SessionEntry } from "./session-entry" -import { Struct } from "effect" -import { Session } from "@/session/session" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { WorkspaceID } from "@/control-plane/schema" +import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" +import * as Database from "@/storage/db" +import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { SessionMessage } from "./session-message" +import type { Prompt } from "./session-prompt" +import { EventV2 } from "./event" +import { ProjectID } from "@/project/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { SessionEvent } from "./session-event" +import { V2Schema } from "./schema" -export const ID = SessionID +export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type -export type ID = Schema.Schema.Type - -export class PromptInput extends Schema.Class("Session.PromptInput")({ - ...Struct.omit(SessionEntry.User.fields, ["time", "type"]), - id: Schema.optionalKey(SessionEntry.ID), - sessionID: ID, -}) {} - -export class CreateInput extends Schema.Class("Session.CreateInput")({ - id: Schema.optionalKey(ID), -}) {} +export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ - id: ID, + id: SessionID, + parentID: SessionID.pipe(Schema.optional), + projectID: ProjectID, + workspaceID: WorkspaceID.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - modelID: Schema.String, + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), }).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), + title: Schema.String, + /* + slug: Schema.String, + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), + */ }) {} export interface Interface { - fromID: (id: ID) => Effect.Effect - create: (input: CreateInput) => Effect.Effect - prompt: (input: PromptInput) => Effect.Effect + readonly list: (input: { + limit?: number + order?: "asc" | "desc" + directory?: string + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + cursor?: { + id: SessionID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: SessionID + prompt: Prompt + delivery?: Delivery + }) => Effect.Effect + readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect + readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect + readonly switchModel: (input: { + sessionID: SessionID + id: ModelID + providerID: ProviderID + variant?: string + }) => Effect.Effect + readonly compact: (sessionID: SessionID) => Effect.Effect + readonly wait: (sessionID: SessionID) => Effect.Effect } -export class Service extends Context.Service()("Session.Service") {} +export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect(Service)( +export const layer = Layer.effect( + Service, Effect.gen(function* () { - const session = yield* Session.Service + const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) - const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) { - throw new Error("Not implemented") - }) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }) - const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) { - throw new Error("Not implemented") - }) + function fromRow(row: typeof SessionTable.$inferSelect): Info { + return { + id: SessionID.make(row.id), + projectID: ProjectID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + } + } - const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) { - const match = yield* session.get(id) - return fromV1(match) - }) + const result: Interface = { + list: Effect.fn("V2Session.list")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const conditions: SQL[] = [] + if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.path) + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.start) conditions.push(gte(SessionTable.time_created, input.start)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or( + gt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + )! + : or( + lt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + )!, + ) + } + const query = Database.Client() + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) - return Service.of({ - create, - prompt, - fromID, - }) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + gt(SessionMessageTable.id, input.cursor.id), + ), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + lt(SessionMessageTable.id, input.cursor.id), + ), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + + const rows = Database.use((db) => { + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return direction === "previous" ? rows.toReversed() : rows + }) + return rows.map((row) => decode(row)) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + const rows = Database.use((db) => { + const compaction = db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + + return db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and( + eq(SessionMessageTable.time_created, compaction.time_created), + gte(SessionMessageTable.id, compaction.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + }) + return rows.map((row) => decode(row)) + }), + prompt: Effect.fn("V2Session.prompt")(function* (_input) { + return {} as any + }), + shell: Effect.fn("V2Session.shell")(function* (_input) {}), + skill: Effect.fn("V2Session.skill")(function* (_input) {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + id: input.id, + providerID: input.providerID, + variant: input.variant, + }) + }), + compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), + wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + } + + return Service.of(result) }), ) -function fromV1(input: Session.Info): Info { - return new Info({ - id: ID.make(input.id), - }) -} +export const defaultLayer = layer export * as SessionV2 from "./session" diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/opencode/src/v2/tool-output.ts new file mode 100644 index 000000000..dee2bb11e --- /dev/null +++ b/packages/opencode/src/v2/tool-output.ts @@ -0,0 +1,18 @@ +export * as ToolOutput from "./tool-output" +import { Schema } from "effect" + +export class TextContent extends Schema.Class("Tool.TextContent")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class FileContent extends Schema.Class("Tool.FileContent")({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), +}) {} + +export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type")) + +export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 9a92fc507..2722757ab 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -59,6 +59,7 @@ function toolEvent( raw: opts.raw, } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 5b0fcad3c..78253361b 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string }) function vcs(branch: string): Event { return { + id: `evt_vcs_${branch}`, type: "vcs.branch.updated", properties: { branch, @@ -34,6 +35,7 @@ function vcs(branch: string): Event { function update(version: string): Event { return { + id: `evt_update_${version}`, type: "installation.update-available", properties: { version, diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 479da7f51..b408f7ef1 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") +process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills @@ -79,7 +80,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"] process.env["OPENCODE_DB"] = ":memory:" // Now safe to import from src/ -const Log = await import("@opencode-ai/core/util/log") +const { Log } = await import("@opencode-ai/core/util/log") const { initProjectors } = await import("../src/server/projectors") void Log.init({ diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 352fb2e2f..b7ffa0ca5 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -226,7 +226,14 @@ describe("HttpApi server", () => { const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ + "GET /api/session", + "GET /api/session/{sessionID}/context", + "GET /api/session/{sessionID}/message", + "POST /api/session/{sessionID}/compact", + "POST /api/session/{sessionID}/prompt", + "POST /api/session/{sessionID}/wait", + ]) }) test("matches generated OpenAPI route parameters", async () => { diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index d7e48240a..940efed9c 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -27,6 +27,14 @@ async function readFirstChunk(response: Response) { return new TextDecoder().decode(result.value) } +async function readFirstEvent(response: Response) { + return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as { + id?: string + type: string + properties: Record + } +} + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() @@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") expect(response.headers.get("x-accel-buffering")).toBe("no") expect(response.headers.get("x-content-type-options")).toBe("nosniff") - expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) test("matches legacy first event frame", async () => { @@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => { const legacy = await app(false).request(EventPaths.event, { headers }) const effect = await app(true).request(EventPaths.event, { headers }) - expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy)) + const legacyEvent = await readFirstEvent(legacy) + const effectEvent = await readFirstEvent(effect) + expect(effectEvent.type).toBe(legacyEvent.type) + expect(effectEvent.properties).toEqual(legacyEvent.properties) }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 70fe2d81b..d96347bed 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -17,7 +17,9 @@ import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { SessionMessage } from "../../src/v2/session-message" +import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" @@ -203,6 +205,45 @@ describe("session HttpApi", () => { { headers }, ), ).toMatchObject({ info: { id: message.info.id } }) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const message = new SessionMessage.Assistant({ + id: SessionMessage.ID.create(), + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: DateTime.makeUnsafe(1) }, + content: [], + }) + Database.use((db) => + db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: parent.id, + type: message.type, + time_created: 1, + data: { + time: { created: 1 }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run(), + ) + }, + }), + ) + + expect( + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items, + ).toMatchObject([{ type: "assistant" }]) }), ), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index df83adb8d..0d02d9918 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -20,6 +20,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SessionV2 } from "../../src/v2/session" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -597,6 +598,15 @@ describe("session.compaction.create", () => { auto: true, overflow: true, }) + + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( + Effect.provide(SessionV2.defaultLayer), + ) + expect(v2.at(-1)).toMatchObject({ + type: "compaction", + reason: "auto", + summary: "", + }) }), ), ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 533056940..a602c0c8d 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -19,6 +19,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" +import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -31,6 +32,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionV2 } from "../../src/v2/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -39,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" @@ -371,6 +374,47 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +it.live("prompt emits v2 prompted and synthetic events", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "hello v2" }, + { + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,bm90ZSBjb250ZW50", + }, + ], + }) + + const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( + Effect.provide(SessionV2.layer), + ) + const row = Database.use((db) => + db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(), + ) + expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) + expect(typeof row?.data.time.created).toBe("number") + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), + expect.objectContaining({ type: "synthetic", text: "note content" }), + ]), + ) + }), + { git: true, config: providerCfg }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts deleted file mode 100644 index defce40c1..000000000 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ /dev/null @@ -1,916 +0,0 @@ -import { describe, expect, test } from "bun:test" -import * as DateTime from "effect/DateTime" -import * as FastCheck from "effect/testing/FastCheck" -import { SessionEntry } from "../../src/v2/session-entry" -import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" -import { SessionEvent } from "../../src/v2/session-event" - -const time = (n: number) => DateTime.makeUnsafe(n) - -const word = FastCheck.string({ minLength: 1, maxLength: 8 }) -const text = FastCheck.string({ maxLength: 16 }) -const texts = FastCheck.array(text, { maxLength: 8 }) -const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 })) -const dict = FastCheck.dictionary(word, val, { maxKeys: 4 }) -const files = FastCheck.array( - word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })), - { maxLength: 2 }, -) - -function maybe(arb: FastCheck.Arbitrary) { - return FastCheck.oneof(FastCheck.constant(undefined), arb) -} - -function assistant() { - return new SessionEntry.Assistant({ - id: SessionEvent.ID.create(), - type: "assistant", - time: { created: time(0) }, - content: [], - retries: [], - }) -} - -function retryError(message: string) { - return new SessionEvent.RetryError({ - message, - isRetryable: true, - }) -} - -function retry(attempt: number, message: string, created: number) { - return new SessionEntry.AssistantRetry({ - attempt, - error: retryError(message), - time: { - created: time(created), - }, - }) -} - -function memoryState() { - const state: SessionEntryStepper.MemoryState = { - entries: [], - pending: [], - } - return state -} - -function active() { - const state: SessionEntryStepper.MemoryState = { - entries: [assistant()], - pending: [], - } - return state -} - -function run(events: SessionEvent.Event[], state = memoryState()) { - return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) -} - -function last(state: SessionEntryStepper.MemoryState) { - const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") - expect(entry?.type).toBe("assistant") - return entry?.type === "assistant" ? entry : undefined -} - -function texts_of(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") -} - -function reasons(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") -} - -function tools(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") -} - -function tool(state: SessionEntryStepper.MemoryState, callID: string) { - return tools(state).find((x) => x.callID === callID) -} - -function retriesOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.retries ?? [] -} - -function adapterStore() { - return { - committed: [] as SessionEntry.Entry[], - deferred: [] as SessionEntry.Entry[], - } -} - -function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { - const activeAssistantIndex = () => - store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - const getCurrentAssistant = () => { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = store.committed[index] - return assistant?.type === "assistant" ? assistant : undefined - } - - return { - getCurrentAssistant, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = store.committed[index] - if (current?.type !== "assistant") return - store.committed[index] = assistant - }, - appendEntry(entry) { - store.committed.push(entry) - }, - appendPending(entry) { - store.deferred.push(entry) - }, - finish() { - return store - }, - } -} - -describe("session-entry-stepper", () => { - describe("stepWith", () => { - test("reduces through a custom adapter", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), - ) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(7), - }), - ) - - expect(store.deferred).toHaveLength(1) - expect(store.deferred[0]?.type).toBe("user") - expect(store.committed).toHaveLength(1) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].content).toEqual([ - { type: "reasoning", text: "thought" }, - { type: "text", text: "world" }, - ]) - expect(store.committed[0].time.completed).toEqual(time(7)) - }) - - test("aggregates retry events onto the current assistant", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ) - - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - - describe("memory", () => { - test("tracks and replaces the current assistant", () => { - const state = active() - const adapter = SessionEntryStepper.memory(state) - const current = adapter.getCurrentAssistant() - - expect(current?.type).toBe("assistant") - if (!current) return - - adapter.updateAssistant( - new SessionEntry.Assistant({ - ...current, - content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], - time: { - ...current.time, - completed: time(1), - }, - }), - ) - - expect(adapter.getCurrentAssistant()).toBeUndefined() - expect(state.entries[0]?.type).toBe("assistant") - if (state.entries[0]?.type !== "assistant") return - - expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) - expect(state.entries[0].time.completed).toEqual(time(1)) - }) - - test("appends committed and pending entries", () => { - const state = memoryState() - const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent( - SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), - ) - const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) - - adapter.appendEntry(committed) - adapter.appendPending(pending) - - expect(state.entries).toEqual([committed]) - expect(state.pending).toEqual([pending]) - }) - - test("stepWith through memory records reasoning", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), - ) - - expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) - }) - - test("stepWith through memory records retries", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) - }) - }) - - describe("step", () => { - describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - active(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step( - state, - SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), - ), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) - - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, - ) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(parts.length + 2), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(parts.length + 3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(parts.length + 2)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) - expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, - ) - }) - - test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, - ) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, - ) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) - }) - }) - - describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(reason.length + 7), - }), - ]) - - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return - - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - ], - active(), - ) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) - - test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, - ) - }) - - test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, - ) - }) - - test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, - ) - }) - }) - }) -}) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 0afbb1831..234c5246e 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -124,7 +124,7 @@ describe("SyncEvent", () => { yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) yield* Effect.promise(() => received) expect(events).toHaveLength(1) - expect(events[0]).toEqual({ + expect(events[0]).toMatchObject({ type: "item.created", properties: { id: "evt_1", diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts new file mode 100644 index 000000000..128177167 --- /dev/null +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "bun:test" +import * as DateTime from "effect/DateTime" +import { SessionID } from "../../src/session/schema" +import { EventV2 } from "../../src/v2/event" +import { SessionEvent } from "../../src/v2/session-event" +import { SessionMessageUpdater } from "../../src/v2/session-message-updater" + +test("step snapshots carry over to assistant messages", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + snapshot: "before", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + finish: "stop", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + snapshot: "after", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].finish).toBe("stop") +}) + +test("text ended populates assistant text content", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "hello assistant", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) +}) + +test("tool completion stores completed timestamp", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const callID = "call" + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.input.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + callID, + name: "bash", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.called", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + callID, + tool: "bash", + input: { command: "pwd" }, + provider: { executed: true, metadata: { source: "provider" } }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.success", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + callID, + structured: {}, + content: [{ type: "text", text: "/tmp" }], + provider: { executed: true, metadata: { status: "done" } }, + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content[0]?.type).toBe("tool") + if (state.messages[0].content[0]?.type !== "tool") return + expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) + expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } }) +}) + +test("compaction events reduce to compaction message", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const id = EventV2.ID.create() + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id, + type: "session.next.compaction.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "auto", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "hello ", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "summary", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + text: "final summary", + include: "recent context", + }, + } satisfies SessionEvent.Event) + + expect(state.messages).toHaveLength(1) + expect(state.messages[0]).toMatchObject({ + id, + type: "compaction", + reason: "auto", + summary: "final summary", + include: "recent context", + time: { created: DateTime.makeUnsafe(1) }, + }) +}) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index e920cc0fd..c490a0be7 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,7 +9,7 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono" +const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") if (openapiSource === "httpapi") { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67261d749..74c584462 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -20,10 +20,10 @@ import type { ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, - EventTuiCommandExecute, - EventTuiPromptAppend, - EventTuiSessionSelect, - EventTuiToastShow, + EventTuiCommandExecute2, + EventTuiPromptAppend2, + EventTuiSessionSelect2, + EventTuiToastShow2, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, @@ -90,6 +90,7 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + Prompt, ProviderAuthResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, @@ -126,6 +127,7 @@ import type { SessionDeleteMessageErrors, SessionDeleteMessageResponses, SessionDeleteResponses, + SessionDelivery, SessionDiffResponses, SessionForkResponses, SessionGetErrors, @@ -187,6 +189,14 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2SessionCompactResponses, + V2SessionContextResponses, + V2SessionListErrors, + V2SessionListResponses, + V2SessionMessagesErrors, + V2SessionMessagesResponses, + V2SessionPromptResponses, + V2SessionWaitResponses, VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, @@ -244,111 +254,6 @@ class HeyApiRegistry { } } -export class Config extends HeyApiClient { - /** - * Get global configuration - * - * Retrieve the current global OpenCode configuration settings and preferences. - */ - public get(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/config", - ...options, - }) - } - - /** - * Update global configuration - * - * Update global OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Global extends HeyApiClient { - /** - * Get health - * - * Get health information about the OpenCode server. - */ - public health(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/health", - ...options, - }) - } - - /** - * Get global events - * - * Subscribe to global events from the OpenCode system using server-sent events. - */ - public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/event", - ...options, - }) - } - - /** - * Dispose instance - * - * Clean up and dispose all OpenCode instances, releasing all resources. - */ - public dispose(options?: Options) { - return (options?.client ?? this.client).post({ - url: "/global/dispose", - ...options, - }) - } - - /** - * Upgrade opencode - * - * Upgrade opencode to the specified version or latest if not specified. - */ - public upgrade( - parameters?: { - target?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) - return (options?.client ?? this.client).post({ - url: "/global/upgrade", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _config?: Config - get config(): Config { - return (this._config ??= new Config({ client: this.client })) - } -} - export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -512,6 +417,419 @@ export class App extends HeyApiClient { } } +export class Config extends HeyApiClient { + /** + * Get global configuration + * + * Retrieve the current global OpenCode configuration settings and preferences. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/config", + ...options, + }) + } + + /** + * Update global configuration + * + * Update global OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) + return (options?.client ?? this.client).patch({ + url: "/global/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Global extends HeyApiClient { + /** + * Get health + * + * Get health information about the OpenCode server. + */ + public health(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/health", + ...options, + }) + } + + /** + * Get global events + * + * Subscribe to global events from the OpenCode system using server-sent events. + */ + public event(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/event", + ...options, + }) + } + + /** + * Dispose instance + * + * Clean up and dispose all OpenCode instances, releasing all resources. + */ + public dispose(options?: Options) { + return (options?.client ?? this.client).post({ + url: "/global/dispose", + ...options, + }) + } + + /** + * Upgrade opencode + * + * Upgrade opencode to the specified version or latest if not specified. + */ + public upgrade( + parameters?: { + target?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) + return (options?.client ?? this.client).post({ + url: "/global/upgrade", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _config?: Config + get config(): Config { + return (this._config ??= new Config({ client: this.client })) + } +} + +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + +export class Config2 extends HeyApiClient { + /** + * Get configuration + * + * Retrieve the current OpenCode configuration settings and preferences. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config", + ...options, + ...params, + }) + } + + /** + * Update configuration + * + * Update OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + directory?: string + workspace?: string + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "config", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List config providers + * + * Get a list of all configured AI providers and their default models. + */ + public providers( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/providers", + ...options, + ...params, + }) + } +} + +export class Console extends HeyApiClient { + /** + * Get active Console provider metadata + * + * Get the active Console org name and the set of provider IDs managed by that Console org. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console", + ...options, + ...params, + }) + } + + /** + * List switchable Console orgs + * + * Get the available Console orgs across logged-in accounts, including the current active org. + */ + public listOrgs( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console/orgs", + ...options, + ...params, + }) + } + + /** + * Switch active Console org + * + * Persist a new active Console account/org selection for the current local OpenCode state. + */ + public switchOrg( + parameters?: { + directory?: string + workspace?: string + accountID?: string + orgID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "accountID" }, + { in: "body", key: "orgID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/console/switch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Session extends HeyApiClient { + /** + * List sessions + * + * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + */ + public list( + parameters?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session", + ...options, + ...params, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + export class Adapter extends HeyApiClient { /** * List workspace adapters @@ -737,11 +1055,474 @@ export class Workspace extends HeyApiClient { } } -export class Console extends HeyApiClient { +export class Experimental extends HeyApiClient { + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } + + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } +} + +export class Tool extends HeyApiClient { /** - * Get active Console provider metadata + * List tools * - * Get the active Console org name and the set of provider IDs managed by that Console org. + * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + */ + public list( + parameters: { + directory?: string + workspace?: string + provider: string + model: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool", + ...options, + ...params, + }) + } + + /** + * List tool IDs + * + * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. + */ + public ids( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool/ids", + ...options, + ...params, + }) + } +} + +export class Worktree extends HeyApiClient { + /** + * Remove worktree + * + * Remove a git worktree and delete its branch. + */ + public remove( + parameters?: { + directory?: string + workspace?: string + worktreeRemoveInput?: WorktreeRemoveInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeRemoveInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List worktrees + * + * List all sandbox worktrees for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/worktree", + ...options, + ...params, + }) + } + + /** + * Create worktree + * + * Create a new git worktree for the current project and run any configured startup scripts. + */ + public create( + parameters?: { + directory?: string + workspace?: string + worktreeCreateInput?: WorktreeCreateInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + workspace?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Find extends HeyApiClient { + /** + * Find text + * + * Search for text patterns across files in the project using ripgrep. + */ + public text( + parameters: { + directory?: string + workspace?: string + pattern: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "pattern" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find", + ...options, + ...params, + }) + } + + /** + * Find files + * + * Search for files or directories by name or pattern in the project directory. + */ + public files( + parameters: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/file", + ...options, + ...params, + }) + } + + /** + * Find symbols + * + * Search for workspace symbols like functions, classes, and variables using LSP. + */ + public symbols( + parameters: { + directory?: string + workspace?: string + query: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/symbol", + ...options, + ...params, + }) + } +} + +export class File extends HeyApiClient { + /** + * List files + * + * List files and directories in a specified path. + */ + public list( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file", + ...options, + ...params, + }) + } + + /** + * Read file + * + * Read the content of a specified file. + */ + public read( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/content", + ...options, + ...params, + }) + } + + /** + * Get file status + * + * Get the git status of all files in the project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/status", + ...options, + ...params, + }) + } +} + +export class Instance extends HeyApiClient { + /** + * Dispose instance + * + * Clean up and dispose the current OpenCode instance, releasing all resources. + */ + public dispose( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/instance/dispose", + ...options, + ...params, + }) + } +} + +export class Path extends HeyApiClient { + /** + * Get paths + * + * Retrieve the current working directory and related path information for the OpenCode instance. */ public get( parameters?: { @@ -761,19 +1542,21 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console", + return (options?.client ?? this.client).get({ + url: "/path", ...options, ...params, }) } +} +export class Vcs extends HeyApiClient { /** - * List switchable Console orgs + * Get VCS info * - * Get the available Console orgs across logged-in accounts, including the current active org. + * Retrieve version control system (VCS) information for the current project, such as git branch. */ - public listOrgs( + public get( parameters?: { directory?: string workspace?: string @@ -791,24 +1574,23 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console/orgs", + return (options?.client ?? this.client).get({ + url: "/vcs", ...options, ...params, }) } /** - * Switch active Console org + * Get VCS diff * - * Persist a new active Console account/org selection for the current local OpenCode state. + * Retrieve the current git diff for the working tree or against the default branch. */ - public switchOrg( - parameters?: { + public diff( + parameters: { directory?: string workspace?: string - accountID?: string - orgID?: string + mode: "git" | "branch" }, options?: Options, ) { @@ -819,14 +1601,209 @@ export class Console extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "accountID" }, - { in: "body", key: "orgID" }, + { in: "query", key: "mode" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/console/switch", + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } +} + +export class Command extends HeyApiClient { + /** + * List commands + * + * Get a list of all available commands in the OpenCode system. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/command", + ...options, + ...params, + }) + } +} + +export class Lsp extends HeyApiClient { + /** + * Get LSP status + * + * Get LSP server status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/lsp", + ...options, + ...params, + }) + } +} + +export class Formatter extends HeyApiClient { + /** + * Get formatter status + * + * Get formatter status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/formatter", + ...options, + ...params, + }) + } +} + +export class Auth2 extends HeyApiClient { + /** + * Remove MCP OAuth + * + * Remove OAuth credentials for an MCP server. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Start MCP OAuth + * + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + */ + public start( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Complete MCP OAuth + * + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + */ + public callback( + parameters: { + name: string + directory?: string + workspace?: string + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", ...options, ...params, headers: { @@ -836,24 +1813,17 @@ export class Console extends HeyApiClient { }, }) } -} -export class Session extends HeyApiClient { /** - * List sessions + * Authenticate MCP OAuth * - * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + * Start OAuth flow and wait for callback (opens browser). */ - public list( - parameters?: { + public authenticate( + parameters: { + name: string directory?: string workspace?: string - roots?: boolean | "true" | "false" - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean | "true" | "false" }, options?: Options, ) { @@ -862,33 +1832,30 @@ export class Session extends HeyApiClient { [ { args: [ + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", - ...options, - ...params, - }) + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, + }, + ) } } -export class Resource extends HeyApiClient { +export class Mcp extends HeyApiClient { /** - * Get MCP resources + * Get MCP status * - * Get all available MCP resources from connected servers. Optionally filter by name. + * Get the status of all Model Context Protocol (MCP) servers. */ - public list( + public status( parameters?: { directory?: string workspace?: string @@ -906,33 +1873,115 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", + return (options?.client ?? this.client).get({ + url: "/mcp", ...options, ...params, }) } -} -export class Experimental extends HeyApiClient { - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) + /** + * Add MCP server + * + * Dynamically add a new Model Context Protocol (MCP) server to the system. + */ + public add( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) } - private _console?: Console - get console(): Console { - return (this._console ??= new Console({ client: this.client })) + /** + * Connect an MCP server. + */ + public connect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", + ...options, + ...params, + }) } - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) + /** + * Disconnect an MCP server. + */ + public disconnect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) } - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) + private _auth?: Auth2 + get auth(): Auth2 { + return (this._auth ??= new Auth2({ client: this.client })) } } @@ -1329,147 +2378,48 @@ export class Pty extends HeyApiClient { } } -export class Config2 extends HeyApiClient { +export class Question extends HeyApiClient { /** - * Get configuration + * List pending questions * - * Retrieve the current OpenCode configuration settings and preferences. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config", - ...options, - ...params, - }) - } - - /** - * Update configuration - * - * Update OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - directory?: string - workspace?: string - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "config", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List config providers - * - * Get a list of all configured AI providers and their default models. - */ - public providers( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config/providers", - ...options, - ...params, - }) - } -} - -export class Tool extends HeyApiClient { - /** - * List tool IDs - * - * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. - */ - public ids( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", - ...options, - ...params, - }) - } - - /** - * List tools - * - * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + * Get all pending question requests across all sessions. */ public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/question", + ...options, + ...params, + }) + } + + /** + * Reply to question request + * + * Provide answers to a question request from the AI assistant. + */ + public reply( parameters: { + requestID: string directory?: string workspace?: string - provider: string - model: string + answers?: Array }, options?: Options, ) { @@ -1478,50 +2428,16 @@ export class Tool extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: "body", key: "answers" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool", - ...options, - ...params, - }) - } -} - -export class Worktree extends HeyApiClient { - /** - * Remove worktree - * - * Remove a git worktree and delete its branch. - */ - public remove( - parameters?: { - directory?: string - workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reply", ...options, ...params, headers: { @@ -1533,9 +2449,43 @@ export class Worktree extends HeyApiClient { } /** - * List worktrees + * Reject question request * - * List all sandbox worktrees for the current project. + * Reject a question request from the AI assistant. + */ + public reject( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Permission extends HeyApiClient { + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. */ public list( parameters?: { @@ -1555,23 +2505,25 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/permission", ...options, ...params, }) } /** - * Create worktree + * Respond to permission request * - * Create a new git worktree for the current project and run any configured startup scripts. + * Approve or deny a permission request from the AI assistant. */ - public create( - parameters?: { + public reply( + parameters: { + requestID: string directory?: string workspace?: string - worktreeCreateInput?: WorktreeCreateInput + reply?: "once" | "always" | "reject" + message?: string }, options?: Options, ) { @@ -1580,15 +2532,17 @@ export class Worktree extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", ...options, ...params, headers: { @@ -1600,15 +2554,153 @@ export class Worktree extends HeyApiClient { } /** - * Reset worktree + * Respond to permission * - * Reset a worktree branch to the primary default branch. + * Approve or deny a permission request from the AI assistant. + * + * @deprecated */ - public reset( + public respond( + parameters: { + sessionID: string + permissionID: string + directory?: string + workspace?: string + response?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "permissionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "response" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permissions/{permissionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Oauth extends HeyApiClient { + /** + * Start OAuth authorization + * + * Start the OAuth authorization flow for a provider. + */ + public authorize( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + inputs?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "inputs" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/authorize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Handle OAuth callback + * + * Handle the OAuth callback from a provider after user authorization. + */ + public callback( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Provider extends HeyApiClient { + /** + * List providers + * + * Get a list of all available AI providers, including both available and connected ones. + */ + public list( parameters?: { directory?: string workspace?: string - worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1619,22 +2711,51 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + return (options?.client ?? this.client).get({ + url: "/provider", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } + + /** + * Get provider auth methods + * + * Retrieve available authentication methods for all AI providers. + */ + public auth( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/provider/auth", + ...options, + ...params, + }) + } + + private _oauth?: Oauth + get oauth(): Oauth { + return (this._oauth ??= new Oauth({ client: this.client })) + } } export class Session2 extends HeyApiClient { @@ -1691,6 +2812,12 @@ export class Session2 extends HeyApiClient { workspace?: string parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string }, @@ -1705,6 +2832,8 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "parentID" }, { in: "body", key: "title" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, ], @@ -1926,184 +3055,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Initialize session - * - * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. - */ - public init( - parameters: { - sessionID: string - directory?: string - workspace?: string - modelID?: string - providerID?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Fork session - * - * Create a new session by forking an existing session at a specific message point. - */ - public fork( - parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Abort session - * - * Abort an active session and stop any ongoing AI processing or command execution. - */ - public abort( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", - ...options, - ...params, - }) - } - - /** - * Unshare session - * - * Remove the shareable link for a session, making it private again. - */ - public unshare( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - - /** - * Share session - * - * Create a shareable link for a session, allowing others to view the conversation. - */ - public share( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - /** * Get message diff * @@ -2138,49 +3089,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Summarize session - * - * Generate a concise summary of the session using AI compaction to preserve key information. - */ - public summarize( - parameters: { - sessionID: string - directory?: string - workspace?: string - providerID?: string - modelID?: string - auto?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - /** * Get session messages * @@ -2280,7 +3188,7 @@ export class Session2 extends HeyApiClient { /** * Delete message * - * Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message. + * Permanently delete a specific message and all of its parts from a session without reverting file changes. */ public deleteMessage( parameters: { @@ -2349,6 +3257,227 @@ export class Session2 extends HeyApiClient { }) } + /** + * Fork session + * + * Create a new session by forking an existing session at a specific message point. + */ + public fork( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/fork", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Abort session + * + * Abort an active session and stop any ongoing AI processing or command execution. + */ + public abort( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/abort", + ...options, + ...params, + }) + } + + /** + * Initialize session + * + * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. + */ + public init( + parameters: { + sessionID: string + directory?: string + workspace?: string + modelID?: string + providerID?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "modelID" }, + { in: "body", key: "providerID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/init", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Unshare session + * + * Remove the shareable link for a session, making it private again. + */ + public unshare( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Share session + * + * Create a shareable link for a session, allowing others to view the conversation. + */ + public share( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Summarize session + * + * Generate a concise summary of the session using AI compaction to preserve key information. + */ + public summarize( + parameters: { + sessionID: string + directory?: string + workspace?: string + providerID?: string + modelID?: string + auto?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "auto" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/summarize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send async message * @@ -2591,7 +3720,7 @@ export class Session2 extends HeyApiClient { export class Part extends HeyApiClient { /** - * Delete a part from a message + * Delete a part from a message. */ public delete( parameters: { @@ -2625,7 +3754,7 @@ export class Part extends HeyApiClient { } /** - * Update a part in a message + * Update a part in a message. */ public update( parameters: { @@ -2666,386 +3795,6 @@ export class Part extends HeyApiClient { } } -export class Permission extends HeyApiClient { - /** - * Respond to permission - * - * Approve or deny a permission request from the AI assistant. - * - * @deprecated - */ - public respond( - parameters: { - sessionID: string - permissionID: string - directory?: string - workspace?: string - response?: "once" | "always" | "reject" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "response" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Respond to permission request - * - * Approve or deny a permission request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - reply?: "once" | "always" | "reject" - message?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List pending permissions - * - * Get all pending permission requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/permission", - ...options, - ...params, - }) - } -} - -export class Question extends HeyApiClient { - /** - * List pending questions - * - * Get all pending question requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/question", - ...options, - ...params, - }) - } - - /** - * Reply to question request - * - * Provide answers to a question request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - answers?: Array - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Reject question request - * - * Reject a question request from the AI assistant. - */ - public reject( - parameters: { - requestID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", - ...options, - ...params, - }) - } -} - -export class Oauth extends HeyApiClient { - /** - * OAuth authorize - * - * Initiate OAuth authorization for a specific AI provider to get an authorization URL. - */ - public authorize( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - inputs?: { - [key: string]: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthAuthorizeResponses, - ProviderOauthAuthorizeErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/authorize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * OAuth callback - * - * Handle the OAuth callback from a provider after user authorization. - */ - public callback( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthCallbackResponses, - ProviderOauthCallbackErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/callback", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Provider extends HeyApiClient { - /** - * List providers - * - * Get a list of all available AI providers, including both available and connected ones. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider", - ...options, - ...params, - }) - } - - /** - * Get provider auth methods - * - * Retrieve available authentication methods for all AI providers. - */ - public auth( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider/auth", - ...options, - ...params, - }) - } - - private _oauth?: Oauth - get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) - } -} - export class History extends HeyApiClient { /** * List sync events @@ -3179,181 +3928,13 @@ export class Sync extends HeyApiClient { } } -export class Find extends HeyApiClient { +export class Session3 extends HeyApiClient { /** - * Find text + * List v2 sessions * - * Search for text patterns across files in the project using ripgrep. - */ - public text( - parameters: { - directory?: string - workspace?: string - pattern: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find", - ...options, - ...params, - }) - } - - /** - * Find files - * - * Search for files or directories by name or pattern in the project directory. - */ - public files( - parameters: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/file", - ...options, - ...params, - }) - } - - /** - * Find symbols - * - * Search for workspace symbols like functions, classes, and variables using LSP. - */ - public symbols( - parameters: { - directory?: string - workspace?: string - query: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/symbol", - ...options, - ...params, - }) - } -} - -export class File extends HeyApiClient { - /** - * List files - * - * List files and directories in a specified path. + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. */ public list( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file", - ...options, - ...params, - }) - } - - /** - * Read file - * - * Read the content of a specified file. - */ - public read( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file/content", - ...options, - ...params, - }) - } - - /** - * Get file status - * - * Get the git status of all files in the project. - */ - public status( parameters?: { directory?: string workspace?: string @@ -3371,57 +3952,25 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/file/status", + return (options?.client ?? this.client).get({ + url: "/api/session", ...options, ...params, }) } -} -export class Event extends HeyApiClient { /** - * Subscribe to events + * Send v2 message * - * Get events + * Create a v2 session message and queue it for the agent loop. */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - -export class Auth2 extends HeyApiClient { - /** - * Remove MCP OAuth - * - * Remove OAuth credentials for an MCP server - */ - public remove( + public prompt( parameters: { - name: string + sessionID: string directory?: string workspace?: string + prompt?: Prompt + delivery?: SessionDelivery }, options?: Options, ) { @@ -3430,81 +3979,17 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Start MCP OAuth - * - * Start OAuth authentication flow for a Model Context Protocol (MCP) server. - */ - public start( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Complete MCP OAuth - * - * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. - */ - public callback( - parameters: { - name: string - directory?: string - workspace?: string - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", ...options, ...params, headers: { @@ -3516,13 +4001,13 @@ export class Auth2 extends HeyApiClient { } /** - * Authenticate MCP OAuth + * Compact v2 session * - * Start OAuth flow and wait for callback (opens browser) + * Compact a v2 session conversation. */ - public authenticate( + public compact( parameters: { - name: string + sessionID: string directory?: string workspace?: string }, @@ -3533,156 +4018,121 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", + ...options, + ...params, + }) + } + + /** + * Wait for v2 session + * + * Wait for a v2 session agent loop to become idle. + */ + public wait( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", + ...options, + ...params, + }) + } + + /** + * Get v2 session context + * + * Retrieve the active context messages for a v2 session (all messages after the last compaction). + */ + public context( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", + ...options, + ...params, + }) + } + + /** + * Get v2 session messages + * + * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public messages( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) } } -export class Mcp extends HeyApiClient { - /** - * Get MCP status - * - * Get the status of all Model Context Protocol (MCP) servers. - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/mcp", - ...options, - ...params, - }) - } - - /** - * Add MCP server - * - * Dynamically add a new Model Context Protocol (MCP) server to the system. - */ - public add( - parameters?: { - directory?: string - workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Connect an MCP server - */ - public connect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", - ...options, - ...params, - }) - } - - /** - * Disconnect an MCP server - */ - public disconnect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", - ...options, - ...params, - }) - } - - private _auth?: Auth2 - get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) +export class V2 extends HeyApiClient { + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) } } @@ -3690,7 +4140,7 @@ export class Control extends HeyApiClient { /** * Get next TUI request * - * Retrieve the next TUI (Terminal User Interface) request from the queue for processing. + * Retrieve the next TUI request from the queue for processing. */ public next( parameters?: { @@ -3759,7 +4209,7 @@ export class Tui extends HeyApiClient { /** * Append TUI prompt * - * Append prompt to the TUI + * Append prompt to the TUI. */ public appendPrompt( parameters?: { @@ -3826,7 +4276,7 @@ export class Tui extends HeyApiClient { /** * Open sessions dialog * - * Open the session dialog + * Open the session dialog. */ public openSessions( parameters?: { @@ -3856,7 +4306,7 @@ export class Tui extends HeyApiClient { /** * Open themes dialog * - * Open the theme dialog + * Open the theme dialog. */ public openThemes( parameters?: { @@ -3886,7 +4336,7 @@ export class Tui extends HeyApiClient { /** * Open models dialog * - * Open the model dialog + * Open the model dialog. */ public openModels( parameters?: { @@ -3916,7 +4366,7 @@ export class Tui extends HeyApiClient { /** * Submit TUI prompt * - * Submit the prompt + * Submit the prompt. */ public submitPrompt( parameters?: { @@ -3946,7 +4396,7 @@ export class Tui extends HeyApiClient { /** * Clear TUI prompt * - * Clear the prompt + * Clear the prompt. */ public clearPrompt( parameters?: { @@ -3976,7 +4426,7 @@ export class Tui extends HeyApiClient { /** * Execute TUI command * - * Execute a TUI command (e.g. agent_cycle) + * Execute a TUI command. */ public executeCommand( parameters?: { @@ -4013,7 +4463,7 @@ export class Tui extends HeyApiClient { /** * Show TUI toast * - * Show a toast notification in the TUI + * Show a toast notification in the TUI. */ public showToast( parameters?: { @@ -4056,13 +4506,13 @@ export class Tui extends HeyApiClient { /** * Publish TUI event * - * Publish a TUI event + * Publish a TUI event. */ public publish( parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 }, options?: Options, ) { @@ -4133,230 +4583,6 @@ export class Tui extends HeyApiClient { } } -export class Instance extends HeyApiClient { - /** - * Dispose instance - * - * Clean up and dispose the current OpenCode instance, releasing all resources. - */ - public dispose( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/instance/dispose", - ...options, - ...params, - }) - } -} - -export class Path extends HeyApiClient { - /** - * Get paths - * - * Retrieve the current working directory and related path information for the OpenCode instance. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/path", - ...options, - ...params, - }) - } -} - -export class Vcs extends HeyApiClient { - /** - * Get VCS info - * - * Retrieve version control system (VCS) information for the current project, such as git branch. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs", - ...options, - ...params, - }) - } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", - ...options, - ...params, - }) - } -} - -export class Command extends HeyApiClient { - /** - * List commands - * - * Get a list of all available commands in the OpenCode system. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/command", - ...options, - ...params, - }) - } -} - -export class Lsp extends HeyApiClient { - /** - * Get LSP status - * - * Get LSP server status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/lsp", - ...options, - ...params, - }) - } -} - -export class Formatter extends HeyApiClient { - /** - * Get formatter status - * - * Get formatter status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/formatter", - ...options, - ...params, - }) - } -} - export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -4365,11 +4591,6 @@ export class OpencodeClient extends HeyApiClient { OpencodeClient.__registry.set(this, args?.key) } - private _global?: Global - get global(): Global { - return (this._global ??= new Global({ client: this.client })) - } - private _auth?: Auth get auth(): Auth { return (this._auth ??= new Auth({ client: this.client })) @@ -4380,19 +4601,14 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) + private _global?: Global + get global(): Global { + return (this._global ??= new Global({ client: this.client })) } - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } - - private _pty?: Pty - get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) } private _config?: Config2 @@ -4400,6 +4616,11 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -4410,36 +4631,6 @@ export class OpencodeClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _session?: Session2 - get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) - } - - private _part?: Part - get part(): Part { - return (this._part ??= new Part({ client: this.client })) - } - - private _permission?: Permission - get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) - } - - private _question?: Question - get question(): Question { - return (this._question ??= new Question({ client: this.client })) - } - - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) - } - - private _sync?: Sync - get sync(): Sync { - return (this._sync ??= new Sync({ client: this.client })) - } - private _find?: Find get find(): Find { return (this._find ??= new Find({ client: this.client })) @@ -4450,21 +4641,6 @@ export class OpencodeClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } - - private _mcp?: Mcp - get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) - } - - private _tui?: Tui - get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) - } - private _instance?: Instance get instance(): Instance { return (this._instance ??= new Instance({ client: this.client })) @@ -4494,4 +4670,59 @@ export class OpencodeClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } + + private _mcp?: Mcp + get mcp(): Mcp { + return (this._mcp ??= new Mcp({ client: this.client })) + } + + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + + private _pty?: Pty + get pty(): Pty { + return (this._pty ??= new Pty({ client: this.client })) + } + + private _question?: Question + get question(): Question { + return (this._question ??= new Question({ client: this.client })) + } + + private _permission?: Permission + get permission(): Permission { + return (this._permission ??= new Permission({ client: this.client })) + } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } + + private _part?: Part + get part(): Part { + return (this._part ??= new Part({ client: this.client })) + } + + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } + + private _tui?: Tui + get tui(): Tui { + return (this._tui ??= new Tui({ client: this.client })) + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 31bd40ab4..caa3d4c76 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,53 +4,104 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string +export type Event = + | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string +} + +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string - } -} - -export type EventLspUpdated = { - type: "lsp.updated" - properties: { - [key: string]: unknown - } -} - -export type EventMessagePartDelta = { - type: "message.part.delta" - properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} +export type Auth = OAuth | ApiAuth | WellKnownAuth export type PermissionRequest = { id: string @@ -67,20 +118,6 @@ export type PermissionRequest = { } } -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SnapshotFileDiff = { file: string patch: string @@ -89,14 +126,6 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - export type ProviderAuthError = { name: "ProviderAuthError" data: { @@ -158,35 +187,6 @@ export type ApiError = { } } -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventInstallationUpdated = { - type: "installation.updated" - properties: { - version: string - } -} - -export type EventInstallationUpdateAvailable = { - type: "installation.update-available" - properties: { - version: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -211,13 +211,7 @@ export type QuestionInfo = { * Available choices */ options: Array - /** - * Allow selecting multiple choices - */ multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ custom?: boolean } @@ -236,11 +230,6 @@ export type QuestionRequest = { tool?: QuestionTool } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - export type QuestionAnswer = Array export type QuestionReplied = { @@ -249,21 +238,11 @@ export type QuestionReplied = { answers: Array } -export type EventQuestionReplied = { - type: "question.replied" - properties: QuestionReplied -} - export type QuestionRejected = { sessionID: string requestID: string } -export type EventQuestionRejected = { - type: "question.rejected" - properties: QuestionRejected -} - export type Todo = { /** * Brief description of the task @@ -279,14 +258,6 @@ export type Todo = { priority: string } -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type SessionStatus = | { type: "idle" @@ -301,29 +272,8 @@ export type SessionStatus = type: "busy" } -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - export type EventTuiPromptAppend = { + id: string type: "tui.prompt.append" properties: { text: string @@ -331,6 +281,7 @@ export type EventTuiPromptAppend = { } export type EventTuiCommandExecute = { + id: string type: "tui.command.execute" properties: { command: @@ -355,19 +306,18 @@ export type EventTuiCommandExecute = { } export type EventTuiToastShow = { + id: string type: "tui.toast.show" properties: { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } } export type EventTuiSessionSelect = { + id: string type: "tui.session.select" properties: { /** @@ -377,31 +327,6 @@ export type EventTuiSessionSelect = { } } -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - export type Project = { id: string worktree: string @@ -426,65 +351,6 @@ export type Project = { sandboxes: Array } -export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string - } -} - export type Pty = { id: string title: string @@ -495,35 +361,6 @@ export type Pty = { pid: number } -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - export type OutputFormatText = { type: "text" } @@ -609,22 +446,6 @@ export type AssistantMessage = { export type Message = UserMessage | AssistantMessage -export type EventMessageUpdated = { - type: "message.updated" - properties: { - sessionID: string - info: Message - } -} - -export type EventMessageRemoved = { - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - export type TextPart = { id: string sessionID: string @@ -888,24 +709,6 @@ export type Part = | RetryPart | CompactionPart -export type EventMessagePartUpdated = { - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number - } -} - -export type EventMessagePartRemoved = { - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -934,6 +737,12 @@ export type Session = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -950,160 +759,10 @@ export type Session = { } } -export type EventSessionCreated = { - type: "session.created" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionUpdated = { - type: "session.updated" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionDeleted = { - type: "session.deleted" - properties: { - sessionID: string - info: Session - } -} - -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message - } -} - -export type SyncEventMessageRemoved = { - type: "sync" - name: "message.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "sync" - name: "message.part.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "sync" - name: "message.part.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "sync" - name: "session.created.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "sync" - name: "session.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: { - id?: string | null - slug?: string | null - projectID?: string | null - workspaceID?: string | null - directory?: string | null - path?: string | null - parentID?: string | null - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - share?: { - url?: string | null - } - title?: string | null - version?: string | null - time?: { - created?: number | null - updated?: number | null - compacting?: number | null - archived?: number | null - } - permission?: PermissionRuleset | null - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - } - } -} - -export type SyncEventSessionDeleted = { - type: "sync" - name: "session.deleted.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } +export type Prompt = { + text: string + files?: Array + agents?: Array } export type GlobalEvent = { @@ -1156,6 +815,31 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded | EventServerConnected | EventGlobalDisposed | SyncEventMessageUpdated @@ -1165,6 +849,31 @@ export type GlobalEvent = { | SyncEventSessionCreated | SyncEventSessionUpdated | SyncEventSessionDeleted + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolError + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionDelta + | SyncEventSessionNextCompactionEnded } /** @@ -1176,25 +885,10 @@ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" * Server configuration for opencode serve and web commands */ export type ServerConfig = { - /** - * Port to listen on - */ port?: number - /** - * Hostname to listen on - */ hostname?: string - /** - * Enable mDNS service discovery - */ mdns?: boolean - /** - * Custom domain name for mDNS service (default: opencode.local) - */ mdnsDomain?: string - /** - * Additional domains to allow for CORS - */ cors?: Array } @@ -1229,28 +923,16 @@ export type PermissionConfig = export type AgentConfig = { model?: string - /** - * Default model variant for this agent (applies only when using the agent's configured model). - */ variant?: string temperature?: number top_p?: number prompt?: string - /** - * @deprecated Use 'permission' field instead - */ tools?: { [key: string]: boolean } disable?: boolean - /** - * Description of when to use the agent - */ description?: string mode?: "subagent" | "primary" | "all" - /** - * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) - */ hidden?: boolean options?: { [key: string]: unknown @@ -1259,13 +941,7 @@ export type AgentConfig = { * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - /** - * Maximum number of agentic iterations before forcing text-only response - */ steps?: number - /** - * @deprecated Use 'steps' field instead. - */ maxSteps?: number permission?: PermissionConfig [key: string]: @@ -1306,21 +982,12 @@ export type ProviderConfig = { options?: { apiKey?: string baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ setCacheKey?: boolean /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false - /** - * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. - */ chunkTimeout?: number [key: string]: unknown | string | boolean | number | false | number | undefined } @@ -1377,9 +1044,6 @@ export type ProviderConfig = { */ variants?: { [key: string]: { - /** - * Disable this variant for the model - */ disabled?: boolean [key: string]: unknown | boolean | undefined } @@ -1397,38 +1061,17 @@ export type McpLocalConfig = { * Command and arguments to run the MCP server */ command: Array - /** - * Environment variables to set when running the MCP server - */ environment?: { [key: string]: string } - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } export type McpOAuthConfig = { - /** - * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. - */ clientId?: string - /** - * OAuth client secret (if required by the authorization server) - */ clientSecret?: string - /** - * OAuth scopes to request during authorization - */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ redirectUri?: string } @@ -1441,13 +1084,7 @@ export type McpRemoteConfig = { * URL of the remote MCP server */ url: string - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Headers to send with the request - */ headers?: { [key: string]: string } @@ -1455,9 +1092,6 @@ export type McpRemoteConfig = { * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ oauth?: McpOAuthConfig | false - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } @@ -1467,19 +1101,10 @@ export type McpRemoteConfig = { export type LayoutConfig = "auto" | "stretch" export type Config = { - /** - * JSON schema reference for configuration validation - */ $schema?: string - /** - * Default shell to use for terminal and bash tool - */ shell?: string logLevel?: LogLevel server?: ServerConfig - /** - * Command configuration, see https://opencode.ai/docs/commands - */ command?: { [key: string]: { template: string @@ -1489,25 +1114,13 @@ export type Config = { subtask?: boolean } } - /** - * Additional skill folder paths - */ skills?: { - /** - * Additional paths to skill folders - */ paths?: Array - /** - * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) - */ urls?: Array } watcher?: { ignore?: Array } - /** - * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. - */ snapshot?: boolean plugin?: Array< | string @@ -1518,53 +1131,23 @@ export type Config = { }, ] > - /** - * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing - */ share?: "manual" | "auto" | "disabled" - /** - * @deprecated Use 'share' field instead. Share newly created sessions automatically - */ autoshare?: boolean /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ autoupdate?: boolean | "notify" - /** - * Disable providers that are loaded automatically - */ disabled_providers?: Array - /** - * When set, ONLY these providers will be enabled. All other providers will be ignored - */ enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ small_model?: string - /** - * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. - */ default_agent?: string - /** - * Custom username to display in conversations instead of system username - */ username?: string - /** - * @deprecated Use `agent` field instead. - */ mode?: { build?: AgentConfig plan?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Agent configuration, see https://opencode.ai/docs/agents - */ agent?: { plan?: AgentConfig build?: AgentConfig @@ -1575,15 +1158,9 @@ export type Config = { compaction?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Custom provider configurations and model overrides - */ provider?: { [key: string]: ProviderConfig } - /** - * MCP (Model Context Protocol) server configurations - */ mcp?: { [key: string]: | McpLocalConfig @@ -1629,9 +1206,6 @@ export type Config = { } } } - /** - * Additional instruction files or patterns to include - */ instructions?: Array layout?: LayoutConfig permission?: PermissionConfig @@ -1639,121 +1213,29 @@ export type Config = { [key: string]: boolean } enterprise?: { - /** - * Enterprise URL - */ url?: string } - /** - * Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned. - */ tool_output?: { - /** - * Maximum lines of tool output before it is truncated and saved to disk (default: 2000) - */ max_lines?: number - /** - * Maximum bytes of tool output before it is truncated and saved to disk (default: 51200) - */ max_bytes?: number } compaction?: { - /** - * Enable automatic compaction when context is full (default: true) - */ auto?: boolean - /** - * Enable pruning of old tool outputs (default: true) - */ prune?: boolean - /** - * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) - */ tail_turns?: number - /** - * Maximum number of tokens from recent turns to preserve verbatim after compaction - */ preserve_recent_tokens?: number - /** - * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. - */ reserved?: number } experimental?: { disable_paste_summary?: boolean - /** - * Enable the batch tool - */ batch_tool?: boolean - /** - * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) - */ openTelemetry?: boolean - /** - * Tools that should only be available to primary agents. - */ primary_tools?: Array - /** - * Continue the agent loop when a tool call is denied - */ continue_loop_on_deny?: boolean - /** - * Timeout in milliseconds for model context protocol (MCP) requests - */ mcp_timeout?: number } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - -export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} - -export type ApiAuth = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} - -export type Auth = OAuth | ApiAuth | WellKnownAuth - -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type Model = { id: string providerID: string @@ -1845,8 +1327,6 @@ export type ConsoleState = { switchableOrgCount: number } -export type ToolIds = Array - export type ToolListItem = { id: string description: string @@ -1855,11 +1335,7 @@ export type ToolListItem = { export type ToolList = Array -export type Worktree = { - name: string - branch: string - directory: string -} +export type ToolIds = Array export type WorktreeCreateInput = { name?: string @@ -1869,6 +1345,12 @@ export type WorktreeCreateInput = { startCommand?: string } +export type Worktree = { + name: string + branch: string + directory: string +} + export type WorktreeRemoveInput = { directory: string } @@ -1901,6 +1383,12 @@ export type GlobalSession = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -1926,93 +1414,6 @@ export type McpResource = { client: string } -export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type AgentPartInput = { - id?: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { - providerID: string - modelID: string - } - command?: string -} - -export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - > -} - -export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} - export type Symbol = { name: string kind: number @@ -2059,88 +1460,6 @@ export type File = { status: "added" | "deleted" | "modified" } -export type Event = - | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventProjectUpdated - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - | EventServerConnected - | EventGlobalDisposed - -export type McpStatusConnected = { - status: "connected" -} - -export type McpStatusDisabled = { - status: "disabled" -} - -export type McpStatusFailed = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} - -export type McpStatus = - | McpStatusConnected - | McpStatusDisabled - | McpStatusFailed - | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration - -export type McpUnsupportedOAuthError = { - error: string -} - export type Path = { home: string state: string @@ -2208,6 +1527,1791 @@ export type FormatterStatus = { enabled: boolean } +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type McpUnsupportedOAuthError = { + error: string +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type EventTuiPromptAppend2 = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute2 = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow2 = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect2 = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type SyncEventMessageUpdated = { + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Message + } +} + +export type SyncEventMessageRemoved = { + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + } +} + +export type SyncEventMessagePartUpdated = { + type: "sync" + name: "message.part.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + part: Part + time: number + } +} + +export type SyncEventMessagePartRemoved = { + type: "sync" + name: "message.part.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SyncEventSessionCreated = { + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionUpdated = { + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: { + id?: string | null + slug?: string | null + projectID?: string | null + workspaceID?: string | null + directory?: string | null + path?: string | null + parentID?: string | null + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } | null + share?: { + url?: string | null + } + title?: string | null + agent?: string | null + model?: { + id: string + providerID: string + variant?: string + } | null + version?: string | null + time?: { + created?: number | null + updated?: number | null + compacting?: number | null + archived?: number | null + } + permission?: PermissionRuleset | null + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } | null + } + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionNextAgentSwitched = { + type: "sync" + name: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + } +} + +export type SyncEventSessionNextModelSwitched = { + type: "sync" + name: "session.next.model.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type SyncEventSessionNextPrompted = { + type: "sync" + name: "session.next.prompted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + name: "session.next.synthetic.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextShellStarted = { + type: "sync" + name: "session.next.shell.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type SyncEventSessionNextShellEnded = { + type: "sync" + name: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + name: "session.next.step.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + name: "session.next.step.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + } +} + +export type SyncEventSessionNextTextDelta = { + type: "sync" + name: "session.next.text.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + delta: string + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + name: "session.next.text.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + name: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolError = { + type: "sync" + name: "session.next.tool.error.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + name: "session.next.retried.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SyncEventSessionNextCompactionStarted = { + type: "sync" + name: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type SyncEventSessionNextCompactionDelta = { + type: "sync" + name: "session.next.compaction.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextCompactionEnded = { + type: "sync" + name: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventLspClientDiagnostics = { + id: string + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + +export type EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventMessagePartDelta = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventPermissionAsked = { + id: string + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: QuestionRequest +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: QuestionReplied +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: QuestionRejected +} + +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventSessionStatus = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: Project +} + +export type EventVcsBranchUpdated = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + id: string + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady = { + id: string + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolError = { + id: string + type: "session.next.tool.error" + properties: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type SessionInfo = { + id: string + parentID?: string + projectID: string + workspaceID?: string + path?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + time: { + created: number + updated: number + archived?: number + } + title: string +} + +export type SessionDelivery = "immediate" | "deferred" + +export type SessionMessageAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: { + id: string + providerID: string + variant?: string + } +} + +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + structured: { + [key: string]: unknown + } +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: { + type: string + message: string + } +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: { + id: string + providerID: string + variant?: string + } + content: Array + snapshot?: { + start?: string + end?: string + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: string +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + include?: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type EventTuiToastShow1 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + +export type AuthSetData = { + body?: Auth + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + export type GlobalHealthData = { body?: never path?: never @@ -2335,276 +3439,924 @@ export type GlobalUpgradeResponses = { export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] -export type AuthRemoveData = { - body?: never - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] - -export type AuthRemoveResponses = { - /** - * Successfully removed authentication credentials - */ - 200: boolean -} - -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] - -export type AuthSetData = { - body?: Auth - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthSetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] - -export type AuthSetResponses = { - /** - * Successfully set authentication credentials - */ - 200: boolean -} - -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] - -export type AppLogData = { - body?: { - /** - * Service name for the log entry - */ - service: string - /** - * Log level - */ - level: "debug" | "info" | "error" | "warn" - /** - * Log message - */ - message: string - /** - * Additional metadata for the log entry - */ - extra?: { - [key: string]: unknown - } - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/log" -} - -export type AppLogErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppLogError = AppLogErrors[keyof AppLogErrors] - -export type AppLogResponses = { - /** - * Log entry written successfully - */ - 200: boolean -} - -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] - -export type ExperimentalWorkspaceAdapterListData = { +export type EventSubscribeData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adapter" + url: "/event" } -export type ExperimentalWorkspaceAdapterListResponses = { +export type EventSubscribeResponses = { /** - * Workspace adapters + * Event stream */ - 200: Array<{ - type: string - name: string - description: string - }> + 200: Event } -export type ExperimentalWorkspaceAdapterListResponse = - ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] -export type ExperimentalWorkspaceListData = { +export type ConfigGetData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceListResponses = { +export type ConfigGetResponses = { /** - * Workspaces + * Get config info */ - 200: Array + 200: Config } -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } +export type ConfigUpdateData = { + body?: Config path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceCreateErrors = { +export type ConfigUpdateErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] -export type ExperimentalWorkspaceCreateResponses = { +export type ConfigUpdateResponses = { /** - * Workspace created + * Successfully updated config */ - 200: Workspace + 200: Config } -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] -export type ExperimentalWorkspaceStatusData = { +export type ConfigProvidersData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/status" + url: "/config/providers" } -export type ExperimentalWorkspaceStatusResponses = { +export type ConfigProvidersResponses = { /** - * Workspace status - */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> -} - -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] - -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} - -export type ExperimentalWorkspaceRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] - -export type ExperimentalWorkspaceRemoveResponses = { - /** - * Workspace removed - */ - 200: Workspace -} - -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] - -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}/session-restore" -} - -export type ExperimentalWorkspaceSessionRestoreErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] - -export type ExperimentalWorkspaceSessionRestoreResponses = { - /** - * Session replay started + * List of providers */ 200: { - total: number + providers: Array + default: { + [key: string]: string + } } } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ExperimentalConsoleGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console" +} + +export type ExperimentalConsoleGetResponses = { + /** + * Active Console provider metadata + */ + 200: ConsoleState +} + +export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] + +export type ExperimentalConsoleListOrgsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/orgs" +} + +export type ExperimentalConsoleListOrgsResponses = { + /** + * Switchable Console orgs + */ + 200: { + orgs: Array<{ + accountID: string + accountEmail: string + accountUrl: string + orgID: string + orgName: string + active: boolean + }> + } +} + +export type ExperimentalConsoleListOrgsResponse = + ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] + +export type ExperimentalConsoleSwitchOrgData = { + body?: { + accountID: string + orgID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/switch" +} + +export type ExperimentalConsoleSwitchOrgResponses = { + /** + * Switch success + */ + 200: boolean +} + +export type ExperimentalConsoleSwitchOrgResponse = + ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type WorktreeRemoveData = { + body?: WorktreeRemoveInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] + +export type WorktreeRemoveResponses = { + /** + * Worktree removed + */ + 200: boolean +} + +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] + +export type WorktreeListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeListResponses = { + /** + * List of worktree directories + */ + 200: Array +} + +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] + +export type WorktreeCreateData = { + body?: WorktreeCreateInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] + +export type WorktreeCreateResponses = { + /** + * Worktree created + */ + 200: Worktree +} + +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] + +export type WorktreeResetData = { + body?: WorktreeResetInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree/reset" +} + +export type WorktreeResetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] + +export type WorktreeResetResponses = { + /** + * Worktree reset + */ + 200: boolean +} + +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] + +export type ExperimentalSessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + } + url: "/experimental/session" +} + +export type ExperimentalSessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] + +export type ExperimentalResourceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/resource" +} + +export type ExperimentalResourceListResponses = { + /** + * MCP resources + */ + 200: { + [key: string]: McpResource + } +} + +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + +export type FindTextData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + pattern: string + } + url: "/find" +} + +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> +} + +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] + +export type FindFilesData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + } + url: "/find/file" +} + +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array +} + +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] + +export type FindSymbolsData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + } + url: "/find/symbol" +} + +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array +} + +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] + +export type FileListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file" +} + +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array +} + +export type FileListResponse = FileListResponses[keyof FileListResponses] + +export type FileReadData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file/content" +} + +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent +} + +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] + +export type FileStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/file/status" +} + +export type FileStatusResponses = { + /** + * File status + */ + 200: Array +} + +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] + +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + +export type PathGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/path" +} + +export type PathGetResponses = { + /** + * Path + */ + 200: Path +} + +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] + +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type VcsDiffData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + mode: "git" | "branch" + } + url: "/vcs/diff" +} + +export type VcsDiffResponses = { + /** + * VCS diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/command" +} + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type AppAgentsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/agent" +} + +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array +} + +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] + +export type AppSkillsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/skill" +} + +export type AppSkillsResponses = { + /** + * List of skills + */ + 200: Array<{ + name: string + description: string + location: string + content: string + }> +} + +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] + +export type LspStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp" +} + +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array +} + +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] + +export type FormatterStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/formatter" +} + +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array +} + +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type McpStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] + +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + authorizationUrl: string + oauthState: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + code: string + } + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + +export type McpConnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/connect" +} + +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean +} + +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] + +export type McpDisconnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/disconnect" +} + +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean +} + +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] export type ProjectListData = { body?: never @@ -2884,439 +4636,285 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] -export type PtyConnectData = { +export type QuestionListData = { body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/question" +} + +export type QuestionListResponses = { + /** + * List of pending questions + */ + 200: Array +} + +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] + +export type QuestionReplyData = { + body?: { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array + } path: { - ptyID: string + requestID: string } query?: { directory?: string workspace?: string } - url: "/pty/{ptyID}/connect" + url: "/question/{requestID}/reply" } -export type PtyConnectErrors = { +export type QuestionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ 404: NotFoundError } -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] -export type PtyConnectResponses = { +export type QuestionReplyResponses = { /** - * Connected session + * Question answered successfully */ 200: boolean } -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] -export type ConfigGetData = { +export type QuestionRejectData = { body?: never - path?: never + path: { + requestID: string + } query?: { directory?: string workspace?: string } - url: "/config" + url: "/question/{requestID}/reject" } -export type ConfigGetResponses = { - /** - * Get config info - */ - 200: Config -} - -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] - -export type ConfigUpdateData = { - body?: Config - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/config" -} - -export type ConfigUpdateErrors = { +export type QuestionRejectErrors = { /** * Bad request */ 400: BadRequestError -} - -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] - -export type ConfigUpdateResponses = { /** - * Successfully updated config + * Not found */ - 200: Config + 404: NotFoundError } -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] -export type ConfigProvidersData = { +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + +export type PermissionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config/providers" + url: "/permission" } -export type ConfigProvidersResponses = { +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + message?: string + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/permission/{requestID}/reply" +} + +export type PermissionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] + +export type PermissionReplyResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] + +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider" +} + +export type ProviderListResponses = { /** * List of providers */ 200: { - providers: Array + all: Array default: { [key: string]: string } + connected: Array } } -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] -export type ExperimentalConsoleGetData = { +export type ProviderAuthData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console" + url: "/provider/auth" } -export type ExperimentalConsoleGetResponses = { +export type ProviderAuthResponses = { /** - * Active Console provider metadata - */ - 200: ConsoleState -} - -export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] - -export type ExperimentalConsoleListOrgsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/console/orgs" -} - -export type ExperimentalConsoleListOrgsResponses = { - /** - * Switchable Console orgs + * Provider auth methods */ 200: { - orgs: Array<{ - accountID: string - accountEmail: string - accountUrl: string - orgID: string - orgName: string - active: boolean - }> + [key: string]: Array } } -export type ExperimentalConsoleListOrgsResponse = - ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] -export type ExperimentalConsoleSwitchOrgData = { +export type ProviderOauthAuthorizeData = { body?: { - accountID: string - orgID: string + /** + * Auth method index + */ + method: number + inputs?: { + [key: string]: string + } + } + path: { + providerID: string } - path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console/switch" + url: "/provider/{providerID}/oauth/authorize" } -export type ExperimentalConsoleSwitchOrgResponses = { +export type ProviderOauthAuthorizeErrors = { /** - * Switch success + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + code?: string + } + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/provider/{providerID}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully */ 200: boolean } -export type ExperimentalConsoleSwitchOrgResponse = - ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] - -export type ToolIdsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/tool/ids" -} - -export type ToolIdsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] - -export type ToolIdsResponses = { - /** - * Tool IDs - */ - 200: ToolIds -} - -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] - -export type ToolListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - provider: string - model: string - } - url: "/experimental/tool" -} - -export type ToolListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolListError = ToolListErrors[keyof ToolListErrors] - -export type ToolListResponses = { - /** - * Tools - */ - 200: ToolList -} - -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] - -export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] - -export type WorktreeRemoveResponses = { - /** - * Worktree removed - */ - 200: boolean -} - -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] - -export type WorktreeListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeListResponses = { - /** - * List of worktree directories - */ - 200: Array -} - -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] - -export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] - -export type WorktreeCreateResponses = { - /** - * Worktree created - */ - 200: Worktree -} - -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] - -export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/reset" -} - -export type WorktreeResetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] - -export type WorktreeResetResponses = { - /** - * Worktree reset - */ - 200: boolean -} - -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] - -export type ExperimentalSessionListData = { - body?: never - path?: never - query?: { - /** - * Filter sessions by project directory - */ - directory?: string - workspace?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ - start?: number - /** - * Return sessions updated before this timestamp (milliseconds since epoch) - */ - cursor?: number - /** - * Filter sessions by title (case-insensitive) - */ - search?: string - /** - * Maximum number of sessions to return - */ - limit?: number - /** - * Include archived sessions (default false) - */ - archived?: boolean | "true" | "false" - } - url: "/experimental/session" -} - -export type ExperimentalSessionListResponses = { - /** - * List of sessions - */ - 200: Array -} - -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] - -export type ExperimentalResourceListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/resource" -} - -export type ExperimentalResourceListResponses = { - /** - * MCP resources - */ - 200: { - [key: string]: McpResource - } -} - -export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] export type SessionListData = { body?: never path?: never query?: { - /** - * Filter sessions by directory - */ directory?: string workspace?: string - /** - * List all sessions for the current project - */ scope?: "project" - /** - * Filter sessions by project-relative path - */ path?: string - /** - * Only return root sessions (no parentID) - */ roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ start?: number - /** - * Filter sessions by title (case-insensitive) - */ search?: string - /** - * Maximum number of sessions to return - */ limit?: number } url: "/session" @@ -3335,6 +4933,12 @@ export type SessionCreateData = { body?: { parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string } @@ -3570,169 +5174,6 @@ export type SessionTodoResponses = { export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] -export type SessionInitData = { - body?: { - modelID: string - providerID: string - messageID: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/init" -} - -export type SessionInitErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] - -export type SessionInitResponses = { - /** - * 200 - */ - 200: boolean -} - -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] - -export type SessionForkData = { - body?: { - messageID?: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/fork" -} - -export type SessionForkResponses = { - /** - * 200 - */ - 200: Session -} - -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] - -export type SessionAbortData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/abort" -} - -export type SessionAbortErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] - -export type SessionAbortResponses = { - /** - * Aborted session - */ - 200: boolean -} - -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] - -export type SessionUnshareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionUnshareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] - -export type SessionUnshareResponses = { - /** - * Successfully unshared session - */ - 200: Session -} - -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] - -export type SessionShareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionShareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] - -export type SessionShareResponses = { - /** - * Successfully shared session - */ - 200: Session -} - -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] - export type SessionDiffData = { body?: never path: { @@ -3755,44 +5196,6 @@ export type SessionDiffResponses = { export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] -export type SessionSummarizeData = { - body?: { - providerID: string - modelID: string - auto?: boolean - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/summarize" -} - -export type SessionSummarizeErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] - -export type SessionSummarizeResponses = { - /** - * Summarized session - */ - 200: boolean -} - -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] - export type SessionMessagesData = { body?: never path: { @@ -3801,9 +5204,6 @@ export type SessionMessagesData = { query?: { directory?: string workspace?: string - /** - * Maximum number of messages to return - */ limit?: number before?: string } @@ -3844,9 +5244,6 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -3963,21 +5360,42 @@ export type SessionMessageResponses = { export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] -export type PartDeleteData = { - body?: never +export type SessionForkData = { + body?: { + messageID?: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/fork" } -export type PartDeleteErrors = { +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] + +export type SessionAbortData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/abort" +} + +export type SessionAbortErrors = { /** * Bad request */ @@ -3988,32 +5406,34 @@ export type PartDeleteErrors = { 404: NotFoundError } -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] -export type PartDeleteResponses = { +export type SessionAbortResponses = { /** - * Successfully deleted part + * Aborted session */ 200: boolean } -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] -export type PartUpdateData = { - body?: Part +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/init" } -export type PartUpdateErrors = { +export type SessionInitErrors = { /** * Bad request */ @@ -4024,16 +5444,122 @@ export type PartUpdateErrors = { 404: NotFoundError } -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] -export type PartUpdateResponses = { +export type SessionInitResponses = { /** - * Successfully updated part + * 200 */ - 200: Part + 200: boolean } -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] + +export type SessionUnshareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] + +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] + +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session +} + +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] + +export type SessionSummarizeData = { + body?: { + providerID: string + modelID: string + auto?: boolean + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/summarize" +} + +export type SessionSummarizeErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] + +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean +} + +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] export type SessionPromptAsyncData = { body?: { @@ -4044,9 +5570,6 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -4292,22 +5815,21 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] -export type PermissionReplyData = { - body?: { - reply: "once" | "always" | "reject" - message?: string - } +export type PartDeleteData = { + body?: never path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/permission/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type PermissionReplyErrors = { +export type PartDeleteErrors = { /** * Bad request */ @@ -4318,73 +5840,32 @@ export type PermissionReplyErrors = { 404: NotFoundError } -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] -export type PermissionReplyResponses = { +export type PartDeleteResponses = { /** - * Permission processed successfully + * Successfully deleted part */ 200: boolean } -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] -export type PermissionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/permission" -} - -export type PermissionListResponses = { - /** - * List of pending permissions - */ - 200: Array -} - -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] - -export type QuestionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/question" -} - -export type QuestionListResponses = { - /** - * List of pending questions - */ - 200: Array -} - -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] - -export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } +export type PartUpdateData = { + body?: Part path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/question/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type QuestionReplyErrors = { +export type PartUpdateErrors = { /** * Bad request */ @@ -4395,182 +5876,16 @@ export type QuestionReplyErrors = { 404: NotFoundError } -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] -export type QuestionReplyResponses = { +export type PartUpdateResponses = { /** - * Question answered successfully + * Successfully updated part */ - 200: boolean + 200: Part } -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] - -export type QuestionRejectData = { - body?: never - path: { - requestID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reject" -} - -export type QuestionRejectErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] - -export type QuestionRejectResponses = { - /** - * Question rejected successfully - */ - 200: boolean -} - -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] - -export type ProviderListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider" -} - -export type ProviderListResponses = { - /** - * List of providers - */ - 200: { - all: Array - default: { - [key: string]: string - } - connected: Array - } -} - -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] - -export type ProviderAuthData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} - -export type ProviderAuthResponses = { - /** - * Provider auth methods - */ - 200: { - [key: string]: Array - } -} - -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] - -export type ProviderOauthAuthorizeData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * Prompt inputs - */ - inputs?: { - [key: string]: string - } - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/authorize" -} - -export type ProviderOauthAuthorizeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] - -export type ProviderOauthAuthorizeResponses = { - /** - * Authorization URL and method - */ - 200: ProviderAuthAuthorization -} - -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] - -export type ProviderOauthCallbackData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * OAuth authorization code - */ - code?: string - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/callback" -} - -export type ProviderOauthCallbackErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] - -export type ProviderOauthCallbackResponses = { - /** - * OAuth callback processed successfully - */ - 200: boolean -} - -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] export type SyncStartData = { body?: never @@ -4670,402 +5985,150 @@ export type SyncHistoryListResponses = { export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] -export type FindTextData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - pattern: string - } - url: "/find" -} - -export type FindTextResponses = { - /** - * Matches - */ - 200: Array<{ - path: { - text: string - } - lines: { - text: string - } - line_number: number - absolute_offset: number - submatches: Array<{ - match: { - text: string - } - start: number - end: number - }> - }> -} - -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] - -export type FindFilesData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} - -export type FindFilesResponses = { - /** - * File paths - */ - 200: Array -} - -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] - -export type FindSymbolsData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - } - url: "/find/symbol" -} - -export type FindSymbolsResponses = { - /** - * Symbols - */ - 200: Array -} - -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] - -export type FileListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} - -export type FileListResponses = { - /** - * Files and directories - */ - 200: Array -} - -export type FileListResponse = FileListResponses[keyof FileListResponses] - -export type FileReadData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file/content" -} - -export type FileReadResponses = { - /** - * File content - */ - 200: FileContent -} - -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] - -export type FileStatusData = { +export type V2SessionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/file/status" + url: "/api/session" } -export type FileStatusResponses = { - /** - * File status - */ - 200: Array -} - -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] - -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] - -export type McpStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpStatusResponses = { - /** - * MCP server status - */ - 200: { - [key: string]: McpStatus - } -} - -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] - -export type McpAddData = { - body?: { - name: string - config: McpLocalConfig | McpRemoteConfig - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpAddErrors = { +export type V2SessionListErrors = { /** * Bad request */ 400: BadRequestError } -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] -export type McpAddResponses = { +export type V2SessionListResponses = { /** - * MCP server added successfully + * V2SessionsResponse */ - 200: { - [key: string]: McpStatus - } + 200: V2SessionsResponse } -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] -export type McpAuthRemoveData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthRemoveErrors = { - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] - -export type McpAuthRemoveResponses = { - /** - * OAuth credentials removed - */ - 200: { - success: true - } -} - -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] - -export type McpAuthStartData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthStartErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] - -export type McpAuthStartResponses = { - /** - * OAuth flow started - */ - 200: { - /** - * URL to open in browser for authorization - */ - authorizationUrl: string - } -} - -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] - -export type McpAuthCallbackData = { +export type V2SessionPromptData = { body?: { - /** - * Authorization code from OAuth callback - */ - code: string + prompt: Prompt + delivery?: SessionDelivery } path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth/callback" + url: "/api/session/{sessionID}/prompt" } -export type McpAuthCallbackErrors = { +export type V2SessionPromptResponses = { + /** + * Session.Message + */ + 200: SessionMessage +} + +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] + +export type V2SessionCompactData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/compact" +} + +export type V2SessionCompactResponses = { + /** + * + */ + 204: void +} + +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] + +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] + +export type V2SessionContextData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/context" +} + +export type V2SessionContextResponses = { + /** + * Success + */ + 200: Array +} + +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] + +export type V2SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/message" +} + +export type V2SessionMessagesErrors = { /** * Bad request */ 400: BadRequestError +} + +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { /** - * Not found + * V2SessionMessagesResponse */ - 404: NotFoundError + 200: V2SessionMessagesResponse } -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] - -export type McpAuthCallbackResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] - -export type McpAuthAuthenticateData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/authenticate" -} - -export type McpAuthAuthenticateErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] - -export type McpAuthAuthenticateResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] - -export type McpConnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/connect" -} - -export type McpConnectResponses = { - /** - * MCP server connected successfully - */ - 200: boolean -} - -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] - -export type McpDisconnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/disconnect" -} - -export type McpDisconnectResponses = { - /** - * MCP server disconnected successfully - */ - 200: boolean -} - -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] export type TuiAppendPromptData = { body?: { @@ -5246,9 +6309,6 @@ export type TuiShowToastData = { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } path?: never @@ -5269,7 +6329,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 path?: never query?: { directory?: string @@ -5374,179 +6434,202 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] -export type InstanceDisposeData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/instance/dispose" + url: "/experimental/workspace/adapter" } -export type InstanceDisposeResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Instance disposed + * Workspace adapters + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra?: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}/session-restore" +} + +export type ExperimentalWorkspaceSessionRestoreErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] + +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + +export type PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect" +} + +export type PtyConnectErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session */ 200: boolean } -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] - -export type PathGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/path" -} - -export type PathGetResponses = { - /** - * Path - */ - 200: Path -} - -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] - -export type VcsGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} - -export type VcsGetResponses = { - /** - * VCS info - */ - 200: VcsInfo -} - -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] - -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - -export type CommandListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/command" -} - -export type CommandListResponses = { - /** - * List of commands - */ - 200: Array -} - -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] - -export type AppAgentsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/agent" -} - -export type AppAgentsResponses = { - /** - * List of agents - */ - 200: Array -} - -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] - -export type AppSkillsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/skill" -} - -export type AppSkillsResponses = { - /** - * List of skills - */ - 200: Array<{ - name: string - description: string - location: string - content: string - }> -} - -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] - -export type LspStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/lsp" -} - -export type LspStatusResponses = { - /** - * LSP server status - */ - 200: Array -} - -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] - -export type FormatterStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/formatter" -} - -export type FormatterStatusResponses = { - /** - * Formatter status - */ - 200: Array -} - -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md new file mode 100644 index 000000000..20d84c8f4 --- /dev/null +++ b/specs/v2/session-concepts-gap.md @@ -0,0 +1,131 @@ +# Session V2 Concept Gaps + +Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. + +## Message Metadata + +- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. +- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. + +## Output Format + +- Text output format. +- JSON-schema output format. +- Structured-output retry count. +- Structured assistant result payload. +- Structured-output error classification. + +## Errors + +- Aborted error. +- Provider auth error. +- API error with status, retryability, headers, body, and metadata. +- Context-overflow error. +- Output-length error. +- Unknown error. +- V2 mostly reduces assistant errors to strings, except retry errors. + +## Part Identity + +- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. +- V2 assistant content does not preserve stable per-content IDs. +- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. + +## Part Timing And Metadata + +- V1 text, reasoning, and tool states carry timing and provider metadata. +- V2 assistant text and reasoning content only store text. +- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. + +## Snapshots And Patches + +- Snapshot parts. +- Patch parts. +- Step-start snapshot references. +- Step-finish snapshot references. +- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. + +## Step Boundaries + +- V1 stores `step-start` and `step-finish` as first-class parts. +- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. +- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. + +## Compaction + +- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. +- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. +- V1 also has history filtering semantics around completed summary messages and retained tails. + +## Files And Sources + +- V1 file parts have `mime`, `filename`, `url`, and typed source information. +- V1 source variants include file, symbol, and resource sources. +- Symbol sources include LSP range, name, and kind. +- Resource sources include client name and URI. +- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. + +## Agents And Subtasks + +- Agent parts. +- Subtask parts. +- Subtask prompt, description, agent, model, and command. +- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. + +## Text Flags + +- Synthetic text flag. +- Ignored text flag. +- V2 has a separate synthetic entry, but no ignored text concept. + +## Tool Calls + +- V1 pending tool state stores parsed input and raw input text separately. +- V2 pending tool state stores a string input but does not preserve a separate raw field. +- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. +- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. +- V1 error tool state has `time.start` and `time.end`. +- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. +- V1 tracks provider execution and provider call metadata. +- V2 events include provider info, but `SessionEntryStepper` drops it from entries. +- V1 has tool-output compaction and truncation behavior via `time.compacted`. + +## Media Handling + +- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. +- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. +- V2 has attachments but not these model-message conversion semantics. + +## Retries + +- V1 stores retries as independently addressable retry parts. +- V2 stores retries as an assistant aggregate. +- V2 captures some retry information, but not the independent part identity/update model. + +## Processor Control Flow + +- Session status transitions: busy, retry, and idle. +- Retry policy integration. +- Context-overflow-driven compaction. +- Abort and interrupt handling. +- Permission-denied blocking. +- Doom-loop detection. +- Plugin hook for `experimental.text.complete`. +- Background summary generation after steps. +- Cleanup semantics for open text, reasoning, and tool calls. + +## Sync And Bus Events + +- Message updated. +- Message removed. +- Message part updated. +- Message part delta. +- Message part removed. +- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. + +## History Retrieval + +- Cursor encoding and decoding. +- Paged message retrieval. +- Reverse streaming through history. +- Compaction-aware history filtering. From a6cadba81432997fb3ca5c848f7586c6f7b8d48b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:10:52 +0000 Subject: [PATCH 079/174] chore: generate --- .../snapshot.json | 144 +- .../snapshot.json | 142 +- .../20260501142318_next_venus/snapshot.json | 144 +- .../src/server/routes/instance/event.ts | 8 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 6 +- .../src/v2/session-message-updater.ts | 4 +- .../test/server/httpapi-session.test.ts | 3 +- packages/sdk/openapi.json | 2798 ++++++++++++++++- 9 files changed, 2875 insertions(+), 376 deletions(-) diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json index bb6d06237..a237b4156 100644 --- a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "61f807f9-6398-4067-be05-804acc2561bc", - "prevIds": [ - "66cbe0d7-def0-451b-b88a-7608513a9b44" - ], + "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], "ddl": [ { "name": "account_state", @@ -1043,13 +1041,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1058,13 +1052,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1073,13 +1063,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1088,13 +1074,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1103,13 +1085,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1118,13 +1096,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1133,13 +1107,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1148,13 +1118,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1163,13 +1129,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1178,13 +1140,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1193,128 +1151,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1478,4 +1406,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index 1f3bc493c..740ba0e25 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": [ - "61f807f9-6398-4067-be05-804acc2561bc" - ], + "prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"], "ddl": [ { "name": "account_state", @@ -1053,13 +1051,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1068,13 +1062,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1083,13 +1073,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1098,13 +1084,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1113,13 +1095,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1128,13 +1106,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1143,13 +1117,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1158,13 +1128,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1173,13 +1139,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1188,13 +1150,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1203,128 +1161,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json index e594de2f0..1eb0cf0b0 100644 --- a/packages/opencode/migration/20260501142318_next_venus/snapshot.json +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", - "prevIds": [ - "aaa2ebeb-caa4-478d-8365-4fc595d16856" - ], + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], "ddl": [ { "name": "account_state", @@ -1073,13 +1071,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1088,13 +1082,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1103,13 +1093,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1118,13 +1104,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1133,13 +1115,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1148,13 +1126,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1163,13 +1137,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1178,13 +1148,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1193,13 +1159,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1208,13 +1170,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1223,128 +1181,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1508,4 +1436,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 52e9bc196..aeb1da539 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -51,10 +51,10 @@ export const EventRoutes = () => // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, }), ) }, 10_000) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1a32a656d..e2a47f180 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -377,7 +377,7 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { + EventV2.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, structured: value.output.metadata, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index fedfa8996..09d2c8c3c 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -81,7 +81,11 @@ export function fromRow(row: SessionRow): Info { title: row.title, agent: row.agent ?? undefined, model: row.model - ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant } + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } : undefined, version: row.version, summary, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 844f6fe2d..ad1aa32e7 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -89,9 +89,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve assistant?.content.findLast((item): item is DraftText => item.type === "text") const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => - assistant?.content.findLast( - (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID, - ) + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d96347bed..c9a0b53bb 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -242,7 +242,8 @@ describe("session HttpApi", () => { ) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items, + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) + .items, ).toMatchObject([{ type: "assistant" }]) }), ), diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 208346325..b1c4ec1d7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2461,6 +2461,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -7595,6 +7613,9 @@ "Event.server.instance.disposed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "server.instance.disposed" @@ -7609,11 +7630,14 @@ "required": ["directory"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.file.edited": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "file.edited" @@ -7628,11 +7652,14 @@ "required": ["file"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.file.watcher.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "file.watcher.updated" @@ -7651,11 +7678,14 @@ "required": ["file", "event"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "lsp.client.diagnostics" @@ -7673,11 +7703,14 @@ "required": ["serverID", "path"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.lsp.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "lsp.updated" @@ -7687,11 +7720,14 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.part.delta": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.delta" @@ -7721,7 +7757,7 @@ "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "PermissionRequest": { "type": "object", @@ -7775,6 +7811,9 @@ "Event.permission.asked": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "permission.asked" @@ -7783,11 +7822,14 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.permission.replied": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "permission.replied" @@ -7811,7 +7853,7 @@ "required": ["sessionID", "requestID", "reply"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SnapshotFileDiff": { "type": "object", @@ -7842,6 +7884,9 @@ "Event.session.diff": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.diff" @@ -7863,7 +7908,7 @@ "required": ["sessionID", "diff"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "ProviderAuthError": { "type": "object", @@ -8036,6 +8081,9 @@ "Event.session.error": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.error" @@ -8075,11 +8123,14 @@ } } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.installation.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "installation.updated" @@ -8094,11 +8145,14 @@ "required": ["version"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.installation.update-available": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "installation.update-available" @@ -8113,7 +8167,7 @@ "required": ["version"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionOption": { "type": "object", @@ -8198,6 +8252,9 @@ "Event.question.asked": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.asked" @@ -8206,7 +8263,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -8237,6 +8294,9 @@ "Event.question.replied": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.replied" @@ -8245,7 +8305,7 @@ "$ref": "#/components/schemas/QuestionReplied" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionRejected": { "type": "object", @@ -8264,6 +8324,9 @@ "Event.question.rejected": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.rejected" @@ -8272,7 +8335,7 @@ "$ref": "#/components/schemas/QuestionRejected" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Todo": { "type": "object", @@ -8295,6 +8358,9 @@ "Event.todo.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "todo.updated" @@ -8316,7 +8382,7 @@ "required": ["sessionID", "todos"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -8368,6 +8434,9 @@ "Event.session.status": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.status" @@ -8386,11 +8455,14 @@ "required": ["sessionID", "status"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.idle": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.idle" @@ -8406,11 +8478,14 @@ "required": ["sessionID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.compacted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.compacted" @@ -8426,7 +8501,7 @@ "required": ["sessionID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -8547,6 +8622,9 @@ "Event.mcp.tools.changed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "mcp.tools.changed" @@ -8561,11 +8639,14 @@ "required": ["server"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "mcp.browser.open.failed" @@ -8583,11 +8664,14 @@ "required": ["mcpName", "url"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.command.executed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "command.executed" @@ -8613,7 +8697,7 @@ "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Project": { "type": "object", @@ -8687,6 +8771,9 @@ "Event.project.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "project.updated" @@ -8695,11 +8782,14 @@ "$ref": "#/components/schemas/Project" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "vcs.branch.updated" @@ -8713,11 +8803,14 @@ } } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.ready": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.ready" @@ -8732,11 +8825,14 @@ "required": ["name"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.failed" @@ -8751,11 +8847,14 @@ "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.restore": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.restore" @@ -8785,11 +8884,14 @@ "required": ["workspaceID", "sessionID", "total", "step"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.status": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.status" @@ -8809,11 +8911,14 @@ "required": ["workspaceID", "status"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.worktree.ready": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "worktree.ready" @@ -8831,11 +8936,14 @@ "required": ["name", "branch"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.worktree.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "worktree.failed" @@ -8850,7 +8958,7 @@ "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Pty": { "type": "object", @@ -8889,6 +8997,9 @@ "Event.pty.created": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.created" @@ -8903,11 +9014,14 @@ "required": ["info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.updated" @@ -8922,11 +9036,14 @@ "required": ["info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.exited": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.exited" @@ -8947,11 +9064,14 @@ "required": ["id", "exitCode"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.deleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.deleted" @@ -8967,7 +9087,7 @@ "required": ["id"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "OutputFormatText": { "type": "object", @@ -9263,6 +9383,9 @@ "Event.message.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.updated" @@ -9281,11 +9404,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.removed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.removed" @@ -9305,7 +9431,7 @@ "required": ["sessionID", "messageID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "TextPart": { "type": "object", @@ -10147,6 +10273,9 @@ "Event.message.part.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.updated" @@ -10170,11 +10299,14 @@ "required": ["sessionID", "part", "time"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.part.removed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.removed" @@ -10198,7 +10330,7 @@ "required": ["sessionID", "messageID", "partID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "PermissionAction": { "type": "string", @@ -10291,6 +10423,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "version": { "type": "string" }, @@ -10347,6 +10497,9 @@ "Event.session.created": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.created" @@ -10365,11 +10518,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.updated" @@ -10388,11 +10544,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.deleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.deleted" @@ -10411,11 +10570,1082 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] + }, + "Event.session.next.agent.switched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.agent.switched" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.model.switched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.model.switched" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Prompt.Source": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"] + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["uri", "mime"] + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["name"] + }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": ["text"] + }, + "Event.session.next.prompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.prompted" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.synthetic" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.shell.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.shell.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.shell.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.step.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.step.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.step.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.step.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reasoning": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "write": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["timestamp", "sessionID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.called": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.called" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"] + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"] + }, + "Event.session.next.tool.progress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.progress" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.success": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.success" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.error" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "session.next.retry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"] + }, + "Event.session.next.retried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.retried" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] }, "Event.server.connected": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "server.connected" @@ -10425,11 +11655,14 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.global.disposed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "global.disposed" @@ -10439,7 +11672,7 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SyncEvent.message.updated": { "type": "object", @@ -10800,6 +12033,38 @@ } ] }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + { + "type": "null" + } + ] + }, "version": { "anyOf": [ { @@ -10943,6 +12208,1210 @@ }, "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, + "SyncEvent.session.next.agent.switched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.agent.switched.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.model.switched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.model.switched.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.prompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.prompted.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.synthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.synthetic.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.shell.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.shell.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.shell.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.shell.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.step.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.step.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.step.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.step.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reasoning": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "write": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["timestamp", "sessionID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.called": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.called.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.progress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.progress.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.success": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.success.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.error.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.retried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.retried.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, "GlobalEvent": { "type": "object", "properties": { @@ -11092,6 +13561,81 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.prompted" + }, + { + "$ref": "#/components/schemas/Event.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/Event.session.next.retried" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.ended" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -11118,6 +13662,81 @@ }, { "$ref": "#/components/schemas/SyncEvent.session.deleted" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.prompted" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.step.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.retried" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.ended" } ] } @@ -12749,6 +15368,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "version": { "type": "string" }, @@ -13387,6 +16024,81 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.prompted" + }, + { + "$ref": "#/components/schemas/Event.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/Event.session.next.retried" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.ended" + }, { "$ref": "#/components/schemas/Event.server.connected" }, From ad05a46d747bad0c03a511ccef1115ee95a997c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:26:54 -0400 Subject: [PATCH 080/174] refactor(lifecycle): bootstrap as pure orchestration (#25510) --- packages/opencode/src/file/watcher.ts | 6 ++-- packages/opencode/src/project/bootstrap.ts | 18 +++++------- packages/opencode/src/project/project.ts | 29 ++++++++++++++++++- .../opencode/test/project/project.test.ts | 2 ++ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b68c3a335..146d7b4d0 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -123,7 +123,9 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) + yield* Effect.forkScoped( + subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]), + ) } if (ctx.project.vcs === "git") { @@ -135,7 +137,7 @@ export const layer = Layer.effect( const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) - yield* subscribe(vcsDir, ignore) + yield* Effect.forkScoped(subscribe(vcsDir, ignore)) } } }, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ea2aa2e84..fb3e1bb32 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" -import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" @@ -23,13 +22,13 @@ export const layer = Layer.effect( // Yield each bootstrap dep at layer init so `run` itself has R = never. // InstanceStore imports only the lightweight tag from bootstrap-service.ts, // so it can depend on bootstrap without importing this implementation graph. - const bus = yield* Bus.Service const config = yield* Config.Service const file = yield* File.Service const fileWatcher = yield* FileWatcher.Service const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service + const project = yield* Project.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -41,16 +40,13 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - yield* Effect.all( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + // Each service self-manages its own slow work via Effect.forkScoped against + // its per-instance state scope. We just await materialization here. + yield* Effect.forEach( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), + { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) - - const projectID = ctx.project.id - yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }) }).pipe(Effect.withSpan("InstanceBootstrap")) return Service.of({ run }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f30d2e90c..a2c1a097b 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" @@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable Effect.Effect readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect @@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service > = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const bus = yield* Bus.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -417,6 +427,21 @@ export const layer: Layer.Layer< ) }) + const initState = yield* InstanceState.make( + Effect.fn("Project.initState")(function* (ctx) { + yield* bus.subscribe(Command.Event.Executed).pipe( + Stream.runForEach((payload) => + payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void, + ), + Effect.forkScoped, + ) + }), + ) + + const init = Effect.fn("Project.init")(function* () { + yield* InstanceState.get(initState) + }) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] @@ -466,6 +491,7 @@ export const layer: Layer.Layer< }) return Service.of({ + init, fromDirectory, discover, list, @@ -481,6 +507,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( + Layer.provide(Bus.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e69b8e6df..9906b3164 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Bus } from "@/bus" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) { function projectLayerWithFailure(failArg: string) { return Project.layer.pipe( Layer.provide(mockGitFailure(failArg)), + Layer.provide(Bus.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) From c4311dda3125256e1207a8d1f130e8d0d3fde7b2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:27:41 -0400 Subject: [PATCH 081/174] feat(cli): allow effectCmd instance to be a function of args (#25517) --- packages/opencode/src/cli/effect-cmd.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 94ad0232c..ceb52d07a 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -37,10 +37,14 @@ interface EffectCmdOpts { * directly under AppRuntime — it can yield any `AppServices` but must not * yield `InstanceRef` (it'd be undefined, causing a defect). * + * Function form: `(args) => boolean` decides per-invocation. Useful for + * commands like `run --attach ` where one flag flips between local + * (needs instance) and remote (doesn't). + * * Use `false` for commands that don't read project state (e.g. `models`, * `serve`, `web`, `account`, `db`, `upgrade`). */ - instance?: boolean + instance?: boolean | ((args: Args) => boolean) /** Defaults to process.cwd(). Override for commands that take a directory positional. */ directory?: (args: Args) => string handler: (args: Args) => Effect.Effect @@ -72,7 +76,8 @@ export const effectCmd = (opts: EffectCmdOpts) => async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args - if (opts.instance === false) { + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { await AppRuntime.runPromise(opts.handler(args)) return } From 2829943ad15da5cd736a7f70f45f54daf488bcdd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:31:20 -0400 Subject: [PATCH 082/174] refactor(cli): convert debug wait, agent list, acp to effectCmd (#25518) --- packages/opencode/src/cli/cmd/acp.ts | 97 ++++++++++---------- packages/opencode/src/cli/cmd/agent.ts | 35 ++++--- packages/opencode/src/cli/cmd/debug/index.ts | 21 +++-- 3 files changed, 76 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 9095984fe..87671f5a0 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,6 +1,6 @@ import * as Log from "@opencode-ai/core/util/log" -import { bootstrap } from "../bootstrap" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" @@ -9,7 +9,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) -export const AcpCommand = cmd({ +export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { @@ -19,52 +19,53 @@ export const AcpCommand = cmd({ default: process.cwd(), }) }, - handler: async (args) => { + handler: Effect.fn("Cli.acp")(function* (args) { process.env.OPENCODE_CLIENT = "acp" - await bootstrap(process.cwd(), async () => { - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) - const sdk = createOpencodeClient({ - baseUrl: `http://${server.hostname}:${server.port}`, - }) - - const input = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - process.stdout.write(chunk, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }, - }) - const output = new ReadableStream({ - start(controller) { - process.stdin.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)) - }) - process.stdin.on("end", () => controller.close()) - process.stdin.on("error", (err) => controller.error(err)) - }, - }) - - const stream = ndJsonStream(input, output) - const agent = await ACP.init({ sdk }) - - new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) - }, stream) - - log.info("setup connection") - process.stdin.resume() - await new Promise((resolve, reject) => { - process.stdin.on("end", resolve) - process.stdin.on("error", reject) - }) + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, }) - }, + + const input = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }, + }) + const output = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (err) => controller.error(err)) + }, + }) + + const stream = ndJsonStream(input, output) + const agent = yield* Effect.promise(() => ACP.init({ sdk })) + + new AgentSideConnection((conn) => { + return agent.create(conn, { sdk }) + }, stream) + + log.info("setup connection") + process.stdin.resume() + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + process.stdin.on("end", () => resolve()) + process.stdin.on("error", reject) + }), + ) + }), }) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 11a6c7f43..401126949 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -13,6 +13,8 @@ import { Instance } from "../../project/instance" import { WithInstance } from "../../project/with-instance" import { EOL } from "os" import type { Argv } from "yargs" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" type AgentMode = "all" | "primary" | "subagent" @@ -233,28 +235,23 @@ const AgentCreateCommand = cmd({ }, }) -const AgentListCommand = cmd({ +const AgentListCommand = effectCmd({ command: "list", describe: "list all available agents", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const sortedAgents = agents.sort((a, b) => { - if (a.native !== b.native) { - return a.native ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})` + EOL) - process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) - } - }, + handler: Effect.fn("Cli.agent.list")(function* () { + const agents = yield* Agent.Service.use((svc) => svc.list()) + const sortedAgents = agents.sort((a, b) => { + if (a.native !== b.native) { + return a.native ? -1 : 1 + } + return a.name.localeCompare(b.name) }) - }, + + for (const agent of sortedAgents) { + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) + } + }), }) export const AgentCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 194e66b1f..2603663fb 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,6 @@ import { Global } from "@opencode-ai/core/global" -import { bootstrap } from "../../bootstrap" +import { Duration, Effect } from "effect" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" @@ -26,19 +27,19 @@ export const DebugCommand = cmd({ .command(StartupCommand) .command(AgentCommand) .command(PathsCommand) - .command({ - command: "wait", - describe: "wait indefinitely (for debugging)", - async handler() { - await bootstrap(process.cwd(), async () => { - await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) - }) - }, - }) + .command(WaitCommand) .demandCommand(), async handler() {}, }) +const WaitCommand = effectCmd({ + command: "wait", + describe: "wait indefinitely (for debugging)", + handler: Effect.fn("Cli.debug.wait")(function* () { + yield* Effect.sleep(Duration.days(1)) + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", From 7409dcc6bdd2329c12b7053d0476bd8802747e7f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:35:20 -0400 Subject: [PATCH 083/174] refactor(cli): convert run command to effectCmd (#25519) --- packages/opencode/src/cli/cmd/cmd.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 37 ++++++++++++++----------- packages/opencode/src/cli/effect-cmd.ts | 6 ++-- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index fe6d62d7b..05af009b8 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[] } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f73ca6717..72096dba3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,10 +1,10 @@ import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" -import { bootstrap } from "../bootstrap" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -203,11 +203,17 @@ function normalizePath(input?: string) { return input } -export const RunCommand = cmd({ +export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - builder: (yargs: Argv) => { - return yargs + // --attach connects to a remote server (no local instance needed); the + // default path runs an in-process server and needs the project instance. + instance: (args) => !args.attach, + // For --dir without --attach, load instance for the resolved target dir. + // The handler also chdirs (preserving the legacy order: chdir → file resolution). + directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), + builder: (yargs: Argv) => + yargs .positional("message", { describe: "message to send", type: "string", @@ -291,9 +297,9 @@ export const RunCommand = cmd({ type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.run")(function* (args) { + yield* Effect.promise(async () => { let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") @@ -661,13 +667,12 @@ export const RunCommand = cmd({ return await execute(sdk) } - await bootstrap(process.cwd(), async () => { - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) }) - }, + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index ceb52d07a..b0f6de16b 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" -import { cmd } from "./cmd/cmd" +import { cmd, type WithDoubleDash } from "./cmd/cmd" /** * User-visible command failure. Throw via `fail("...")` from an effectCmd handler @@ -47,7 +47,7 @@ interface EffectCmdOpts { instance?: boolean | ((args: Args) => boolean) /** Defaults to process.cwd(). Override for commands that take a directory positional. */ directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect + handler: (args: WithDoubleDash) => Effect.Effect } /** @@ -75,7 +75,7 @@ export const effectCmd = (opts: EffectCmdOpts) => builder: opts.builder as never, async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. - const args = rawArgs as unknown as Args + const args = rawArgs as unknown as WithDoubleDash const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false if (!useInstance) { await AppRuntime.runPromise(opts.handler(args)) From 61150f63917a893e1b09c9eb1dbced7c7131fb34 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:36:41 +0000 Subject: [PATCH 084/174] chore: generate --- packages/opencode/src/cli/cmd/run.ts | 654 +++++++++++++-------------- 1 file changed, 327 insertions(+), 327 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 72096dba3..75f68e8ea 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -300,288 +300,310 @@ export const RunCommand = effectCmd({ }), handler: Effect.fn("Cli.run")(function* (args) { yield* Effect.promise(async () => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") + let message = [...args.message, ...(args["--"] || [])] + .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir - try { - process.chdir(args.dir) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) process.exit(1) } + })() - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + if (args.file) { + const list = Array.isArray(args.file) ? args.file : [args.file] - files.push({ - type: "file", - url: pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) - } - } - - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - - if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session - - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id - } - - if (baseID) return baseID - - const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id - } - - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) - } - } - - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } - } - - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) - return true - } - return false - } - - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { - const toggles = new Map() - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + for (const filePath of list) { + const resolvedPath = path.resolve(process.cwd(), filePath) + if (!(await Filesystem.exists(resolvedPath))) { + UI.error(`File not found: ${filePath}`) + process.exit(1) } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue + const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue + files.push({ + type: "file", + url: pathToFileURL(resolvedPath).href, + filename: path.basename(resolvedPath), + mime, + }) + } + } + + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + + if (message.trim().length === 0 && !args.command) { + UI.error("You must provide a message or a command") + process.exit(1) + } + + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exit(1) + } + + const rules: Permission.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + + if (baseID && args.fork) { + const forked = await sdk.session.fork({ sessionID: baseID }) + return forked.data?.id + } + + if (baseID) return baseID + + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } + + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + } + } + + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + try { + if (part.tool === ShellID.ToolID) return shell(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } catch { + return fallback(part) + } + } + + function emit(type: string, data: Record) { + if (args.format === "json") { + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + return true + } + return false + } + + const events = await sdk.event.subscribe() + let error: string | undefined + + async function loop() { + const toggles = new Map() + + for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) + } + + if (part.type === "step-start") { + if (emit("step_start", { part })) continue + } + + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } + + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) + } + } + + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) } if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) + break } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } - - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) } - UI.empty() - UI.println(text) - UI.empty() - } - - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) } } } - } - // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + // Validate agent if specified + const agent = await (async () => { + if (!args.agent) return undefined + const name = args.agent - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + // When attaching, validate against the running server instead of local Instance state. + if (args.attach) { + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name } - const agent = modes.find((a) => a.name === name) - if (!agent) { + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -589,8 +611,7 @@ export const RunCommand = effectCmd({ ) return undefined } - - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -598,81 +619,60 @@ export const RunCommand = effectCmd({ ) return undefined } - return name + })() + + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) } + await share(sdk, sessionID) - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], + loop().catch((e) => { + console.error(e) + process.exit(1) }) + + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + } else { + const model = args.model ? Provider.parseModel(args.model) : undefined + await sdk.session.prompt({ + sessionID, + agent, + model, + variant: args.variant, + parts: [...files, { type: "text", text: message }], + }) + } } - } - if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) - } + if (args.attach) { + const headers = (() => { + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return { Authorization: auth } + })() + const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + return await execute(sdk) + } - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) }) }), }) From 0956b15c52fdf6741334e5f87109ac95e7870abf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:38:44 -0400 Subject: [PATCH 085/174] refactor(acp): drop async from synchronous ACP.init (#25520) --- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/cli/cmd/acp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8bbc2427f..d66c1b258 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -130,7 +130,7 @@ async function sendUsageUpdate( }) } -export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) { +export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { return { create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { return new Agent(connection, fullConfig) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 87671f5a0..251c60884 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -52,7 +52,7 @@ export const AcpCommand = effectCmd({ }) const stream = ndJsonStream(input, output) - const agent = yield* Effect.promise(() => ACP.init({ sdk })) + const agent = ACP.init({ sdk }) new AgentSideConnection((conn) => { return agent.create(conn, { sdk }) From 0ba013f8deb89d049fb6be645be652726741ceff Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 21:43:48 -0500 Subject: [PATCH 086/174] chore: rm log statement (#25470) --- packages/opencode/src/permission/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 3fedd41d2..d93670709 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -144,7 +144,6 @@ interface State { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) return evalRule(permission, pattern, ...rulesets) } From b4cc7d13b65eb382f1a7a3d77aa5e370dc9a219b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 12:44:52 +1000 Subject: [PATCH 087/174] fix(desktop): limit zoom handler to zoom keys (#25516) --- .../src/renderer/webview-zoom.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a3..6e13266f4 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -26,13 +26,20 @@ const applyZoom = (next: number) => { window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return - let newZoom = webviewZoom() - - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 - - applyZoom(clamp(newZoom)) + if (event.key === "-") { + event.preventDefault() + applyZoom(clamp(webviewZoom() - 0.2)) + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + applyZoom(clamp(webviewZoom() + 0.2)) + return + } + if (event.key === "0") { + event.preventDefault() + applyZoom(1) + } }) export { webviewZoom } From be88cd5cb9c86bdd8ad379de2b2dc5575798fa36 Mon Sep 17 00:00:00 2001 From: Youssef Achy <19510452+PanAchy@users.noreply.github.com> Date: Sat, 2 May 2026 21:52:32 -0500 Subject: [PATCH 088/174] chore(opencode): exclude .map files from CLI binary build (#25500) --- packages/opencode/script/build.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 35812f953..2f2edb4ff 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => { await $`bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) .sort() const imports = files.map((file, i) => { const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") From af9fdf0a1c3f8da170658f6b8abf064bd1b30824 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:53:20 -0400 Subject: [PATCH 089/174] refactor(cli): convert github subcommands to effectCmd (#25522) --- packages/opencode/src/cli/cmd/github.ts | 41 +++++++++++++------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e707526df..f946e91ed 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,10 +18,9 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@/provider/models" -import { Instance } from "@/project/instance" -import { WithInstance } from "@/project/with-instance" -import { bootstrap } from "../bootstrap" +import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" @@ -200,13 +199,14 @@ export const GithubCommand = cmd({ async handler() {}, }) -export const GithubInstallCommand = cmd({ +export const GithubInstallCommand = effectCmd({ command: "install", describe: "install the GitHub agent", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.github.install")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { { UI.empty() prompts.intro("Install GitHub agent") @@ -254,7 +254,7 @@ export const GithubInstallCommand = cmd({ } async function getAppInfo() { - const project = Instance.project + const project = ctx.project if (project.vcs !== "git") { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) throw new UI.CancelledError() @@ -262,14 +262,14 @@ export const GithubInstallCommand = cmd({ // Get repo info const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), ).then((x) => x.text().trim()) const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) throw new UI.CancelledError() } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } } async function promptProvider() { @@ -420,12 +420,11 @@ jobs: prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } } - }, }) - }, + }), }) -export const GithubRunCommand = cmd({ +export const GithubRunCommand = effectCmd({ command: "run", describe: "run the GitHub agent", builder: (yargs) => @@ -438,8 +437,10 @@ export const GithubRunCommand = cmd({ type: "string", describe: "GitHub personal access token (github_pat_********)", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.github.run")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + yield* Effect.promise(async () => { const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context @@ -502,21 +503,21 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -1646,5 +1647,5 @@ query($owner: String!, $repo: String!, $number: Int!) { }) } }) - }, + }), }) From 31cb0bfa4fcf245a6a1baba45dc2f5b6336e8293 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:54:20 +0000 Subject: [PATCH 090/174] chore: generate --- packages/opencode/src/cli/cmd/github.ts | 334 ++++++++++++------------ 1 file changed, 167 insertions(+), 167 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f946e91ed..a4a209ea3 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -207,184 +207,184 @@ export const GithubInstallCommand = effectCmd({ if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { - { - UI.empty() - prompts.intro("Install GitHub agent") - const app = await getAppInfo() - await installGitHubApp() + { + UI.empty() + prompts.intro("Install GitHub agent") + const app = await getAppInfo() + await installGitHubApp() - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { - // TODO: add guide for copilot, for now just hide it - delete p["github-copilot"] - return p + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { + // TODO: add guide for copilot, for now just hide it + delete p["github-copilot"] + return p + }) + + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() + + await addWorkflowFiles() + printNextSteps() + + function printNextSteps() { + let step2 + if (provider === "amazon-bedrock") { + step2 = + "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" + } else { + step2 = [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ].join("\n") + } + + prompts.outro( + [ + "Next steps:", + "", + ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, + step2, + "", + " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + "", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", + ].join("\n"), + ) + } + + async function getAppInfo() { + const project = ctx.project + if (project.vcs !== "git") { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + + // Get repo info + const info = await AppRuntime.runPromise( + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), + ).then((x) => x.text().trim()) + const parsed = parseGitHubRemote(info) + if (!parsed) { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + } + + async function promptProvider() { + const priority: Record = { + opencode: 0, + anthropic: 1, + openai: 2, + google: 3, + } + let provider = await prompts.select({ + message: "Select provider", + maxItems: 8, + options: pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] === 0 ? "recommended" : undefined, + })), + ), }) - const provider = await promptProvider() - const model = await promptModel() - //const key = await promptKey() + if (prompts.isCancel(provider)) throw new UI.CancelledError() - await addWorkflowFiles() - printNextSteps() + return provider + } - function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, - "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") + async function promptModel() { + const providerData = providers[provider]! + + const model = await prompts.select({ + message: "Select model", + maxItems: 8, + options: pipe( + providerData.models, + values(), + sortBy((x) => x.name ?? x.id), + map((x) => ({ + label: x.name ?? x.id, + value: x.id, + })), + ), + }) + + if (prompts.isCancel(model)) throw new UI.CancelledError() + return model + } + + async function installGitHubApp() { + const s = prompts.spinner() + s.start("Installing GitHub app") + + // Get installation + const installation = await getInstallation() + if (installation) return s.stop("GitHub app already installed") + + // Open browser + const url = "https://github.com/apps/opencode-agent" + const command = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"` + + exec(command, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) } + }) - prompts.outro( - [ - "Next steps:", - "", - ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, - "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", - "", - " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", - ].join("\n"), - ) - } - - async function getAppInfo() { - const project = ctx.project - if (project.vcs !== "git") { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - - // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), - ).then((x) => x.text().trim()) - const parsed = parseGitHubRemote(info) - if (!parsed) { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } - } - - async function promptProvider() { - const priority: Record = { - opencode: 0, - anthropic: 1, - openai: 2, - google: 3, - } - let provider = await prompts.select({ - message: "Select provider", - maxItems: 8, - options: pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] === 0 ? "recommended" : undefined, - })), - ), - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - return provider - } - - async function promptModel() { - const providerData = providers[provider]! - - const model = await prompts.select({ - message: "Select model", - maxItems: 8, - options: pipe( - providerData.models, - values(), - sortBy((x) => x.name ?? x.id), - map((x) => ({ - label: x.name ?? x.id, - value: x.id, - })), - ), - }) - - if (prompts.isCancel(model)) throw new UI.CancelledError() - return model - } - - async function installGitHubApp() { - const s = prompts.spinner() - s.start("Installing GitHub app") - - // Get installation + // Wait for installation + s.message("Waiting for GitHub app to be installed") + const MAX_RETRIES = 120 + let retries = 0 + do { const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") + if (installation) break - // Open browser - const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } - }) - - // Wait for installation - s.message("Waiting for GitHub app to be installed") - const MAX_RETRIES = 120 - let retries = 0 - do { - const installation = await getInstallation() - if (installation) break - - if (retries > MAX_RETRIES) { - s.stop( - `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, - ) - throw new UI.CancelledError() - } - - retries++ - await sleep(1000) - } while (true) // oxlint-disable-line no-constant-condition - - s.stop("Installed GitHub app") - - async function getInstallation() { - return await fetch( - `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + if (retries > MAX_RETRIES) { + s.stop( + `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - .then((res) => res.json()) - .then((data) => data.installation) + throw new UI.CancelledError() } + + retries++ + await sleep(1000) + } while (true) // oxlint-disable-line no-constant-condition + + s.stop("Installed GitHub app") + + async function getInstallation() { + return await fetch( + `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + ) + .then((res) => res.json()) + .then((data) => data.installation) } + } - async function addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function addWorkflowFiles() { + const envStr = + provider === "amazon-bedrock" + ? "" + : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + await Filesystem.write( + path.join(app.root, WORKFLOW_FILE), + `name: opencode on: issue_comment: @@ -415,11 +415,11 @@ jobs: uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, - ) + ) - prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) - } + prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } + } }) }), }) From db24f893137c545768adaf493da1bb541106cc6c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:03:32 -0400 Subject: [PATCH 091/174] refactor(cli): convert mcp list, auth, auth list, logout to effectCmd (#25521) --- packages/opencode/src/cli/cmd/mcp.ts | 497 +++++++++++++-------------- 1 file changed, 243 insertions(+), 254 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e4d7bd922..c220cbbdd 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,6 @@ import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" @@ -65,35 +67,31 @@ function oauthServers(config: Config.Info) { ) } -async function listState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const statuses = yield* mcp.status() - const stored = yield* Effect.all( - Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), - { concurrency: "unbounded" }, - ) - return { config, statuses, stored } - }), - ) +function listState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }) } -async function authState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const auth = yield* Effect.all( - Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), - { concurrency: "unbounded" }, - ) - return { config, auth } - }), - ) +function authState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }) } export const McpCommand = cmd({ @@ -110,73 +108,68 @@ export const McpCommand = cmd({ async handler() {}, }) -export const McpListCommand = cmd({ +export const McpListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list MCP servers and their status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP Servers") + handler: Effect.fn("Cli.mcp.list")(function* () { + UI.empty() + prompts.intro("MCP Servers") - const { config, statuses, stored } = await listState() - const servers = configuredServers(config) + const { config, statuses, stored } = yield* listState() + const servers = configuredServers(config) - if (servers.length === 0) { - prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") - return + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + for (const [name, serverConfig] of servers) { + const status = statuses[name] + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth + const hasStoredTokens = stored[name] + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } - for (const [name, serverConfig] of servers) { - const status = statuses[name] - const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = stored[name] + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } - let statusIcon: string - let statusText: string - let hint = "" - - if (!status) { - statusIcon = "○" - statusText = "not initialized" - } else if (status.status === "connected") { - statusIcon = "✓" - statusText = "connected" - if (hasOAuth && hasStoredTokens) { - hint = " (OAuth)" - } - } else if (status.status === "disabled") { - statusIcon = "○" - statusText = "disabled" - } else if (status.status === "needs_auth") { - statusIcon = "⚠" - statusText = "needs authentication" - } else if (status.status === "needs_client_registration") { - statusIcon = "✗" - statusText = "needs client registration" - hint = "\n " + status.error - } else { - statusIcon = "✗" - statusText = "failed" - hint = "\n " + status.error - } - - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") - prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, - ) - } - - prompts.outro(`${servers.length} server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} server(s)`) + }), }) -export const McpAuthCommand = cmd({ +export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", builder: (yargs) => @@ -186,105 +179,106 @@ export const McpAuthCommand = cmd({ type: "string", }) .command(McpAuthListCommand), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Authentication") + handler: Effect.fn("Cli.mcp.auth")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Authentication") - const { config, auth } = await authState() - const mcpServers = config.mcp ?? {} - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") - prompts.log.info(` + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") + prompts.log.info(` "mcp": { "my-server": { "type": "remote", "url": "https://example.com/mcp" } }`) - prompts.outro("Done") - return + prompts.outro("Done") + return + } + + let serverName = args.name + if (!serverName) { + // Build options with auth status + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, } + }) - let serverName = args.name - if (!serverName) { - // Build options with auth status - const options = servers.map(([name, cfg]) => { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }) + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to authenticate", + options, + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - const selected = await prompts.select({ - message: "Select MCP server to authenticate", - options, - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) + prompts.outro("Done") + return + } - if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) - prompts.outro("Done") - return - } + // Check if already authenticated + const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))) + if (authStatus === "authenticated") { + const confirm = yield* Effect.promise(() => + prompts.confirm({ + message: `${serverName} already has valid credentials. Re-authenticate?`, + }), + ) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } else if (authStatus === "expired") { + prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) + } - // Check if already authenticated - const authStatus = - auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) - if (authStatus === "authenticated") { - const confirm = await prompts.confirm({ - message: `${serverName} already has valid credentials. Re-authenticate?`, - }) - if (prompts.isCancel(confirm) || !confirm) { - prompts.outro("Cancelled") - return - } - } else if (authStatus === "expired") { - prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) - } + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") - const spinner = prompts.spinner() - spinner.start("Starting OAuth flow...") + // Subscribe to browser open failure events to show URL for manual opening + const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { + if (evt.properties.mcpName === serverName) { + spinner.stop("Could not open browser automatically") + prompts.log.warn("Please open this URL in your browser to authenticate:") + prompts.log.info(evt.properties.url) + spinner.start("Waiting for authorization...") + } + }) - // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) - spinner.start("Waiting for authorization...") - } - }) - - try { - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) - - if (status.status === "connected") { - spinner.stop("Authentication successful!") - } else if (status.status === "needs_client_registration") { - spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - prompts.log.info("Add clientId to your MCP server config:") - prompts.log.info(` + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)) + .pipe( + Effect.tap((status) => + Effect.sync(() => { + if (status.status === "connected") { + spinner.stop("Authentication successful!") + } else if (status.status === "needs_client_registration") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + prompts.log.info("Add clientId to your MCP server config:") + prompts.log.info(` "mcp": { "${serverName}": { "type": "remote", @@ -295,61 +289,59 @@ export const McpAuthCommand = cmd({ } } }`) - } else if (status.status === "failed") { + } else if (status.status === "failed") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + } else { + spinner.stop("Unexpected status: " + status.status, 1) + } + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - } else { - spinner.stop("Unexpected status: " + status.status, 1) - } - } catch (error) { - spinner.stop("Authentication failed", 1) - prompts.log.error(error instanceof Error ? error.message : String(error)) - } finally { - unsubscribe() - } + const error = Cause.squash(cause) + prompts.log.error(error instanceof Error ? error.message : String(error)) + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) - prompts.outro("Done") - }, - }) - }, + prompts.outro("Done") + }), }) -export const McpAuthListCommand = cmd({ +export const McpAuthListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Status") + handler: Effect.fn("Cli.mcp.auth.list")(function* () { + UI.empty() + prompts.intro("MCP OAuth Status") - const { config, auth } = await authState() - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.outro("Done") - return - } + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.outro("Done") + return + } - for (const [name, serverConfig] of servers) { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = serverConfig.url + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = serverConfig.url - prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) - } + prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) + } - prompts.outro(`${servers.length} OAuth-capable server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} OAuth-capable server(s)`) + }), }) -export const McpLogoutCommand = cmd({ +export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", builder: (yargs) => @@ -357,57 +349,54 @@ export const McpLogoutCommand = cmd({ describe: "name of the MCP server", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Logout") + handler: Effect.fn("Cli.mcp.logout")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Logout") - const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) - const serverNames = Object.keys(credentials) + const credentials = yield* McpAuth.Service.use((auth) => auth.all()) + const serverNames = Object.keys(credentials) - if (serverNames.length === 0) { - prompts.log.warn("No MCP OAuth credentials stored") - prompts.outro("Done") - return - } + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } - let serverName = args.name - if (!serverName) { - const selected = await prompts.select({ - message: "Select MCP server to logout", - options: serverNames.map((name) => { - const entry = credentials[name] - const hasTokens = !!entry.tokens - const hasClient = !!entry.clientInfo - let hint = "" - if (hasTokens && hasClient) hint = "tokens + client" - else if (hasTokens) hint = "tokens" - else if (hasClient) hint = "client registration" - return { - label: name, - value: name, - hint, - } - }), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + let serverName = args.name + if (!serverName) { + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!credentials[serverName]) { - prompts.log.error(`No credentials found for: ${serverName}`) - prompts.outro("Done") - return - } + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) - prompts.log.success(`Removed OAuth credentials for ${serverName}`) - prompts.outro("Done") - }, - }) - }, + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }), }) async function resolveConfigPath(baseDir: string, global = false) { From a3d282a4c2a4ae9a7fb21d9802f82979458dac4e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 03:04:40 +0000 Subject: [PATCH 092/174] chore: generate --- packages/opencode/src/cli/cmd/mcp.ts | 53 ++++++++++++++-------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c220cbbdd..a2a956c3b 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -268,17 +268,16 @@ export const McpAuthCommand = effectCmd({ } }) - yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)) - .pipe( - Effect.tap((status) => - Effect.sync(() => { - if (status.status === "connected") { - spinner.stop("Authentication successful!") - } else if (status.status === "needs_client_registration") { - spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - prompts.log.info("Add clientId to your MCP server config:") - prompts.log.info(` + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + Effect.tap((status) => + Effect.sync(() => { + if (status.status === "connected") { + spinner.stop("Authentication successful!") + } else if (status.status === "needs_client_registration") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + prompts.log.info("Add clientId to your MCP server config:") + prompts.log.info(` "mcp": { "${serverName}": { "type": "remote", @@ -289,23 +288,23 @@ export const McpAuthCommand = effectCmd({ } } }`) - } else if (status.status === "failed") { - spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - } else { - spinner.stop("Unexpected status: " + status.status, 1) - } - }), - ), - Effect.catchCause((cause) => - Effect.sync(() => { + } else if (status.status === "failed") { spinner.stop("Authentication failed", 1) - const error = Cause.squash(cause) - prompts.log.error(error instanceof Error ? error.message : String(error)) - }), - ), - Effect.ensuring(Effect.sync(() => unsubscribe())), - ) + prompts.log.error(status.error) + } else { + spinner.stop("Unexpected status: " + status.status, 1) + } + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { + spinner.stop("Authentication failed", 1) + const error = Cause.squash(cause) + prompts.log.error(error instanceof Error ? error.message : String(error)) + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) prompts.outro("Done") }), From a79a6594b064429b2f13c92f9b85291b051ca750 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:08:13 -0400 Subject: [PATCH 093/174] chore: bump Effect beta (#25524) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 12677ea97..25068f3d9 100644 --- a/bun.lock +++ b/bun.lock @@ -715,7 +715,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -3078,7 +3078,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], diff --git a/package.json b/package.json index b15fbb254..de3dd31f4 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", From bdabb102fe5e04a6bbbc10114fb2f22cef6dc6ea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:08:26 -0400 Subject: [PATCH 094/174] =?UTF-8?q?refactor(cli/stats):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20fully=20Effect-native=20body=20(#25523)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/stats.ts | 232 +++++++++++-------------- 1 file changed, 105 insertions(+), 127 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 8bf7b2345..0124a2693 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { totalSessions: number @@ -69,38 +68,28 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - return yield* run(args, ctx.project) - }), -}) - -const run = ( - args: { days?: number; tools?: number; models?: unknown; project?: string }, - currentProject: Project.Info, -) => - Effect.promise(async () => { - const stats = await aggregateSessionStats(args.days, args.project, currentProject) - + const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) let modelLimit: number | undefined if (args.models === true) { modelLimit = Infinity } else if (typeof args.models === "number") { modelLimit = args.models } - displayStats(stats, args.tools, modelLimit) - }) + }), +}) -async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) -} +const getAllSessions = Effect.sync(() => + Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), +) -export async function aggregateSessionStats( +const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( days?: number, projectFilter?: string, currentProject?: Project.Info, -): Promise { - const sessions = await getAllSessions() +) { + const svc = yield* Session.Service + const sessions = yield* getAllSessions const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { @@ -169,122 +158,111 @@ export async function aggregateSessionStats( const sessionTotalTokens: number[] = [] - const BATCH_SIZE = 20 - for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { - const batch = filteredSessions.slice(i, i + BATCH_SIZE) + const results = yield* Effect.forEach( + filteredSessions, + (session) => + Effect.gen(function* () { + const messages = yield* svc.messages({ sessionID: session.id }) - const batchPromises = batch.map(async (session) => { - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: session.id })), - ) + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + let sessionModelUsage: Record< + string, + { + messages: number + tokens: { input: number; output: number; cache: { read: number; write: number } } + cost: number + } + > = {} - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } - let sessionToolUsage: Record = {} - let sessionModelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + const modelKey = `${message.info.providerID}/${message.info.modelID}` + if (!sessionModelUsage[modelKey]) { + sessionModelUsage[modelKey] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, + } + } + sessionModelUsage[modelKey].messages++ + sessionModelUsage[modelKey].cost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + + sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 + sessionModelUsage[modelKey].tokens.output += + (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) + sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 + sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 } } - cost: number - } - > = {} - for (const message of messages) { - if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - - const modelKey = `${message.info.providerID}/${message.info.modelID}` - if (!sessionModelUsage[modelKey]) { - sessionModelUsage[modelKey] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 } } - sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 - - if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 - sessionModelUsage[modelKey].tokens.output += - (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) - sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 - sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 - } } - for (const part of message.parts) { - if (part.type === "tool" && part.tool) { - sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 - } + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionTotalTokens: + sessionTokens.input + + sessionTokens.output + + sessionTokens.reasoning + + sessionTokens.cache.read + + sessionTokens.cache.write, + sessionToolUsage, + sessionModelUsage, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, + latestTime: session.time.updated, + } + }), + { concurrency: 20 }, + ) + + for (const result of results) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } + + for (const [model, usage] of Object.entries(result.sessionModelUsage)) { + if (!stats.modelUsage[model]) { + stats.modelUsage[model] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, } } - - return { - messageCount: messages.length, - sessionCost, - sessionTokens, - sessionTotalTokens: - sessionTokens.input + - sessionTokens.output + - sessionTokens.reasoning + - sessionTokens.cache.read + - sessionTokens.cache.write, - sessionToolUsage, - sessionModelUsage, - earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, - latestTime: session.time.updated, - } - }) - - const batchResults = await Promise.all(batchPromises) - - for (const result of batchResults) { - earliestTime = Math.min(earliestTime, result.earliestTime) - latestTime = Math.max(latestTime, result.latestTime) - sessionTotalTokens.push(result.sessionTotalTokens) - - stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost - stats.totalTokens.input += result.sessionTokens.input - stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning - stats.totalTokens.cache.read += result.sessionTokens.cache.read - stats.totalTokens.cache.write += result.sessionTokens.cache.write - - for (const [tool, count] of Object.entries(result.sessionToolUsage)) { - stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count - } - - for (const [model, usage] of Object.entries(result.sessionModelUsage)) { - if (!stats.modelUsage[model]) { - stats.modelUsage[model] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, - } - } - stats.modelUsage[model].messages += usage.messages - stats.modelUsage[model].tokens.input += usage.tokens.input - stats.modelUsage[model].tokens.output += usage.tokens.output - stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read - stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write - stats.modelUsage[model].cost += usage.cost - } + stats.modelUsage[model].messages += usage.messages + stats.modelUsage[model].tokens.input += usage.tokens.input + stats.modelUsage[model].tokens.output += usage.tokens.output + stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read + stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write + stats.modelUsage[model].cost += usage.cost } } @@ -313,7 +291,7 @@ export async function aggregateSessionStats( : sessionTotalTokens[mid] return stats -} +}) export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56 From 5f03d892c099c19b54f72d3dabc9c35d362162d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:19:33 -0400 Subject: [PATCH 095/174] fix(httpapi): pagination Link header echoes request host (#25527) --- .../instance/httpapi/handlers/session.ts | 6 +- .../test/server/httpapi-parity.test.ts | 128 ++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-parity.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 8cc969f48..4a67ba036 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,7 +18,7 @@ import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" -import { Cause, Effect, Schema, Scope } from "effect" +import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) url.searchParams.set("before", page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 000000000..6922d8c43 --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's NamedError +// envelope `{ name, data: { message } }`. HttpApi returns the typed-error +// shape `{ _tag }` instead. SDK consumers reading `error.data.message` +// see undefined. +// +// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test.todo("HttpApi 404 body matches NamedError shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) From 0e13279545f443f0186aee59e868ca9f781e875b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:22:44 -0400 Subject: [PATCH 096/174] refactor(cli): convert agent / providers / mcp to effectCmd (#25525) --- packages/opencode/src/cli/cmd/agent.ts | 23 ++++++++------- packages/opencode/src/cli/cmd/mcp.ts | 32 +++++++++------------ packages/opencode/src/cli/cmd/providers.ts | 33 +++++++++++++--------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 401126949..e2565c627 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -9,8 +9,7 @@ import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { EOL } from "os" import type { Argv } from "yargs" import { Effect } from "effect" @@ -35,7 +34,7 @@ const AVAILABLE_PERMISSIONS = [ "skill", ] -const AgentCreateCommand = cmd({ +const AgentCreateCommand = effectCmd({ command: "create", describe: "create a new agent", builder: (yargs: Argv) => @@ -63,10 +62,11 @@ const AgentCreateCommand = cmd({ alias: ["m"], describe: "model to use in the format of provider/model", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.agent.create")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { const cliPath = args.path const cliDescription = args.description const cliMode = args.mode as AgentMode | undefined @@ -79,7 +79,7 @@ const AgentCreateCommand = cmd({ prompts.intro("Create agent") } - const project = Instance.project + const project = ctx.project // Determine scope/path let targetPath: string @@ -94,7 +94,7 @@ const AgentCreateCommand = cmd({ { label: "Current project", value: "project" as const, - hint: Instance.worktree, + hint: ctx.worktree, }, { label: "Global", @@ -107,7 +107,7 @@ const AgentCreateCommand = cmd({ scope = scopeResult } targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), + scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent", ) } @@ -230,9 +230,8 @@ const AgentCreateCommand = cmd({ prompts.log.success(`Agent created: ${filePath}`) prompts.outro("Done") } - }, }) - }, + }), }) const AgentListCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index a2a956c3b..d1e8b33be 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -11,8 +11,7 @@ import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -433,21 +432,22 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat return configPath } -export const McpAddCommand = cmd({ +export const McpAddCommand = effectCmd({ command: "add", describe: "add an MCP server", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.mcp.add")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { UI.empty() prompts.intro("Add MCP server") - const project = Instance.project + const project = ctx.project // Resolve config paths eagerly for hints const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(Instance.worktree), + resolveConfigPath(ctx.worktree), resolveConfigPath(Global.Path.config, true), ]) @@ -592,12 +592,11 @@ export const McpAddCommand = cmd({ } prompts.outro("MCP server added successfully") - }, }) - }, + }), }) -export const McpDebugCommand = cmd({ +export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", builder: (yargs) => @@ -606,10 +605,8 @@ export const McpDebugCommand = cmd({ type: "string", demandOption: true, }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.mcp.debug")(function* (args) { + yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") @@ -781,7 +778,6 @@ export const McpDebugCommand = cmd({ } prompts.outro("Debug complete") - }, }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index ca6452618..93541114b 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,6 +1,7 @@ import { Auth } from "../../auth" import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" @@ -13,7 +14,6 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -232,11 +232,14 @@ export const ProvidersCommand = cmd({ async handler() {}, }) -export const ProvidersListCommand = cmd({ +export const ProvidersListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list providers and credentials", - async handler(_args) { + // Lists global credentials + provider env vars; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.list")(function* (_args) { + yield* Effect.promise(async () => { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() @@ -280,10 +283,11 @@ export const ProvidersListCommand = cmd({ prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } - }, + }) + }), }) -export const ProvidersLoginCommand = cmd({ +export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -302,10 +306,8 @@ export const ProvidersLoginCommand = cmd({ describe: "login method label (skips method selection)", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.providers.login")(function* (args) { + yield* Effect.promise(async () => { UI.empty() prompts.intro("Add credential") if (args.url) { @@ -487,15 +489,17 @@ export const ProvidersLoginCommand = cmd({ }) prompts.outro("Done") - }, }) - }, + }), }) -export const ProvidersLogoutCommand = cmd({ +export const ProvidersLogoutCommand = effectCmd({ command: "logout", describe: "log out from a configured provider", - async handler(_args) { + // Removes a global auth credential; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.logout")(function* (_args) { + yield* Effect.promise(async () => { UI.empty() const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( Effect.gen(function* () { @@ -525,5 +529,6 @@ export const ProvidersLogoutCommand = cmd({ }), ) prompts.outro("Logout successful") - }, + }) + }), }) From 3f1ce36418835423b79cf4a50f9086a538c37f12 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 03:23:47 +0000 Subject: [PATCH 097/174] chore: generate --- packages/opencode/src/cli/cmd/agent.ts | 283 ++++++----- packages/opencode/src/cli/cmd/mcp.ts | 536 ++++++++++----------- packages/opencode/src/cli/cmd/providers.ts | 446 ++++++++--------- 3 files changed, 631 insertions(+), 634 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index e2565c627..a5bcd7873 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -67,169 +67,166 @@ const AgentCreateCommand = effectCmd({ if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { - const cliPath = args.path - const cliDescription = args.description - const cliMode = args.mode as AgentMode | undefined - const perms = args.permissions + const cliPath = args.path + const cliDescription = args.description + const cliMode = args.mode as AgentMode | undefined + const perms = args.permissions - const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined + const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined - if (!isFullyNonInteractive) { - UI.empty() - prompts.intro("Create agent") - } + if (!isFullyNonInteractive) { + UI.empty() + prompts.intro("Create agent") + } - const project = ctx.project + const project = ctx.project - // Determine scope/path - let targetPath: string - if (cliPath) { - targetPath = path.join(cliPath, "agent") - } else { - let scope: "global" | "project" = "global" - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: "project" as const, - hint: ctx.worktree, - }, - { - label: "Global", - value: "global" as const, - hint: Global.Path.config, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - scope = scopeResult - } - targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), - "agent", - ) - } - - // Get description - let description: string - if (cliDescription) { - description = cliDescription - } else { - const query = await prompts.text({ - message: "Description", - placeholder: "What should this agent do?", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(query)) throw new UI.CancelledError() - description = query - } - - // Generate agent - const spinner = prompts.spinner() - spinner.start("Generating agent configuration...") - const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { - spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) - if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() - }) - spinner.stop(`Agent ${generated.identifier} generated`) - - // Select permissions to allow - let selected: string[] - if (perms !== undefined) { - selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS - } else { - const result = await prompts.multiselect({ - message: "Select permissions to allow (Space to toggle)", - options: AVAILABLE_PERMISSIONS.map((permission) => ({ - label: permission, - value: permission, - })), - initialValues: AVAILABLE_PERMISSIONS, - }) - if (prompts.isCancel(result)) throw new UI.CancelledError() - selected = result - } - - // Get mode - let mode: AgentMode - if (cliMode) { - mode = cliMode - } else { - const modeResult = await prompts.select({ - message: "Agent mode", + // Determine scope/path + let targetPath: string + if (cliPath) { + targetPath = path.join(cliPath, "agent") + } else { + let scope: "global" | "project" = "global" + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "All", - value: "all" as const, - hint: "Can function in both primary and subagent roles", + label: "Current project", + value: "project" as const, + hint: ctx.worktree, }, { - label: "Primary", - value: "primary" as const, - hint: "Acts as a primary/main agent", - }, - { - label: "Subagent", - value: "subagent" as const, - hint: "Can be used as a subagent by other agents", + label: "Global", + value: "global" as const, + hint: Global.Path.config, }, ], - initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - mode = modeResult + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult } + targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent") + } - // Build permissions config — deny anything not explicitly selected. - const permissions: Record = {} - for (const permission of AVAILABLE_PERMISSIONS) { - if (!selected.includes(permission)) { - permissions[permission] = "deny" - } + // Get description + let description: string + if (cliDescription) { + description = cliDescription + } else { + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + description = query + } + + // Generate agent + const spinner = prompts.spinner() + spinner.start("Generating agent configuration...") + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await AppRuntime.runPromise( + Agent.Service.use((svc) => svc.generate({ description, model })), + ).catch((error) => { + spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) + if (isFullyNonInteractive) process.exit(1) + throw new UI.CancelledError() + }) + spinner.stop(`Agent ${generated.identifier} generated`) + + // Select permissions to allow + let selected: string[] + if (perms !== undefined) { + selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS + } else { + const result = await prompts.multiselect({ + message: "Select permissions to allow (Space to toggle)", + options: AVAILABLE_PERMISSIONS.map((permission) => ({ + label: permission, + value: permission, + })), + initialValues: AVAILABLE_PERMISSIONS, + }) + if (prompts.isCancel(result)) throw new UI.CancelledError() + selected = result + } + + // Get mode + let mode: AgentMode + if (cliMode) { + mode = cliMode + } else { + const modeResult = await prompts.select({ + message: "Agent mode", + options: [ + { + label: "All", + value: "all" as const, + hint: "Can function in both primary and subagent roles", + }, + { + label: "Primary", + value: "primary" as const, + hint: "Acts as a primary/main agent", + }, + { + label: "Subagent", + value: "subagent" as const, + hint: "Can be used as a subagent by other agents", + }, + ], + initialValue: "all" as const, + }) + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + mode = modeResult + } + + // Build permissions config — deny anything not explicitly selected. + const permissions: Record = {} + for (const permission of AVAILABLE_PERMISSIONS) { + if (!selected.includes(permission)) { + permissions[permission] = "deny" } + } - // Build frontmatter - const frontmatter: { - description: string - mode: AgentMode - permission?: Record - } = { - description: generated.whenToUse, - mode, - } - if (Object.keys(permissions).length > 0) { - frontmatter.permission = permissions - } + // Build frontmatter + const frontmatter: { + description: string + mode: AgentMode + permission?: Record + } = { + description: generated.whenToUse, + mode, + } + if (Object.keys(permissions).length > 0) { + frontmatter.permission = permissions + } - // Write file - const content = matter.stringify(generated.systemPrompt, frontmatter) - const filePath = path.join(targetPath, `${generated.identifier}.md`) + // Write file + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join(targetPath, `${generated.identifier}.md`) - await fs.mkdir(targetPath, { recursive: true }) - - if (await Filesystem.exists(filePath)) { - if (isFullyNonInteractive) { - console.error(`Error: Agent file already exists: ${filePath}`) - process.exit(1) - } - prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() - } - - await Filesystem.write(filePath, content) + await fs.mkdir(targetPath, { recursive: true }) + if (await Filesystem.exists(filePath)) { if (isFullyNonInteractive) { - console.log(filePath) - } else { - prompts.log.success(`Agent created: ${filePath}`) - prompts.outro("Done") + console.error(`Error: Agent file already exists: ${filePath}`) + process.exit(1) } + prompts.log.error(`Agent file already exists: ${filePath}`) + throw new UI.CancelledError() + } + + await Filesystem.write(filePath, content) + + if (isFullyNonInteractive) { + console.log(filePath) + } else { + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + } }) }), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d1e8b33be..d9927e287 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -440,158 +440,158 @@ export const McpAddCommand = effectCmd({ if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { - UI.empty() - prompts.intro("Add MCP server") + UI.empty() + prompts.intro("Add MCP server") - const project = ctx.project + const project = ctx.project - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(ctx.worktree), - resolveConfigPath(Global.Path.config, true), - ]) + // Resolve config paths eagerly for hints + const [projectConfigPath, globalConfigPath] = await Promise.all([ + resolveConfigPath(ctx.worktree), + resolveConfigPath(Global.Path.config, true), + ]) - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, - }, - { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult - } - - const name = await prompts.text({ - message: "Enter MCP server name", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(name)) throw new UI.CancelledError() - - const type = await prompts.select({ - message: "Select MCP server type", + // Determine scope + let configPath = globalConfigPath + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "Local", - value: "local", - hint: "Run a local command", + label: "Current project", + value: projectConfigPath, + hint: projectConfigPath, }, { - label: "Remote", - value: "remote", - hint: "Connect to a remote URL", + label: "Global", + value: globalConfigPath, + hint: globalConfigPath, }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + configPath = scopeResult + } - if (type === "local") { - const command = await prompts.text({ - message: "Enter command to run", - placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + const name = await prompts.text({ + message: "Enter MCP server name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(name)) throw new UI.CancelledError() - const mcpConfig: ConfigMCP.Info = { - type: "local", - command: command.split(" "), - } + const type = await prompts.select({ + message: "Select MCP server type", + options: [ + { + label: "Local", + value: "local", + hint: "Run a local command", + }, + { + label: "Remote", + value: "remote", + hint: "Connect to a remote URL", + }, + ], + }) + if (prompts.isCancel(type)) throw new UI.CancelledError() - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) - prompts.outro("MCP server added successfully") - return + if (type === "local") { + const command = await prompts.text({ + message: "Enter command to run", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() + + const mcpConfig: ConfigMCP.Info = { + type: "local", + command: command.split(" "), } - if (type === "remote") { - const url = await prompts.text({ - message: "Enter MCP server URL", - placeholder: "e.g., https://example.com/mcp", - validate: (x) => { - if (!x) return "Required" - if (x.length === 0) return "Required" - const isValid = URL.canParse(x) - return isValid ? undefined : "Invalid URL" - }, - }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return + } - const useOAuth = await prompts.confirm({ - message: "Does this server require OAuth authentication?", + if (type === "remote") { + const url = await prompts.text({ + message: "Enter MCP server URL", + placeholder: "e.g., https://example.com/mcp", + validate: (x) => { + if (!x) return "Required" + if (x.length === 0) return "Required" + const isValid = URL.canParse(x) + return isValid ? undefined : "Invalid URL" + }, + }) + if (prompts.isCancel(url)) throw new UI.CancelledError() + + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, + }) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + let mcpConfig: ConfigMCP.Info + + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - let mcpConfig: ConfigMCP.Info + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() - if (useOAuth) { - const hasClientId = await prompts.confirm({ - message: "Do you have a pre-registered client ID?", + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - if (hasClientId) { - const clientId = await prompts.text({ - message: "Enter client ID", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } - const hasSecret = await prompts.confirm({ - message: "Do you have a client secret?", - initialValue: false, - }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - - let clientSecret: string | undefined - if (hasSecret) { - const secret = await prompts.password({ - message: "Enter client secret", - }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() - clientSecret = secret - } - - mcpConfig = { - type: "remote", - url, - oauth: { - clientId, - ...(clientSecret && { clientSecret }), - }, - } - } else { - mcpConfig = { - type: "remote", - url, - oauth: {}, - } + mcpConfig = { + type: "remote", + url, + oauth: { + clientId, + ...(clientSecret && { clientSecret }), + }, } } else { mcpConfig = { type: "remote", url, + oauth: {}, } } - - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } else { + mcpConfig = { + type: "remote", + url, + } } - prompts.outro("MCP server added successfully") + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } + + prompts.outro("MCP server added successfully") }) }), }) @@ -607,177 +607,177 @@ export const McpDebugCommand = effectCmd({ }), handler: Effect.fn("Cli.mcp.debug")(function* (args) { yield* Effect.promise(async () => { - UI.empty() - prompts.intro("MCP OAuth Debug") + UI.empty() + prompts.intro("MCP OAuth Debug") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const serverName = args.name + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const mcpServers = config.mcp ?? {} + const serverName = args.name - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - if (!isMcpRemote(serverConfig)) { - prompts.log.error(`MCP server ${serverName} is not a remote server`) - prompts.outro("Done") - return - } + if (!isMcpRemote(serverConfig)) { + prompts.log.error(`MCP server ${serverName} is not a remote server`) + prompts.outro("Done") + return + } - if (serverConfig.oauth === false) { - prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) - prompts.outro("Done") - return - } + if (serverConfig.oauth === false) { + prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) + prompts.outro("Done") + return + } - prompts.log.info(`Server: ${serverName}`) - prompts.log.info(`URL: ${serverConfig.url}`) + prompts.log.info(`Server: ${serverName}`) + prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } - }), - ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) + // Check stored auth status + const { authStatus, entry } = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + return { + authStatus: yield* mcp.getAuthStatus(serverName), + entry: yield* auth.get(serverName), } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) - } - } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } - } + }), + ) + prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - const spinner = prompts.spinner() - spinner.start("Testing connection...") + if (entry?.tokens) { + prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + if (entry.tokens.expiresAt) { + const expiresDate = new Date(entry.tokens.expiresAt * 1000) + const isExpired = entry.tokens.expiresAt < Date.now() / 1000 + prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) + } + if (entry.tokens.refreshToken) { + prompts.log.info(` Refresh token: present`) + } + } + if (entry?.clientInfo) { + prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) + if (entry.clientInfo.clientSecretExpiresAt) { + const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) + prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) + } + } - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + const spinner = prompts.spinner() + spinner.start("Testing connection...") + + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, + id: 1, + }), + }) + + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } + + if (response.status === 401) { + prompts.log.warn("Server returned 401 Unauthorized") + + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const auth = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service }), + ) + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + + prompts.log.info("Testing OAuth flow (without completing authorization)...") + + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, }) - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } - - if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) - - prompts.log.info("Testing OAuth flow (without completing authorization)...") - - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + prompts.log.info("No client ID - dynamic registration will be attempted") } - } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) - } - } catch { - // Not JSON, ignore - } - } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) } } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } - prompts.outro("Debug complete") + prompts.outro("Debug complete") }) }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 93541114b..71e03c7e7 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -240,49 +240,49 @@ export const ProvidersListCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.list")(function* (_args) { yield* Effect.promise(async () => { - UI.empty() - const authPath = path.join(Global.Path.data, "auth.json") - const homedir = os.homedir() - const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() + UI.empty() + const authPath = path.join(Global.Path.data, "auth.json") + const homedir = os.homedir() + const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath + prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) + const database = await getModels() - for (const [providerID, result] of results) { - const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) - } + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${results.length} credentials`) - const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] - for (const [providerID, provider] of Object.entries(database)) { - for (const envVar of provider.env) { - if (process.env[envVar]) { - activeEnvVars.push({ - provider: provider.name || providerID, - envVar, - }) + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) + } } } - } - if (activeEnvVars.length > 0) { - UI.empty() - prompts.intro("Environment") + if (activeEnvVars.length > 0) { + UI.empty() + prompts.intro("Environment") - for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + } + + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } - - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) - } }) }), }) @@ -308,187 +308,187 @@ export const ProvidersLoginCommand = effectCmd({ }), handler: Effect.fn("Cli.providers.login")(function* (args) { yield* Effect.promise(async () => { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { + auth: { command: string[]; env: string } + } + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { + stdout: "pipe", + }) + if (!proc.stdout) { + prompts.log.error("Failed") prompts.outro("Done") return } - await refreshModels().catch(() => {}) - - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - return filtered - }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) { + prompts.log.error("Failed") + prompts.outro("Done") + return } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + await put(url, { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), + prompts.log.success("Logged into " + url) + prompts.outro("Done") + return + } + await refreshModels().catch(() => {}) + + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const providers = await getModels().then((x) => { + const filtered: Record = {} + for (const [key, value] of Object.entries(x)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + return filtered + }) + const hooks = await AppRuntime.runPromise( + Effect.gen(function* () { + const plugin = yield* Plugin.Service + return yield* plugin.list() + }), + ) + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - ...pluginProviders.map((x) => ({ + map((x) => ({ label: x.name, value: x.id, - hint: "plugin", + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], })), - ] + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + prompts.log.error(`Unknown provider "${input}"`) + process.exit(1) } + provider = match.value + } else { + const selected = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...options, + { + value: "other", + label: "Other", + }, + ], + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + provider = selected as string + } - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + if (handled) return + } + + if (provider === "other") { + const custom = await prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }) + if (prompts.isCancel(custom)) throw new UI.CancelledError() + provider = custom.replace(/^@ai-sdk\//, "") + + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) if (handled) return } - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await put(provider, { + type: "api", + key, + }) - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } - - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) - - prompts.outro("Done") + prompts.outro("Done") }) }), }) @@ -500,35 +500,35 @@ export const ProvidersLogoutCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.logout")(function* (_args) { yield* Effect.promise(async () => { - UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } - const database = await getModels() - const selected = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - prompts.outro("Logout successful") + UI.empty() + const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) + prompts.intro("Remove credential") + if (credentials.length === 0) { + prompts.log.error("No credentials found") + return + } + const database = await getModels() + const selected = await prompts.select({ + message: "Select provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + const providerID = selected as string + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) + prompts.outro("Logout successful") }) }), }) From 33312bfd1b32745417fc56928d46f384ead2e10b Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 2 May 2026 23:24:46 -0400 Subject: [PATCH 098/174] fix(session): encode v2 session responses (#25528) --- packages/opencode/src/v2/session.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1777b875a..1f4cbcf1e 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema" import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" +import { optionalOmitUndefined } from "@/util/schema" export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ identifier: "Session.Delivery", @@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ id: SessionID, - parentID: SessionID.pipe(Schema.optional), + parentID: optionalOmitUndefined(SessionID), projectID: ProjectID, - workspaceID: WorkspaceID.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + workspaceID: optionalOmitUndefined(WorkspaceID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), model: Schema.Struct({ id: ModelID, providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), + variant: optionalOmitUndefined(Schema.String), + }).pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, - archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), }), title: Schema.String, /* @@ -109,7 +110,7 @@ export const layer = Layer.effect( decodeMessage({ ...row.data, id: row.id, type: row.type }) function fromRow(row: typeof SessionTable.$inferSelect): Info { - return { + return new Info({ id: SessionID.make(row.id), projectID: ProjectID.make(row.project_id), workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, @@ -129,7 +130,7 @@ export const layer = Layer.effect( updated: DateTime.makeUnsafe(row.time_updated), archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, }, - } + }) } const result: Interface = { From b89d48a2a45520c6b3cb451a7860a5c2d6cab6ff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 03:25:46 +0000 Subject: [PATCH 099/174] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index bea97a0cb..84c3b1304 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", - "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", - "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", - "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" + "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=", + "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=", + "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=", + "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI=" } } From 8e016b4703a37dadaafc5de0a1ba17176b1a06a0 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 22:36:02 -0500 Subject: [PATCH 100/174] fix: regression w/ auth login where stderr was ignored instead of inherited (#25529) --- packages/opencode/src/cli/cmd/providers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 71e03c7e7..3dce55d32 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -318,6 +318,7 @@ export const ProvidersLoginCommand = effectCmd({ prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", + stderr: "inherit", }) if (!proc.stdout) { prompts.log.error("Failed") From 1717d636a24c0100d36c39deacbd875e0fe93b40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:40:59 -0400 Subject: [PATCH 101/174] =?UTF-8?q?refactor(cli/mcp+agent):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20drop=20AppRuntime.runPromise=20bridges=20(#25530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/agent.ts | 6 ++---- packages/opencode/src/cli/cmd/mcp.ts | 24 ++++++++---------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a5bcd7873..2026d8232 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,5 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" @@ -66,6 +65,7 @@ const AgentCreateCommand = effectCmd({ const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { const cliPath = args.path const cliDescription = args.description @@ -127,9 +127,7 @@ const AgentCreateCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Generating agent configuration...") const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { + const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) if (isFullyNonInteractive) process.exit(1) throw new UI.CancelledError() diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d9927e287..2ae7cece6 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -19,7 +19,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -606,11 +605,13 @@ export const McpDebugCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.mcp.debug")(function* (args) { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} const serverName = args.name @@ -636,15 +637,11 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`Server: ${serverName}`) prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } + // Check stored auth status — services already in hand, run inline. + const { authStatus, entry } = await Effect.runPromise( + Effect.all({ + authStatus: mcp.getAuthStatus(serverName), + entry: auth.get(serverName), }), ) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) @@ -704,11 +701,6 @@ export const McpDebugCommand = effectCmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) const authProvider = new McpOAuthProvider( serverName, serverConfig.url, From bd32252a7e3570f4501d7e217ad2380536dea095 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:42:40 -0400 Subject: [PATCH 102/174] =?UTF-8?q?refactor(cli/providers):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20drop=20inline=20AppRuntime.runPromise=20calls=20(#2?= =?UTF-8?q?5532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/providers.ts | 40 +++++++--------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 3dce55d32..081bcece0 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -239,19 +239,16 @@ export const ProvidersListCommand = effectCmd({ // Lists global credentials + provider env vars; no project instance needed. instance: false, handler: Effect.fn("Cli.providers.list")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service yield* Effect.promise(async () => { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() + const results = Object.entries(await Effect.runPromise(authSvc.all())) + const database = await Effect.runPromise(modelsDev.get()) for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -307,6 +304,8 @@ export const ProvidersLoginCommand = effectCmd({ type: "string", }), handler: Effect.fn("Cli.providers.login")(function* (args) { + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service yield* Effect.promise(async () => { UI.empty() prompts.intro("Add credential") @@ -342,7 +341,7 @@ export const ProvidersLoginCommand = effectCmd({ } await refreshModels().catch(() => {}) - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const config = await Effect.runPromise(cfgSvc.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined @@ -356,12 +355,7 @@ export const ProvidersLoginCommand = effectCmd({ } return filtered }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) + const hooks = await Effect.runPromise(pluginSvc.list()) const priority: Record = { opencode: 0, @@ -500,20 +494,17 @@ export const ProvidersLogoutCommand = effectCmd({ // Removes a global auth credential; no project instance needed. instance: false, handler: Effect.fn("Cli.providers.logout")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service yield* Effect.promise(async () => { UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) + const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all())) prompts.intro("Remove credential") if (credentials.length === 0) { prompts.log.error("No credentials found") return } - const database = await getModels() + const database = await Effect.runPromise(modelsDev.get()) const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ @@ -523,12 +514,7 @@ export const ProvidersLogoutCommand = effectCmd({ }) if (prompts.isCancel(selected)) throw new UI.CancelledError() const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) + await Effect.runPromise(authSvc.remove(providerID)) prompts.outro("Logout successful") }) }), From 2df8eda8a3baf8c624527995ae1adb4dc19a1071 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 00:24:33 -0400 Subject: [PATCH 103/174] fix(cli): bridge Instance.current ALS in effectCmd handlers (regression from #25522) (#25546) --- packages/opencode/src/cli/effect-cmd.ts | 27 ++++++----- .../test/cli/effect-cmd-instance-als.test.ts | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/cli/effect-cmd-instance-als.test.ts diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index b0f6de16b..ada5f8677 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" import { cmd, type WithDoubleDash } from "./cmd/cmd" /** @@ -82,17 +83,21 @@ export const effectCmd = (opts: EffectCmdOpts) => return } const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise( - InstanceStore.Service.use((store) => - store.provide( - { directory }, - Effect.gen(function* () { - const ctx = yield* InstanceRef - const body = opts.handler(args) - return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body - }), - ), - ), + // Two-phase: load ctx, then run body inside Instance.current ALS. + // Effect's InstanceRef is provided via fiber context, but that context is + // lost across `await` inside `Effect.promise(async () => ...)` callbacks + // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` + // there, attach() falls back to Instance.current ALS, which Node preserves + // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), ) + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } }, }) diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 000000000..de6fed8da --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +// Regression for PR #25522: when an effectCmd handler does +// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, +// the inner runPromise creates a fresh fiber after `await` whose Effect context +// has lost the outer InstanceRef. Services that read `InstanceState.context` +// then fall back to `Instance.current` ALS, which must be installed at the JS +// callback boundary (Node ALS persists across awaits, Effect's fiber context +// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// Pins effect-cmd.ts directly: the pattern test below exercises the load + +// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't +// fail it. This grep guards the actual production callsite. +test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { + const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") + expect(source).toContain("Instance.restore(ctx") +}) + +test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { + await using dir = await tmpdir({ git: true }) + await provideTestInstance({ + directory: dir.path, + fn: () => + Effect.runPromise( + Effect.promise(async () => { + await new Promise((r) => setTimeout(r, 5)) + const current = await Effect.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + ), + }) +}) From 9179bafd547d879c2b02bac10492eca7db2695fe Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 3 May 2026 01:04:52 -0400 Subject: [PATCH 104/174] Add debug info command (#25550) --- packages/opencode/src/cli/cmd/debug/index.ts | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 2603663fb..6e2643f68 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,10 @@ import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Flag } from "@opencode-ai/core/flag/flag" +import os from "os" import { Duration, Effect } from "effect" +import { Config } from "@/config/config" +import { ConfigPlugin } from "@/config/plugin" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" @@ -26,6 +31,7 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) + .command(InfoCommand) .command(PathsCommand) .command(WaitCommand) .demandCommand(), @@ -40,6 +46,34 @@ const WaitCommand = effectCmd({ }), }) +const InfoCommand = effectCmd({ + command: "info", + describe: "show debug information", + handler: Effect.fn("Cli.debug.info")(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const termProgram = process.env.TERM_PROGRAM + ? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}` + : undefined + const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ") + + console.log(`opencode version: ${InstallationVersion}`) + console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`) + console.log(`terminal: ${terminal || "unknown"}`) + console.log("plugins:") + if (Flag.OPENCODE_PURE) { + console.log("external plugins disabled (--pure)") + return + } + if (!config.plugin_origins?.length) { + console.log("none") + return + } + for (const plugin of config.plugin_origins) { + console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`) + } + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", From fc57eb3b8e0844fe3dfffda9ce769d002c8f6993 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:05:36 -0400 Subject: [PATCH 105/174] ci --- .github/TEAM_MEMBERS | 1 - .opencode/agent/triage.md | 133 +++++++------------------------------- 2 files changed, 24 insertions(+), 110 deletions(-) diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index 3b8519d3b..e5f8f000e 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -11,6 +11,5 @@ MrMushrooooom nexxeln R44VC0RP rekram1-node -RhysSullivan thdxr simonklee diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a77b92737..f6f2130f0 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -14,127 +14,42 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -## Labels +Assign issues by choosing the team with the strongest overlap, then assign a member from that team at random. -### windows +## Teams -Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. +### TUI -- Use if they mention WSL too +Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -#### perf +- kommander +- simonklee -Performance-related issues: +### Desktop / Web -- Slow performance -- High RAM usage -- High CPU usage +Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. - -#### desktop - -Desktop app issues: - -- `opencode web` command -- The desktop app itself - -**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues. - -#### nix - -**Only** add if the issue explicitly mentions nix. - -If the issue does not mention nix, do not add nix. - -If the issue mentions nix, assign to `rekram1-node`. - -#### zen - -**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". - -If the issue doesn't have "zen" or "opencode black" in it then don't add zen label - -#### core - -Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. - -Examples: - -- LSP server behavior -- Harness behavior (agent + tools) -- Feature requests for server behavior -- Agent context construction -- API endpoints -- Provider integration issues -- New, broken, or poor-quality models - -#### acp - -If the issue mentions acp support, assign acp label. - -#### docs - -Add if the issue requests better documentation or docs updates. - -#### opentui - -TUI issues potentially caused by our underlying TUI library: - -- Keybindings not working -- Scroll speed issues (too fast/slow/laggy) -- Screen flickering -- Crashes with opentui in the log - -**Do not** add for general TUI bugs. - -When assigning to people here are the following rules: - -Desktop / Web: -Use for desktop-labeled issues only. - -- adamdotdevin -- iamdavidhill +- Hona - Brendonovich -- nexxeln -Zen: -ONLY assign if the issue will have the "zen" label. +### Core + +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, and larger architectural features. + +- jlongster +- rekram1-node +- nexxeln +- kitlangton + +### Inference + +OpenCode Zen, OpenCode Go, and billing issues. - fwang - MrMushrooooom -TUI (`packages/opencode/src/cli/cmd/tui/...`): +### Windows -- thdxr for TUI UX/UI product decisions and interaction flow -- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks -- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues +Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. -Core (`packages/opencode/...`, excluding TUI subtree): - -- thdxr for sqlite/snapshot/memory bugs and larger architectural core features -- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable) -- rekram1-node for harness issues, provider issues, and other bug-squashing - -For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable. - -Docs: - -- R44VC0RP - -Windows: - -- Hona (assign any issue that mentions Windows or is likely Windows-specific) - -Determinism rules: - -- If title + body does not contain "zen", do not add the "zen" label -- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" -- If title + body mentions nix/nixos, assign to `rekram1-node` -- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner - -In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. - -ACP: - -- rekram1-node (assign any acp issues to rekram1-node) +- Hona From 7ccab8d2729bb804c94e49c62df521026a6f80f2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:10:14 -0400 Subject: [PATCH 106/174] core: update triage agent to use qwen3.6-plus model for improved response quality --- .opencode/agent/triage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index f6f2130f0..a4c8454a9 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/minimax-m2.5 +model: opencode/qwen3.6-plus color: "#44BA81" tools: "*": false From a08e4c96514b791391c9b81ade129f6634ad57f7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:21:17 -0400 Subject: [PATCH 107/174] core: simplify triage workflow to focus on issue ownership Switch triage agent to gpt-5.4-nano for faster issue assignment. Remove label management from the triage tool so it only assigns owners based on team ownership rules. This reduces noise in the issue tracker and ensures issues get to the right team member immediately without unnecessary labels. Update team structures to reflect current ownership and add script for processing unassigned issues. --- .opencode/agent/triage.md | 26 ++----- .opencode/tool/github-triage.ts | 78 +++---------------- script/triage-unassigned.ts | 129 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 87 deletions(-) create mode 100644 script/triage-unassigned.ts diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a4c8454a9..03df339cb 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/qwen3.6-plus +model: opencode/gpt-5.4-nano color: "#44BA81" tools: "*": false @@ -14,7 +14,11 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -Assign issues by choosing the team with the strongest overlap, then assign a member from that team at random. +Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. + +Do not add labels to issues. Only assign an owner. + +When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. ## Teams @@ -22,34 +26,18 @@ Assign issues by choosing the team with the strongest overlap, then assign a mem Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -- kommander -- simonklee - ### Desktop / Web Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -- Hona -- Brendonovich - ### Core -Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, and larger architectural features. - -- jlongster -- rekram1-node -- nexxeln -- kitlangton +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. ### Inference OpenCode Zen, OpenCode Go, and billing issues. -- fwang -- MrMushrooooom - ### Windows Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. - -- Hona diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 56886808a..e03b1fdd9 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,16 +1,14 @@ /// import { tool } from "@opencode-ai/plugin" + const TEAM = { - desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], - zen: ["fwang", "MrMushrooooom"], - tui: ["kommander", "rekram1-node", "simonklee"], - core: ["kitlangton", "rekram1-node", "jlongster"], - docs: ["R44VC0RP"], + tui: ["kommander", "simonklee"], + desktop_web: ["Hona", "Brendonovich"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + inference: ["fwang", "MrMushrooooom"], windows: ["Hona"], } as const -const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] - function pick(items: readonly T[]) { return items[Math.floor(Math.random() * items.length)]! } @@ -38,79 +36,23 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: `Use this tool to assign and/or label a GitHub issue. + description: `Use this tool to assign a GitHub issue. -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - assignee: tool.schema - .enum(ASSIGNEES as [string, ...string[]]) - .describe("The username of the assignee") - .default("rekram1-node"), - labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) - .describe("The labels(s) to add to the issue") - .default([]), + team: tool.schema.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]).describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() const owner = "anomalyco" const repo = "opencode" - - const results: string[] = [] - let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] - const web = labels.includes("web") - const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() - const zen = /\bzen\b/.test(text) || text.includes("opencode black") - const nix = /\bnix(os)?\b/.test(text) - - if (labels.includes("nix") && !nix) { - labels = labels.filter((x) => x !== "nix") - results.push("Dropped label: nix (issue does not mention nix)") - } - - const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee - - if (labels.includes("zen") && !zen) { - throw new Error("Only add the zen label when issue title/body contains 'zen'") - } - - if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { - throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") - } - - if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") - } - - if (assignee === "Hona" && !labels.includes("windows")) { - throw new Error("Only windows issues should be assigned to Hona") - } - - if (assignee === "R44VC0RP" && !labels.includes("docs")) { - throw new Error("Only docs issues should be assigned to R44VC0RP") - } - - if (assignee === "kommander" && !labels.includes("opentui")) { - throw new Error("Only opentui issues should be assigned to kommander") - } + const assignee = pick(TEAM[args.team]) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${assignee} to issue #${issue}`) - if (labels.length > 0) { - await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { - method: "POST", - body: JSON.stringify({ labels }), - }) - results.push(`Added labels: ${labels.join(", ")}`) - } - - return results.join("\n") + return `Assigned @${assignee} from ${args.team} to issue #${issue}` }, }) diff --git a/script/triage-unassigned.ts b/script/triage-unassigned.ts new file mode 100644 index 000000000..a71c6af31 --- /dev/null +++ b/script/triage-unassigned.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util" + +async function run(command: string, args: string[], options: Bun.SpawnOptions.OptionsObject = {}) { + const process = Bun.spawn([command, ...args], options) + const status = await process.exited + if (status !== 0) throw new Error(`${command} ${args.join(" ")} exited with ${status}`) + return process +} + +async function text(command: string, args: string[]) { + const process = await run(command, args, { stdout: "pipe", stderr: "inherit" }) + return new Response(process.stdout).text() +} + +async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + days: { type: "string", short: "d", default: "30" }, + limit: { type: "string", short: "l", default: "200" }, + "dry-run": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + }) + + if (values.help) { + console.log(` +Usage: bun script/triage-unassigned.ts [options] + +Triage open GitHub issues created in the last 30 days with no assignee. + +Options: + -d, --days Look back this many days (default: 30) + -l, --limit Maximum issues to process (default: 200) + --dry-run Print matching issues without running triage + -h, --help Show this help message + +Examples: + bun script/triage-unassigned.ts + bun script/triage-unassigned.ts --limit 3 + bun script/triage-unassigned.ts --dry-run +`) + process.exit(0) + } + + const days = Number(values.days) + const limit = Number(values.limit) + if (!Number.isInteger(days) || days < 1) throw new Error("--days must be a positive integer") + if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer") + + const created = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const query = `no:assignee created:>=${created}` + const issues = JSON.parse( + await text("gh", [ + "issue", + "list", + "--state", + "open", + "--search", + query, + "--limit", + String(limit), + "--json", + "number,title,body", + ]), + ) as Array<{ number: number; title: string; body?: string | null }> + + console.log(`Found ${issues.length} open unassigned issues created since ${created}`) + if (issues.length === 0) return + + if (values["dry-run"]) { + for (const issue of issues) console.log(`#${issue.number} ${issue.title}`) + return + } + + const githubToken = process.env.GITHUB_TOKEN || (await text("gh", ["auth", "token"])).trim() + const failures: Array<{ issue: number; error: string }> = [] + + for (const [index, issue] of issues.entries()) { + console.log(`\n[${index + 1}/${issues.length}] Triaging #${issue.number} ${issue.title}`) + const result = Bun.spawn( + [ + "opencode", + "run", + "--agent", + "triage", + `The following issue was just opened, triage it: + +Issue: #${issue.number} +Title: ${issue.title} + +Body: +${issue.body ?? ""}`, + ], + { + env: { + ...process.env, + GITHUB_TOKEN: githubToken, + ISSUE_NUMBER: String(issue.number), + ISSUE_TITLE: issue.title, + ISSUE_BODY: issue.body ?? "", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ) + const status = await result.exited + + if (status === 0) { + console.log(`[${index + 1}/${issues.length}] Done #${issue.number}`) + continue + } + + failures.push({ issue: issue.number, error: `opencode exited with ${status}` }) + console.error(`[${index + 1}/${issues.length}] Failed #${issue.number}: opencode exited with ${status}`) + } + + console.log(`\nFinished triaging ${issues.length - failures.length}/${issues.length} issues`) + if (failures.length === 0) return + + console.error("Failures:") + for (const failure of failures) console.error(`#${failure.issue}: ${failure.error}`) + process.exit(1) +} + +void main() From e2afdc1202d95cece585fbab599672f747625b71 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 05:22:22 +0000 Subject: [PATCH 108/174] chore: generate --- .opencode/tool/github-triage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index e03b1fdd9..35db44641 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -40,7 +40,9 @@ export default tool({ Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - team: tool.schema.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]).describe("The owning team"), + team: tool.schema + .enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) + .describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() From 252e2f98e68f448c4a5ec86073e216052a89997e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:31:34 -0400 Subject: [PATCH 109/174] ci: remove automatic labels from GitHub issue templates to allow manual triage --- .github/ISSUE_TEMPLATE/bug-report.yml | 1 - .github/ISSUE_TEMPLATE/feature-request.yml | 1 - .github/ISSUE_TEMPLATE/question.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index fe1ec8409..96234eb25 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,6 +1,5 @@ name: Bug report description: Report an issue that should be fixed -labels: ["bug"] body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c4757..42f1d3c51 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,6 +1,5 @@ name: 🚀 Feature Request description: Suggest an idea, feature, or enhancement -labels: [discussion] title: "[FEATURE]:" body: diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2310bfcc8..8930ba693 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,6 +1,5 @@ name: Question description: Ask a question -labels: ["question"] body: - type: textarea id: question From b205e104f6d8c2e1349545713ac79df64ffda730 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:53:22 -0400 Subject: [PATCH 110/174] ci: remove vouch-based contributor filtering workflows Removes the automated vouch system that filtered issues and PRs from non-vouched users. This simplifies the contribution process by removing the requirement for maintainers to manually vouch contributors before they can participate. --- .github/VOUCHED.td | 41 ------- .github/workflows/vouch-check-issue.yml | 116 -------------------- .github/workflows/vouch-check-pr.yml | 114 ------------------- .github/workflows/vouch-manage-by-issue.yml | 38 ------- 4 files changed, 309 deletions(-) delete mode 100644 .github/VOUCHED.td delete mode 100644 .github/workflows/vouch-check-issue.yml delete mode 100644 .github/workflows/vouch-check-pr.yml delete mode 100644 .github/workflows/vouch-manage-by-issue.yml diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 3f9df695a..000000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,41 +0,0 @@ -# Vouched contributors for this project. -# -# See https://github.com/mitchellh/vouch for details. -# -# Syntax: -# - One handle per line (without @), sorted alphabetically. -# - Optional platform prefix: platform:username (e.g., github:user). -# - Denounce with minus prefix: -username or -platform:username. -# - Optional details after a space following the handle. -adamdotdevin --agusbasari29 AI PR slop -ariane-emory --atharvau AI review spamming literally every PR --borealbytes --carycooper777 --danieljoshuanazareth --danieljoshuanazareth --davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person -dmtrkovalenko -edemaine -fahreddinozcan --florianleibert -fwang -iamdavidhill -jayair -kitlangton -kommander --opencode2026 --opencodeengineer bot that spams issues -r44vc0rp -rekram1-node --ricardo-m-l --robinmordasiewicz -rubdos --saisharan0103 spamming ai prs -shantur -simonklee --spider-yamet clawdbot/llm psychosis, spam pinging the team --terisuke -thdxr --toastythebot diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml deleted file mode 100644 index 4c2aa960b..000000000 --- a/.github/workflows/vouch-check-issue.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: vouch-check-issue - -on: - issues: - types: [opened] - -permissions: - contents: read - issues: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if issue author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.issue.user.login; - const issueNumber = context.payload.issue.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the issue - const body = 'This issue has been automatically closed.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'closed', - state_reason: 'not_planned', - }); - - core.info(`Closed issue #${issueNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing issue.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to issue #${issueNumber} from ${author}`); diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml deleted file mode 100644 index 51816dfb7..000000000 --- a/.github/workflows/vouch-check-pr.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: vouch-check-pr - -on: - pull_request_target: - types: [opened] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if PR author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.pull_request.user.login; - const prNumber = context.payload.pull_request.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: 'This pull request has been automatically closed.', - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - state: 'closed', - }); - - core.info(`Closed PR #${prNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing PR.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to PR #${prNumber} from ${author}`); diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml deleted file mode 100644 index 79687639d..000000000 --- a/.github/workflows/vouch-manage-by-issue.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: vouch-manage-by-issue - -on: - issue_comment: - types: [created] - -concurrency: - group: vouch-manage - cancel-in-progress: false - -permissions: - contents: write - issues: write - pull-requests: read - -jobs: - manage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - uses: mitchellh/vouch/action/manage-by-issue@main - with: - issue-id: ${{ github.event.issue.number }} - comment-id: ${{ github.event.comment.id }} - roles: admin,maintain,write - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} From 4f7f90133d939e462e5c47549b6f39a7bdce6cdb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:54:26 -0400 Subject: [PATCH 111/174] ci: stop sending daily community recap notifications --- .github/workflows/daily-issues-recap.yml | 170 ---------------------- .github/workflows/daily-pr-recap.yml | 173 ----------------------- 2 files changed, 343 deletions(-) delete mode 100644 .github/workflows/daily-issues-recap.yml delete mode 100644 .github/workflows/daily-pr-recap.yml diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/daily-issues-recap.yml deleted file mode 100644 index 31cf08233..000000000 --- a/.github/workflows/daily-issues-recap.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: daily-issues-recap - -on: - schedule: - # Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving) - - cron: "0 23 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - daily-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily issues recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - # Get today's date range - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather today's issues - Search for all OPEN issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 - - IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) issues only. - - STEP 2: Analyze and categorize - For each issue created today, categorize it: - - **Severity Assessment:** - - CRITICAL: Crashes, data loss, security issues, blocks major functionality - - HIGH: Significant bugs affecting many users, important features broken - - MEDIUM: Bugs with workarounds, minor features broken - - LOW: Minor issues, cosmetic, nice-to-haves - - **Activity Assessment:** - - Note issues with high comment counts or engagement - - Note issues from repeat reporters (check if author has filed before) - - STEP 3: Cross-reference with existing issues - For issues that seem like feature requests or recurring bugs: - - Search for similar older issues to identify patterns - - Note if this is a frequently requested feature - - Identify any issues that are duplicates of long-standing requests - - STEP 4: Generate the recap - Create a structured recap with these sections: - - ===DISCORD_START=== - **Daily Issues Recap - ${TODAY}** - - **Summary Stats** - - Total issues opened today: [count] - - By category: [bugs/features/questions] - - **Critical/High Priority Issues** - [List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers] - - **Most Active/Discussed** - [Issues with significant engagement or from active community members] - - **Trending Topics** - [Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature'] - - **Duplicates & Related** - [Issues that relate to existing open issues] - ===DISCORD_END=== - - STEP 5: Format for Discord - Format the recap as a Discord-compatible message: - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report - - Use hyperlinked issue numbers with suppressed embeds: [#1234]() - - Group related issues on single lines where possible - - Add emoji sparingly for critical items only - - HARD LIMIT: Keep under 1800 characters total - - Skip sections that have nothing notable (e.g., if no critical issues, omit that section) - - Prioritize signal over completeness - only surface what matters - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt - - echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily recap to Discord" diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/daily-pr-recap.yml deleted file mode 100644 index 2f0f023cf..000000000 --- a/.github/workflows/daily-pr-recap.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: daily-pr-recap - -on: - schedule: - # Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving) - - cron: "0 22 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - pr-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily PR recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh pr*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - - # Open PRs created today - gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - # Open PRs with activity today (updated today) - gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) contributions only. - - - - STEP 2: For high-activity PRs, check comment counts - For promising PRs, run: - gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length' - - IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts: - - copilot-pull-request-reviewer - - github-actions - - STEP 3: Identify what matters (ONLY from today's PRs) - - **Bug Fixes From Today:** - - PRs with 'fix' or 'bug' in title created/updated today - - Small bug fixes (< 100 lines changed) that are easy to review - - Bug fixes from community contributors - - **High Activity Today:** - - PRs with significant human comments today (excluding bots listed above) - - PRs with back-and-forth discussion today - - **Quick Wins:** - - Small PRs (< 50 lines) that are approved or nearly approved - - PRs that just need a final review - - STEP 4: Generate the recap - Create a structured recap: - - ===DISCORD_START=== - **Daily PR Recap - ${TODAY}** - - **New PRs Today** - [PRs opened today - group by type: bug fixes, features, etc.] - - **Active PRs Today** - [PRs with activity/updates today - significant discussion] - - **Quick Wins** - [Small PRs ready to merge] - ===DISCORD_END=== - - STEP 5: Format for Discord - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - surface what we might miss - - Use hyperlinked PR numbers with suppressed embeds: [#1234]() - - Include PR author: [#1234]() (@author) - - For bug fixes, add brief description of what it fixes - - Show line count for quick wins: \"(+15/-3 lines)\" - - HARD LIMIT: Keep under 1800 characters total - - Skip empty sections - - Focus on PRs that need human eyes - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt - - echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/pr_recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/pr_recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily PR recap to Discord" From 8299fb3e2b1720b557da56ab9d7505ace7f53fce Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:59:03 -0400 Subject: [PATCH 112/174] ignore: remove triage-unassigned.ts script This script was used to batch-triage open GitHub issues without assignees. Removing as the triage workflow has evolved and this batch approach is no longer needed. --- script/triage-unassigned.ts | 129 ------------------------------------ 1 file changed, 129 deletions(-) delete mode 100644 script/triage-unassigned.ts diff --git a/script/triage-unassigned.ts b/script/triage-unassigned.ts deleted file mode 100644 index a71c6af31..000000000 --- a/script/triage-unassigned.ts +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bun - -import { parseArgs } from "util" - -async function run(command: string, args: string[], options: Bun.SpawnOptions.OptionsObject = {}) { - const process = Bun.spawn([command, ...args], options) - const status = await process.exited - if (status !== 0) throw new Error(`${command} ${args.join(" ")} exited with ${status}`) - return process -} - -async function text(command: string, args: string[]) { - const process = await run(command, args, { stdout: "pipe", stderr: "inherit" }) - return new Response(process.stdout).text() -} - -async function main() { - const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - days: { type: "string", short: "d", default: "30" }, - limit: { type: "string", short: "l", default: "200" }, - "dry-run": { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - }) - - if (values.help) { - console.log(` -Usage: bun script/triage-unassigned.ts [options] - -Triage open GitHub issues created in the last 30 days with no assignee. - -Options: - -d, --days Look back this many days (default: 30) - -l, --limit Maximum issues to process (default: 200) - --dry-run Print matching issues without running triage - -h, --help Show this help message - -Examples: - bun script/triage-unassigned.ts - bun script/triage-unassigned.ts --limit 3 - bun script/triage-unassigned.ts --dry-run -`) - process.exit(0) - } - - const days = Number(values.days) - const limit = Number(values.limit) - if (!Number.isInteger(days) || days < 1) throw new Error("--days must be a positive integer") - if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer") - - const created = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) - const query = `no:assignee created:>=${created}` - const issues = JSON.parse( - await text("gh", [ - "issue", - "list", - "--state", - "open", - "--search", - query, - "--limit", - String(limit), - "--json", - "number,title,body", - ]), - ) as Array<{ number: number; title: string; body?: string | null }> - - console.log(`Found ${issues.length} open unassigned issues created since ${created}`) - if (issues.length === 0) return - - if (values["dry-run"]) { - for (const issue of issues) console.log(`#${issue.number} ${issue.title}`) - return - } - - const githubToken = process.env.GITHUB_TOKEN || (await text("gh", ["auth", "token"])).trim() - const failures: Array<{ issue: number; error: string }> = [] - - for (const [index, issue] of issues.entries()) { - console.log(`\n[${index + 1}/${issues.length}] Triaging #${issue.number} ${issue.title}`) - const result = Bun.spawn( - [ - "opencode", - "run", - "--agent", - "triage", - `The following issue was just opened, triage it: - -Issue: #${issue.number} -Title: ${issue.title} - -Body: -${issue.body ?? ""}`, - ], - { - env: { - ...process.env, - GITHUB_TOKEN: githubToken, - ISSUE_NUMBER: String(issue.number), - ISSUE_TITLE: issue.title, - ISSUE_BODY: issue.body ?? "", - }, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }, - ) - const status = await result.exited - - if (status === 0) { - console.log(`[${index + 1}/${issues.length}] Done #${issue.number}`) - continue - } - - failures.push({ issue: issue.number, error: `opencode exited with ${status}` }) - console.error(`[${index + 1}/${issues.length}] Failed #${issue.number}: opencode exited with ${status}`) - } - - console.log(`\nFinished triaging ${issues.length - failures.length}/${issues.length} issues`) - if (failures.length === 0) return - - console.error("Failures:") - for (const failure of failures) console.error(`#${failure.issue}: ${failure.error}`) - process.exit(1) -} - -void main() From d1f597b5b5abfe330aa30ca3c33ca043bf9b9a83 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 17:49:46 +0530 Subject: [PATCH 113/174] fix(vcs): avoid unbounded diff memory usage (#25581) --- packages/opencode/src/git/index.ts | 106 +++++++++- packages/opencode/src/project/vcs.ts | 227 +++++++++++++++------ packages/opencode/test/git/git.test.ts | 47 +++++ packages/opencode/test/project/vcs.test.ts | 29 +++ 4 files changed, 338 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 16a862447..fff1d70b2 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -24,6 +24,7 @@ const fail = (err: unknown) => text: () => "", stdout: Buffer.alloc(0), stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + truncated: false, }) satisfies Result export type Kind = "added" | "deleted" | "modified" @@ -45,16 +46,28 @@ export type Stat = { readonly deletions: number } +export type Patch = { + readonly text: string + readonly truncated: boolean +} + +export interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + export interface Result { readonly exitCode: number readonly text: () => string readonly stdout: Buffer readonly stderr: Buffer + readonly truncated: boolean } export interface Options { readonly cwd: string readonly env?: Record + readonly maxOutputBytes?: number } export interface Interface { @@ -68,6 +81,10 @@ export interface Interface { readonly status: (cwd: string) => Effect.Effect readonly diff: (cwd: string, ref: string) => Effect.Effect readonly stats: (cwd: string, ref: string) => Effect.Effect + readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly statUntracked: (cwd: string, file: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -96,15 +113,31 @@ export const layer = Layer.effect( stderr: "pipe", }) const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) + const collect = (stream: typeof handle.stdout) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (opts.maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + + const remaining = opts.maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 }) return { exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), + text: () => stdout.buffer.toString("utf8"), + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated || stderr.truncated, } satisfies Result }, Effect.scoped, @@ -240,6 +273,61 @@ export const layer = Layer.effect( }) }) + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("Git.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + if (result.truncated) return + const parts = result.text().split("\t") + if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + return Service.of({ run, branch, @@ -251,6 +339,10 @@ export const layer = Layer.effect( status, diff, stats, + patch, + patchAll, + patchUntracked, + statUntracked, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 24112cf44..28ac143ee 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,10 +1,8 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" @@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 -const count = (text: string) => { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) @@ -38,59 +27,168 @@ const merge = (...lists: Git.Item[][]) => { return [...out.values()] } +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Git.Patch) => { + const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { + if (list.length === 0) return { patches: new Map(), capped: false } + + const result = yield* git.patchAll(cwd, ref, { + context: PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, +) { + const result = + item.code === "??" || !ref + ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + if (!result.truncated && result.text) return result.text + + if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) + return emptyPatch(item.file) +}) + +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES }) + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, +) { + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(git, cwd, ref, item) +}) + const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, git: Git.Interface, cwd: string, ref: string | undefined, list: Git.Item[], map: Map, + batch: { patches: Map; capped: boolean }, ) { - const base = ref ? yield* git.prefix(cwd) : "" - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = map.get(item.file) - return { - file: item.file, - patch: patch(item.file, before, after), - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) + const next: FileDiff[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next }) -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string | undefined, -) { - if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, nums(stats)) -}) - -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string, -) { +const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) return yield* files( - fs, git, cwd, ref, @@ -99,9 +197,15 @@ const compare = Effect.fnUntraced(function* ( extra.filter((item) => item.code === "??"), ), nums(stats), + yield* batchPatches(git, cwd, ref, list), ) }) +const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) + return yield* diffAgainstRef(git, cwd, ref) +}) + export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Mode = Schema.Schema.Type @@ -147,10 +251,9 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* AppFileSystem.Service const git = yield* Git.Service const bus = yield* Bus.Service const scope = yield* Scope.Scope @@ -204,23 +307,19 @@ export const layer: Layer.Layer { }) }) + test("patch() returns capped native patch output", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + + await withGit(async (rt) => { + const [patch, all, capped] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("-before") + expect(patch.text).toContain("+after") + expect(all.truncated).toBe(false) + expect(all.text).toContain("diff --git") + expect(all.text).toContain("other.txt") + expect(all.text).toContain("+new") + expect(capped.truncated).toBe(true) + expect(capped.text).toBe("") + }) + }) + + test("patchUntracked() and statUntracked() handle added files", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + + await withGit(async (rt) => { + const [patch, stat] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("+one") + expect(patch.text).toContain("+two") + expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) + }) + }) + test("show() returns empty text for binary blobs", async () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 6fb0e251d..53ff547ac 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -234,6 +234,7 @@ describe("Vcs diff", () => { }), ]), ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") }) }) @@ -259,6 +260,34 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps batched patches aligned for type changes", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() + await fs.unlink(path.join(tmp.path, "a.txt")) + await fs.symlink("target", path.join(tmp.path, "a.txt")) + await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") + + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }) + }) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() From ca75ac668103730bab0f0fef382982dd79693c52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 08:58:34 -0400 Subject: [PATCH 114/174] refactor(server): extract Hono-coupled utilities to backend-neutral modules (#25542) --- packages/opencode/script/httpapi-exercise.ts | 4 +- packages/opencode/src/server/fence.ts | 74 +------------- packages/opencode/src/server/proxy.ts | 2 +- .../routes/instance/httpapi/handlers/tui.ts | 2 +- .../httpapi/middleware/workspace-routing.ts | 8 +- .../server/routes/instance/httpapi/server.ts | 2 +- .../src/server/routes/instance/tui.ts | 32 ++----- packages/opencode/src/server/routes/ui.ts | 96 +------------------ packages/opencode/src/server/shared/fence.ts | 74 ++++++++++++++ .../opencode/src/server/shared/tui-control.ts | 28 ++++++ packages/opencode/src/server/shared/ui.ts | 91 ++++++++++++++++++ .../src/server/shared/workspace-routing.ts | 36 +++++++ packages/opencode/src/server/workspace.ts | 41 +------- .../opencode/test/server/httpapi-ui.test.ts | 2 +- .../test/server/workspace-routing.test.ts | 6 +- 15 files changed, 265 insertions(+), 233 deletions(-) create mode 100644 packages/opencode/src/server/shared/fence.ts create mode 100644 packages/opencode/src/server/shared/tui-control.ts create mode 100644 packages/opencode/src/server/shared/ui.ts create mode 100644 packages/opencode/src/server/shared/workspace-routing.ts diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 1681f2e21..5bfcae14e 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -182,7 +182,7 @@ type Runtime = { Todo: (typeof import("../src/session/todo"))["Todo"] Worktree: (typeof import("../src/worktree"))["Worktree"] Project: (typeof import("../src/project/project"))["Project"] - Tui: typeof import("../src/server/routes/instance/tui") + Tui: typeof import("../src/server/shared/tui-control") disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] @@ -203,7 +203,7 @@ function runtime() { const todo = await import("../src/session/todo") const worktree = await import("../src/worktree") const project = await import("../src/project/project") - const tui = await import("../src/server/routes/instance/tui") + const tui = await import("../src/server/shared/tui-control") const fixture = await import("../test/fixture/fixture") const db = await import("../test/fixture/db") return { diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index aa784c90d..1b8c42c89 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -1,78 +1,8 @@ import type { MiddlewareHandler } from "hono" -import { Database } from "@/storage/db" -import { inArray } from "drizzle-orm" -import { EventSequenceTable } from "@/sync/event.sql" -import { Workspace } from "@/control-plane/workspace" -import type { WorkspaceID } from "@/control-plane/schema" import * as Log from "@opencode-ai/core/util/log" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" +import { HEADER, diff, load } from "./shared/fence" -const HEADER = "x-opencode-sync" -type State = Record -const log = Log.create({ service: "fence" }) - -export function load(ids?: string[]) { - const rows = Database.use((db) => { - if (!ids?.length) { - return db.select().from(EventSequenceTable).all() - } - - return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() - }) - - return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State -} - -export function diff(prev: State, next: State) { - const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) - return Object.fromEntries( - [...ids] - .map((id) => [id, next[id] ?? -1] as const) - .filter(([id, seq]) => { - return (prev[id] ?? -1) !== seq - }), - ) as State -} - -export function parse(headers: Headers) { - const raw = headers.get(HEADER) - if (!raw) return - - let data - - try { - data = JSON.parse(raw) - } catch { - return - } - - if (!data || typeof data !== "object") return - - return Object.fromEntries( - Object.entries(data).filter(([id, seq]) => { - return typeof id === "string" && Number.isInteger(seq) - }), - ) as State -} - -export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - return Effect.gen(function* () { - log.info("waiting for state", { - workspaceID, - state, - }) - yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) - log.info("state fully synced", { - workspaceID, - state, - }) - }) -} - -export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) -} +const log = Log.create({ service: "fence-middleware" }) export const FenceMiddleware: MiddlewareHandler = async (c, next) => { if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 051d64c24..069f30851 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" import * as Log from "@opencode-ai/core/util/log" -import * as Fence from "./fence" +import * as Fence from "./shared/fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index c7c447ce8..cc8532168 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -5,7 +5,7 @@ import * as Database from "@/storage/db" import { eq } from "drizzle-orm" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../../tui" +import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control" import { InstanceHttpApi } from "../api" import { CommandPayload, TuiPublishPayload } from "../groups/tui" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 4a07aaf11..caa520f7c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -5,8 +5,12 @@ import { Workspace } from "@/control-plane/workspace" import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" -import * as Fence from "@/server/fence" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import * as Fence from "@/server/shared/fence" +import { + getWorkspaceRouteSessionID, + isLocalWorkspaceRoute, + workspaceProxyURL, +} from "@/server/shared/workspace-routing" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e53eca3ef..650efe2b0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -45,7 +45,7 @@ import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" -import { serveUIEffect } from "@/server/routes/ui" +import { serveUIEffect } from "@/server/shared/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index d2be01521..a7a0c9cbd 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -7,32 +7,16 @@ import { Session } from "@/session/session" import type { SessionID } from "@/session/schema" import { TuiEvent } from "@/cli/cmd/tui/event" import { zodObject } from "@/util/effect-zod" -import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { runRequest } from "./trace" - -export const TuiRequest = z.object({ - path: z.string(), - body: z.any(), -}) - -export type TuiRequest = z.infer - -const request = new AsyncQueue() -const response = new AsyncQueue() - -export function nextTuiRequest() { - return request.next() -} - -export function submitTuiRequest(body: TuiRequest) { - request.push(body) -} - -export function submitTuiResponse(body: unknown) { - response.push(body) -} +import { + TuiRequest, + nextTuiRequest, + nextTuiResponse, + submitTuiRequest, + submitTuiResponse, +} from "@/server/shared/tui-control" export async function callTui(ctx: Context) { const body = await ctx.req.json() @@ -40,7 +24,7 @@ export async function callTui(ctx: Context) { path: ctx.req.path, body, }) - return response.next() + return nextTuiResponse() } const TuiControlRoutes = new Hono() diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 403d85d66..ce06b2b35 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,53 +1,10 @@ -import { Flag } from "@opencode-ai/core/flag/flag" +import fs from "node:fs/promises" +import { createHash } from "node:crypto" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Effect, Stream } from "effect" -import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Hono } from "hono" import { proxy } from "hono/proxy" -import { getMimeType } from "hono/utils/mime" -import { createHash } from "node:crypto" -import fs from "node:fs/promises" import { ProxyUtil } from "../proxy-util" - -const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI - ? Promise.resolve(null) - : // @ts-expect-error - generated file at build time - import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) - -const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" -const UI_UPSTREAM = new URL("https://app.opencode.ai") - -const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` - -function themePreloadHash(body: string) { - return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) -} - -function requestBody(request: HttpServerRequest.HttpServerRequest) { - if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty - const len = request.headers["content-length"] - return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) -} - -function proxyResponseHeaders(headers: Record) { - const result = new Headers(headers) - // FetchHttpClient exposes decoded response bodies, so forwarding upstream - // transfer metadata makes browsers decode already-decoded assets again. - result.delete("content-encoding") - result.delete("content-length") - return result -} - -function upstreamURL(path: string) { - return new URL(path, UI_UPSTREAM).toString() -} - -function embeddedUI() { - if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) - return embeddedUIPromise -} +import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -58,7 +15,7 @@ export async function serveUI(request: Request) { if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) if (await fs.exists(match)) { - const mime = getMimeType(match) ?? "text/plain" + const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) return new Response(new Uint8Array(await fs.readFile(match)), { headers }) @@ -79,49 +36,4 @@ export async function serveUI(request: Request) { return response } -export function serveUIEffect( - request: HttpServerRequest.HttpServerRequest, - services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, -) { - return Effect.gen(function* () { - const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) - const path = new URL(request.url, "http://localhost").pathname - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - - if (yield* services.fs.existsSafe(match)) { - const mime = getMimeType(match) ?? "text/plain" - const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) - } - - return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - } - - const response = yield* services.client.execute( - HttpClientRequest.make(request.method)(upstreamURL(path), { - headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), - body: requestBody(request), - }), - ) - const headers = proxyResponseHeaders(response.headers) - - if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text - const match = themePreloadHash(body) - headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) - return HttpServerResponse.text(body, { status: response.status, headers }) - } - - headers.set("Content-Security-Policy", csp()) - return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { - status: response.status, - headers, - }) - }) -} - export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts new file mode 100644 index 000000000..659764970 --- /dev/null +++ b/packages/opencode/src/server/shared/fence.ts @@ -0,0 +1,74 @@ +import { Database } from "@/storage/db" +import { inArray } from "drizzle-orm" +import { EventSequenceTable } from "@/sync/event.sql" +import { Workspace } from "@/control-plane/workspace" +import type { WorkspaceID } from "@/control-plane/schema" +import * as Log from "@opencode-ai/core/util/log" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" + +export const HEADER = "x-opencode-sync" +export type State = Record +const log = Log.create({ service: "fence" }) + +export function load(ids?: string[]) { + const rows = Database.use((db) => { + if (!ids?.length) { + return db.select().from(EventSequenceTable).all() + } + + return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + }) + + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State +} + +export function diff(prev: State, next: State) { + const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) + return Object.fromEntries( + [...ids] + .map((id) => [id, next[id] ?? -1] as const) + .filter(([id, seq]) => { + return (prev[id] ?? -1) !== seq + }), + ) as State +} + +export function parse(headers: Headers) { + const raw = headers.get(HEADER) + if (!raw) return + + let data + + try { + data = JSON.parse(raw) + } catch { + return + } + + if (!data || typeof data !== "object") return + + return Object.fromEntries( + Object.entries(data).filter(([id, seq]) => { + return typeof id === "string" && Number.isInteger(seq) + }), + ) as State +} + +export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + return Effect.gen(function* () { + log.info("waiting for state", { + workspaceID, + state, + }) + yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) + log.info("state fully synced", { + workspaceID, + state, + }) + }) +} + +export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) +} diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts new file mode 100644 index 000000000..40aaf04a9 --- /dev/null +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -0,0 +1,28 @@ +import z from "zod" +import { AsyncQueue } from "@/util/queue" + +export const TuiRequest = z.object({ + path: z.string(), + body: z.any(), +}) + +export type TuiRequest = z.infer + +const request = new AsyncQueue() +const response = new AsyncQueue() + +export function nextTuiRequest() { + return request.next() +} + +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + +export function submitTuiResponse(body: unknown) { + response.push(body) +} + +export function nextTuiResponse() { + return response.next() +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts new file mode 100644 index 000000000..db67749e0 --- /dev/null +++ b/packages/opencode/src/server/shared/ui.ts @@ -0,0 +1,91 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Stream } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createHash } from "node:crypto" +import { ProxyUtil } from "../proxy-util" + +const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI + ? Promise.resolve(null) + : // @ts-expect-error - generated file at build time + import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) + +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +export const UI_UPSTREAM = new URL("https://app.opencode.ai") + +export const csp = (hash = "") => + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` + +export function themePreloadHash(body: string) { + return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) +} + +function proxyResponseHeaders(headers: Record) { + const result = new Headers(headers) + // FetchHttpClient exposes decoded response bodies, so forwarding upstream + // transfer metadata makes browsers decode already-decoded assets again. + result.delete("content-encoding") + result.delete("content-length") + return result +} + +export function upstreamURL(path: string) { + return new URL(path, UI_UPSTREAM).toString() +} + +export function embeddedUI() { + if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) + return embeddedUIPromise +} + +export function serveUIEffect( + request: HttpServerRequest.HttpServerRequest, + services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, +) { + return Effect.gen(function* () { + const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) + const path = new URL(request.url, "http://localhost").pathname + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) + + if (yield* services.fs.existsSafe(match)) { + const mime = AppFileSystem.mimeType(match) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) + } + + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) + } + + const response = yield* services.client.execute( + HttpClientRequest.make(request.method)(upstreamURL(path), { + headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), + body: requestBody(request), + }), + ) + const headers = proxyResponseHeaders(response.headers) + + if (response.headers["content-type"]?.includes("text/html")) { + const body = yield* response.text + const match = themePreloadHash(body) + headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) + return HttpServerResponse.text(body, { status: response.status, headers }) + } + + headers.set("Content-Security-Policy", csp()) + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + headers, + }) + }) +} diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts new file mode 100644 index 000000000..366c455dd --- /dev/null +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -0,0 +1,36 @@ +import { SessionID } from "@/session/schema" + +type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } + +const RULES: Array = [ + { path: "/experimental/workspace", action: "local" }, + { path: "/session/status", action: "forward" }, + { method: "GET", path: "/session", action: "local" }, +] + +export function isLocalWorkspaceRoute(method: string, path: string) { + for (const rule of RULES) { + if (rule.method && rule.method !== method) continue + const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") + if (match) return rule.action === "local" + } + return false +} + +export function getWorkspaceRouteSessionID(url: URL) { + if (url.pathname === "/session/status") return null + + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + return SessionID.make(id) +} + +export function workspaceProxyURL(target: string | URL, requestURL: URL) { + const proxyURL = new URL(target) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` + proxyURL.search = requestURL.search + proxyURL.hash = requestURL.hash + proxyURL.searchParams.delete("workspace") + return proxyURL +} diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index f5f667222..6d4cae807 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -8,45 +8,14 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "@/effect/app-runtime" import { WithInstance } from "@/project/with-instance" import { Session } from "@/session/session" -import { SessionID } from "@/session/schema" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" - -type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } - -const RULES: Array = [ - { path: "/experimental/workspace", action: "local" }, - { path: "/session/status", action: "forward" }, - { method: "GET", path: "/session", action: "local" }, -] - -export function isLocalWorkspaceRoute(method: string, path: string) { - for (const rule of RULES) { - if (rule.method && rule.method !== method) continue - const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") - if (match) return rule.action === "local" - } - return false -} - -export function getWorkspaceRouteSessionID(url: URL) { - if (url.pathname === "/session/status") return null - - const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] - if (!id) return null - - return SessionID.make(id) -} - -export function workspaceProxyURL(target: string | URL, requestURL: URL) { - const proxyURL = new URL(target) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` - proxyURL.search = requestURL.search - proxyURL.hash = requestURL.hash - proxyURL.searchParams.delete("workspace") - return proxyURL -} +import { + getWorkspaceRouteSessionID, + isLocalWorkspaceRoute, + workspaceProxyURL, +} from "./shared/workspace-routing" async function getSessionWorkspace(url: URL) { const id = getWorkspaceRouteSessionID(url) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 7c9739f51..09b234bde 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -17,7 +17,7 @@ import { authorizationRouterMiddleware, } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { serveUIEffect } from "../../src/server/routes/ui" +import { serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 22c44a6df..a921ae277 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" -import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace" +import { + isLocalWorkspaceRoute, + getWorkspaceRouteSessionID, + workspaceProxyURL, +} from "../../src/server/shared/workspace-routing" import { SessionID } from "../../src/session/schema" describe("isLocalWorkspaceRoute", () => { From 3c9f3c5786f524d0861f4113be7d2cfa75db3a74 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 12:59:40 +0000 Subject: [PATCH 115/174] chore: generate --- .../routes/instance/httpapi/middleware/workspace-routing.ts | 6 +----- packages/opencode/src/server/workspace.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index caa520f7c..a91a9992d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -6,11 +6,7 @@ import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/shared/fence" -import { - getWorkspaceRouteSessionID, - isLocalWorkspaceRoute, - workspaceProxyURL, -} from "@/server/shared/workspace-routing" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 6d4cae807..097287530 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -11,11 +11,7 @@ import { Session } from "@/session/session" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" -import { - getWorkspaceRouteSessionID, - isLocalWorkspaceRoute, - workspaceProxyURL, -} from "./shared/workspace-routing" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" async function getSessionWorkspace(url: URL) { const id = getWorkspaceRouteSessionID(url) From 0ee3b872896085230049cc7eeeaee7eabfc644fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:06:23 -0400 Subject: [PATCH 116/174] feat(server): Server.openapi() backed by HttpApi spec, parity-checked against Hono output (#25545) --- packages/opencode/script/httpapi-exercise.ts | 2 +- packages/opencode/src/cli/cmd/generate.ts | 22 ++++++++++------ packages/opencode/src/server/server.ts | 25 +++++++++++++++++++ .../test/server/httpapi-bridge.test.ts | 6 ++--- .../opencode/test/server/httpapi-tui.test.ts | 2 +- packages/sdk/js/script/build.ts | 6 +++-- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 5bfcae14e..9755cf401 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -1506,7 +1506,7 @@ const main = Effect.gen(function* () { const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) - const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi())) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) const selected = scenarios.filter((scenario) => matches(options, scenario)) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 768002957..cb15b484e 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,22 +1,28 @@ import { Server } from "../../server/server" -import { PublicApi } from "../../server/routes/instance/httpapi/public" import type { CommandModule } from "yargs" -import { OpenApi } from "effect/unstable/httpapi" type Args = { httpapi: boolean + hono: boolean } export const GenerateCommand = { command: "generate", builder: (yargs) => - yargs.option("httpapi", { - type: "boolean", - default: false, - description: "Generate OpenAPI from the experimental Effect HttpApi contract", - }), + yargs + .option("httpapi", { + type: "boolean", + default: false, + description: + "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", + }) + .option("hono", { + type: "boolean", + default: false, + description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", + }), handler: async (args) => { - const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi() + const specs = args.hono ? await Server.openapiHono() : await Server.openapi() for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6ebc8dc48..13ec70616 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,6 +5,7 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" +import { OpenApi } from "effect/unstable/httpapi" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -17,6 +18,7 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { PublicApi } from "./routes/instance/httpapi/public" import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" @@ -135,7 +137,30 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv } } +/** + * Generate the OpenAPI document used by the SDK build. + * + * Since the Effect HttpApi backend now covers every Hono route (plus the new + * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity + * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. + * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` + * transform that injects instance query parameters, strips Effect's optional + * null arms, normalizes component names, and patches SSE response schemas so + * the generated SDK keeps the legacy Hono shape. + * + * The Hono-derived spec is still reachable via `openapiHono()` so reviewers + * can diff the two outputs while the Hono backend lingers; once the Hono + * backend is deleted that helper goes with it. + */ export async function openapi() { + return OpenApi.fromApi(PublicApi) +} + +/** + * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once + * the Hono backend is removed. + */ +export async function openapiHono() { // Build a fresh app with all routes registered directly so // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index b7ffa0ca5..615899f2b 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -222,7 +222,7 @@ describe("HttpApi server", () => { }) test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { - const honoRoutes = openApiRouteKeys(await Server.openapi()) + const honoRoutes = openApiRouteKeys(await Server.openapiHono()) const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) @@ -237,7 +237,7 @@ describe("HttpApi server", () => { }) test("matches generated OpenAPI route parameters", async () => { - const hono = openApiParameters(await Server.openapi()) + const hono = openApiParameters(await Server.openapiHono()) const effect = openApiParameters(effectOpenApi()) expect( @@ -248,7 +248,7 @@ describe("HttpApi server", () => { }) test("matches generated OpenAPI request body shape", async () => { - const hono = openApiRequestBodies(await Server.openapi()) + const hono = openApiRequestBodies(await Server.openapiHono()) const effect = openApiRequestBodies(effectOpenApi()) expect( diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 1b9e1c150..8d2670c49 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -46,7 +46,7 @@ afterEach(async () => { describe("tui HttpApi bridge", () => { test("documents legacy bad request responses", async () => { - const legacy = await Server.openapi() + const legacy = await Server.openapiHono() const effect = OpenApi.fromApi(TuiApi) for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index c490a0be7..946ad1402 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -12,10 +12,12 @@ import { createClient } from "@hey-api/openapi-ts" const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") +// `bun dev generate` now derives the spec from the Effect HttpApi contract by +// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. if (openapiSource === "httpapi") { - await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode) -} else { await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +} else { + await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) } await createClient({ From a43f767abbc8b6244142eb62e66a26ba7ec784bd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 13:07:30 +0000 Subject: [PATCH 117/174] chore: generate --- packages/sdk/openapi.json | 21186 +++++++++++++++++++----------------- 1 file changed, 11356 insertions(+), 9830 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b1c4ec1d7..df00c1726 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,16 +1,201 @@ { - "openapi": "3.1.1", + "openapi": "3.1.0", "info": { "title": "opencode", - "description": "opencode api", - "version": "1.0.0" + "version": "1.0.0", + "description": "opencode api" }, "paths": { + "/auth/{providerID}": { + "put": { + "tags": ["control"], + "operationId": "auth.set", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully set authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully set authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Set authentication credentials", + "summary": "Set auth credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auth" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["control"], + "operationId": "auth.remove", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully removed authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove authentication credentials", + "summary": "Remove auth credentials", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] + } + }, + "/log": { + "post": { + "tags": ["control"], + "operationId": "app.log", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Log entry written successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Log entry written successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Write a log entry to the server logs with specified level and metadata.", + "summary": "Write log", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name for the log entry" + }, + "level": { + "type": "string", + "enum": ["debug", "info", "error", "warn"], + "description": "Log level" + }, + "message": { + "type": "string", + "description": "Log message" + }, + "extra": { + "type": "object" + } + }, + "required": ["service", "level", "message"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + } + ] + } + }, "/global/health": { "get": { + "tags": ["global"], "operationId": "global.health", - "summary": "Get health", - "description": "Get health information about the OpenCode server.", + "parameters": [], "responses": { "200": { "description": "Health information", @@ -21,18 +206,22 @@ "properties": { "healthy": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["healthy", "version"] + "required": ["healthy", "version"], + "additionalProperties": false, + "description": "Health information" } } } } }, + "description": "Get health information about the OpenCode server.", + "summary": "Get health", "x-codeSamples": [ { "lang": "js", @@ -43,9 +232,9 @@ }, "/global/event": { "get": { + "tags": ["global"], "operationId": "global.event", - "summary": "Get global events", - "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "parameters": [], "responses": { "200": { "description": "Event stream", @@ -58,6 +247,8 @@ } } }, + "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "summary": "Get global events", "x-codeSamples": [ { "lang": "js", @@ -68,9 +259,9 @@ }, "/global/config": { "get": { + "tags": ["global"], "operationId": "global.config.get", - "summary": "Get global configuration", - "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Get global config info", @@ -83,6 +274,8 @@ } } }, + "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "summary": "Get global configuration", "x-codeSamples": [ { "lang": "js", @@ -91,9 +284,9 @@ ] }, "patch": { + "tags": ["global"], "operationId": "global.config.update", - "summary": "Update global configuration", - "description": "Update global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Successfully updated global config", @@ -116,6 +309,8 @@ } } }, + "description": "Update global OpenCode configuration settings and preferences.", + "summary": "Update global configuration", "requestBody": { "content": { "application/json": { @@ -135,21 +330,24 @@ }, "/global/dispose": { "post": { + "tags": ["global"], "operationId": "global.dispose", - "summary": "Dispose instance", - "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "parameters": [], "responses": { "200": { "description": "Global disposed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Global disposed" } } } } }, + "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "summary": "Dispose instance", "x-codeSamples": [ { "lang": "js", @@ -160,9 +358,9 @@ }, "/global/upgrade": { "post": { + "tags": ["global"], "operationId": "global.upgrade", - "summary": "Upgrade opencode", - "description": "Upgrade opencode to the specified version or latest if not specified.", + "parameters": [], "responses": { "200": { "description": "Upgrade result", @@ -175,28 +373,31 @@ "properties": { "success": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["success", "version"] + "required": ["success", "version"], + "additionalProperties": false }, { "type": "object", "properties": { "success": { "type": "boolean", - "const": false + "enum": [false] }, "error": { "type": "string" } }, - "required": ["success", "error"] + "required": ["success", "error"], + "additionalProperties": false } - ] + ], + "description": "Upgrade result" } } } @@ -212,6 +413,8 @@ } } }, + "description": "Upgrade opencode to the specified version or latest if not specified.", + "summary": "Upgrade opencode", "requestBody": { "content": { "application/json": { @@ -221,7 +424,8 @@ "target": { "type": "string" } - } + }, + "additionalProperties": false } } } @@ -234,1275 +438,72 @@ ] } }, - "/auth/{providerID}": { - "put": { - "operationId": "auth.set", - "summary": "Set auth credentials", - "description": "Set authentication credentials", - "responses": { - "200": { - "description": "Successfully set authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Auth" - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "auth.remove", - "summary": "Remove auth credentials", - "description": "Remove authentication credentials", - "responses": { - "200": { - "description": "Successfully removed authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" - } - ] - } - }, - "/log": { - "post": { - "operationId": "app.log", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Write log", - "description": "Write a log entry to the server logs with specified level and metadata.", - "responses": { - "200": { - "description": "Log entry written successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "service": { - "description": "Service name for the log entry", - "type": "string" - }, - "level": { - "description": "Log level", - "type": "string", - "enum": ["debug", "info", "error", "warn"] - }, - "message": { - "description": "Log message", - "type": "string" - }, - "extra": { - "description": "Additional metadata for the log entry", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["service", "level", "message"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/adapter": { + "/event": { "get": { - "operationId": "experimental.workspace.adapter.list", + "tags": ["event"], + "operationId": "event.subscribe", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List workspace adapters", - "description": "List all available workspace adapters for the current project.", "responses": { "200": { - "description": "Workspace adapters", + "description": "Event stream", "content": { - "application/json": { + "text/event-stream": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": ["type", "name", "description"] - } + "$ref": "#/components/schemas/Event" } } } } }, + "description": "Get events", + "summary": "Subscribe to events", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace": { - "post": { - "operationId": "experimental.workspace.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create workspace", - "description": "Create a workspace for the current project.", - "responses": { - "200": { - "description": "Workspace created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - } - }, - "required": ["type", "branch", "extra"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" - } - ] - }, - "get": { - "operationId": "experimental.workspace.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List workspaces", - "description": "List all workspaces.", - "responses": { - "200": { - "description": "Workspaces", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/status": { - "get": { - "operationId": "experimental.workspace.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Workspace status", - "description": "Get connection status for workspaces in the current project.", - "responses": { - "200": { - "description": "Workspace status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}": { - "delete": { - "operationId": "experimental.workspace.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Remove workspace", - "description": "Remove an existing workspace.", - "responses": { - "200": { - "description": "Workspace removed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}/session-restore": { - "post": { - "operationId": "experimental.workspace.sessionRestore", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Restore session into workspace", - "description": "Replay a session's sync events into the target workspace in batches.", - "responses": { - "200": { - "description": "Session replay started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["total"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" - } - ] - } - }, - "/project": { - "get": { - "operationId": "project.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List all projects", - "description": "Get a list of projects that have been opened with OpenCode.", - "responses": { - "200": { - "description": "List of projects", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" - } - ] - } - }, - "/project/current": { - "get": { - "operationId": "project.current", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get current project", - "description": "Retrieve the currently active project that OpenCode is working with.", - "responses": { - "200": { - "description": "Current project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" - } - ] - } - }, - "/project/git/init": { - "post": { - "operationId": "project.initGit", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Initialize git repository", - "description": "Create a git repository for the current project and return the refreshed project info.", - "responses": { - "200": { - "description": "Project information after git initialization", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" - } - ] - } - }, - "/project/{projectID}": { - "patch": { - "operationId": "project.update", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "projectID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Update project", - "description": "Update project properties such as name, icon, and commands.", - "responses": { - "200": { - "description": "Updated project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" - } - ] - } - }, - "/pty/shells": { - "get": { - "operationId": "pty.shells", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List available shells", - "description": "Get a list of available shells on the system.", - "responses": { - "200": { - "description": "List of shells", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "name": { - "type": "string" - }, - "acceptable": { - "type": "boolean" - } - }, - "required": ["path", "name", "acceptable"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" - } - ] - } - }, - "/pty": { - "get": { - "operationId": "pty.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List PTY sessions", - "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - "responses": { - "200": { - "description": "List of sessions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pty" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" - } - ] - }, - "post": { - "operationId": "pty.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create PTY session", - "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - "responses": { - "200": { - "description": "Created session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "title": { - "type": "string" - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" - } - ] - } - }, - "/pty/{ptyID}": { - "get": { - "operationId": "pty.get", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Get PTY session", - "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Session info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" - } - ] - }, - "put": { - "operationId": "pty.update", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Update PTY session", - "description": "Update properties of an existing pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "size": { - "type": "object", - "properties": { - "rows": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "cols": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["rows", "cols"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "pty.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Remove PTY session", - "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Session removed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" - } - ] - } - }, - "/pty/{ptyID}/connect": { - "get": { - "operationId": "pty.connect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Connect to PTY session", - "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - "responses": { - "200": { - "description": "Connected session", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" } ] } }, "/config": { "get": { + "tags": ["config"], "operationId": "config.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get configuration", - "description": "Retrieve the current OpenCode configuration settings and preferences.", "responses": { "200": { "description": "Get config info", @@ -1515,6 +516,8 @@ } } }, + "description": "Retrieve the current OpenCode configuration settings and preferences.", + "summary": "Get configuration", "x-codeSamples": [ { "lang": "js", @@ -1523,25 +526,26 @@ ] }, "patch": { + "tags": ["config"], "operationId": "config.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Update configuration", - "description": "Update OpenCode configuration settings and preferences.", "responses": { "200": { "description": "Successfully updated config", @@ -1564,6 +568,8 @@ } } }, + "description": "Update OpenCode configuration settings and preferences.", + "summary": "Update configuration", "requestBody": { "content": { "application/json": { @@ -1583,25 +589,26 @@ }, "/config/providers": { "get": { + "tags": ["config"], "operationId": "config.providers", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List config providers", - "description": "Get a list of all configured AI providers and their default models.", "responses": { "200": { "description": "List of providers", @@ -1618,20 +625,21 @@ }, "default": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["providers", "default"] + "required": ["providers", "default"], + "additionalProperties": false, + "description": "List of providers" } } } } }, + "description": "Get a list of all configured AI providers and their default models.", + "summary": "List config providers", "x-codeSamples": [ { "lang": "js", @@ -1642,25 +650,26 @@ }, "/experimental/console": { "get": { + "tags": ["experimental"], "operationId": "experimental.console.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get active Console provider metadata", - "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", "responses": { "200": { "description": "Active Console provider metadata", @@ -1673,6 +682,8 @@ } } }, + "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", + "summary": "Get active Console provider metadata", "x-codeSamples": [ { "lang": "js", @@ -1683,25 +694,26 @@ }, "/experimental/console/orgs": { "get": { + "tags": ["experimental"], "operationId": "experimental.console.listOrgs", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List switchable Console orgs", - "description": "Get the available Console orgs across logged-in accounts, including the current active org.", "responses": { "200": { "description": "Switchable Console orgs", @@ -1734,16 +746,21 @@ "type": "boolean" } }, - "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"] + "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"], + "additionalProperties": false } } }, - "required": ["orgs"] + "required": ["orgs"], + "additionalProperties": false, + "description": "Switchable Console orgs" } } } } }, + "description": "Get the available Console orgs across logged-in accounts, including the current active org.", + "summary": "List switchable Console orgs", "x-codeSamples": [ { "lang": "js", @@ -1754,37 +771,41 @@ }, "/experimental/console/switch": { "post": { + "tags": ["experimental"], "operationId": "experimental.console.switchOrg", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Switch active Console org", - "description": "Persist a new active Console account/org selection for the current local OpenCode state.", "responses": { "200": { "description": "Switch success", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Switch success" } } } } }, + "description": "Persist a new active Console account/org selection for the current local OpenCode state.", + "summary": "Switch active Console org", "requestBody": { "content": { "application/json": { @@ -1798,7 +819,8 @@ "type": "string" } }, - "required": ["accountID", "orgID"] + "required": ["accountID", "orgID"], + "additionalProperties": false } } } @@ -1811,94 +833,44 @@ ] } }, - "/experimental/tool/ids": { - "get": { - "operationId": "tool.ids", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List tool IDs", - "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - "responses": { - "200": { - "description": "Tool IDs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolIDs" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" - } - ] - } - }, "/experimental/tool": { "get": { + "tags": ["experimental"], "operationId": "tool.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "provider", + "in": "query", "schema": { "type": "string" }, "required": true }, { - "in": "query", "name": "model", + "in": "query", "schema": { "type": "string" }, "required": true } ], - "summary": "List tools", - "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", "responses": { "200": { "description": "Tools", @@ -1921,6 +893,8 @@ } } }, + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "summary": "List tools", "x-codeSamples": [ { "lang": "js", @@ -1929,27 +903,128 @@ ] } }, - "/experimental/worktree": { - "post": { - "operationId": "worktree.create", + "/experimental/tool/ids": { + "get": { + "tags": ["experimental"], + "operationId": "tool.ids", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tool IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolIDs" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "summary": "List tool IDs", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + } + ] + } + }, + "/experimental/worktree": { + "get": { + "tags": ["experimental"], + "operationId": "worktree.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of worktree directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of worktree directories" + } + } + } + } + }, + "description": "List all sandbox worktrees for the current project.", + "summary": "List worktrees", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["experimental"], + "operationId": "worktree.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create worktree", - "description": "Create a new git worktree for the current project and run any configured startup scripts.", "responses": { "200": { "description": "Worktree created", @@ -1972,6 +1047,8 @@ } } }, + "description": "Create a new git worktree for the current project and run any configured startup scripts.", + "summary": "Create worktree", "requestBody": { "content": { "application/json": { @@ -1988,75 +1065,35 @@ } ] }, - "get": { - "operationId": "worktree.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List worktrees", - "description": "List all sandbox worktrees for the current project.", - "responses": { - "200": { - "description": "List of worktree directories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" - } - ] - }, "delete": { + "tags": ["experimental"], "operationId": "worktree.remove", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Remove worktree", - "description": "Remove a git worktree and delete its branch.", "responses": { "200": { "description": "Worktree removed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Worktree removed" } } } @@ -2072,6 +1109,8 @@ } } }, + "description": "Remove a git worktree and delete its branch.", + "summary": "Remove worktree", "requestBody": { "content": { "application/json": { @@ -2091,32 +1130,34 @@ }, "/experimental/worktree/reset": { "post": { + "tags": ["experimental"], "operationId": "worktree.reset", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Reset worktree", - "description": "Reset a worktree branch to the primary default branch.", "responses": { "200": { "description": "Worktree reset", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Worktree reset" } } } @@ -2132,6 +1173,8 @@ } } }, + "description": "Reset a worktree branch to the primary default branch.", + "summary": "Reset worktree", "requestBody": { "content": { "application/json": { @@ -2151,26 +1194,28 @@ }, "/experimental/session": { "get": { + "tags": ["experimental"], "operationId": "experimental.session.list", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by project directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "roots", + "in": "query", "schema": { "anyOf": [ { @@ -2182,43 +1227,43 @@ } ] }, - "description": "Only return root sessions (no parentID)" + "required": false }, { - "in": "query", "name": "start", + "in": "query", "schema": { "type": "number" }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "cursor", + "in": "query", "schema": { "type": "number" }, - "description": "Return sessions updated before this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "search", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" + "required": false }, { - "in": "query", "name": "limit", + "in": "query", "schema": { "type": "number" }, - "description": "Maximum number of sessions to return" + "required": false }, { - "in": "query", "name": "archived", + "in": "query", "schema": { "anyOf": [ { @@ -2230,11 +1275,9 @@ } ] }, - "description": "Include archived sessions (default false)" + "required": false } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", "responses": { "200": { "description": "List of sessions", @@ -2244,12 +1287,15 @@ "type": "array", "items": { "$ref": "#/components/schemas/GlobalSession" - } + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", @@ -2260,25 +1306,26 @@ }, "/experimental/resource": { "get": { + "tags": ["experimental"], "operationId": "experimental.resource.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get MCP resources", - "description": "Get all available MCP resources from connected servers. Optionally filter by name.", "responses": { "200": { "description": "MCP resources", @@ -2286,17 +1333,17 @@ "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/McpResource" - } + }, + "description": "MCP resources" } } } } }, + "description": "Get all available MCP resources from connected servers. Optionally filter by name.", + "summary": "Get MCP resources", "x-codeSamples": [ { "lang": "js", @@ -2305,45 +1352,2753 @@ ] } }, - "/session": { + "/find": { "get": { - "operationId": "session.list", + "tags": ["file"], + "operationId": "find.text", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "pattern", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Matches", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "lines": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "line_number": { + "type": "integer", + "minimum": 0 + }, + "absolute_offset": { + "type": "integer", + "minimum": 0 + }, + "submatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["match", "start", "end"], + "additionalProperties": false + } + } + }, + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"], + "additionalProperties": false + }, + "description": "Matches" + } + } + } + } + }, + "description": "Search for text patterns across files in the project using ripgrep.", + "summary": "Find text", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + } + ] + } + }, + "/find/file": { + "get": { + "tags": ["file"], + "operationId": "find.files", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "dirs", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["file", "directory"] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false + } + ], + "responses": { + "200": { + "description": "File paths", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File paths" + } + } + } + } + }, + "description": "Search for files or directories by name or pattern in the project directory.", + "summary": "Find files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + } + ] + } + }, + "/find/symbol": { + "get": { + "tags": ["file"], + "operationId": "find.symbols", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Symbols", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + }, + "description": "Symbols" + } + } + } + } + }, + "description": "Search for workspace symbols like functions, classes, and variables using LSP.", + "summary": "Find symbols", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + } + ] + } + }, + "/file": { + "get": { + "tags": ["file"], + "operationId": "file.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Files and directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileNode" + }, + "description": "Files and directories" + } + } + } + } + }, + "description": "List files and directories in a specified path.", + "summary": "List files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + } + ] + } + }, + "/file/content": { + "get": { + "tags": ["file"], + "operationId": "file.read", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileContent" + } + } + } + } + }, + "description": "Read the content of a specified file.", + "summary": "Read file", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + } + ] + } + }, + "/file/status": { + "get": { + "tags": ["file"], + "operationId": "file.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + }, + "description": "File status" + } + } + } + } + }, + "description": "Get the git status of all files in the project.", + "summary": "Get file status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + } + ] + } + }, + "/instance/dispose": { + "post": { + "tags": ["instance"], + "operationId": "instance.dispose", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Instance disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Instance disposed" + } + } + } + } + }, + "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", + "summary": "Dispose instance", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + } + ] + } + }, + "/path": { + "get": { + "tags": ["instance"], + "operationId": "path.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Path" + } + } + } + } + }, + "description": "Retrieve the current working directory and related path information for the OpenCode instance.", + "summary": "Get paths", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + } + ] + } + }, + "/vcs": { + "get": { + "tags": ["instance"], + "operationId": "vcs.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsInfo" + } + } + } + } + }, + "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", + "summary": "Get VCS info", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" + } + ] + } + }, + "/vcs/diff": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "schema": { + "type": "string", + "enum": ["git", "branch"] + }, + "required": true + } + ], + "responses": { + "200": { + "description": "VCS diff", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileDiff" + }, + "description": "VCS diff" + } + } + } + } + }, + "description": "Retrieve the current git diff for the working tree or against the default branch.", + "summary": "Get VCS diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" + } + ] + } + }, + "/command": { + "get": { + "tags": ["instance"], + "operationId": "command.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of commands", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + }, + "description": "List of commands" + } + } + } + } + }, + "description": "Get a list of all available commands in the OpenCode system.", + "summary": "List commands", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + } + ] + } + }, + "/agent": { + "get": { + "tags": ["instance"], + "operationId": "app.agents", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of agents", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + }, + "description": "List of agents" + } + } + } + } + }, + "description": "Get a list of all available AI agents in the OpenCode system.", + "summary": "List agents", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + } + ] + } + }, + "/skill": { + "get": { + "tags": ["instance"], + "operationId": "app.skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of skills", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["name", "description", "location", "content"], + "additionalProperties": false + }, + "description": "List of skills" + } + } + } + } + }, + "description": "Get a list of all available skills in the OpenCode system.", + "summary": "List skills", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + } + ] + } + }, + "/lsp": { + "get": { + "tags": ["instance"], + "operationId": "lsp.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "LSP server status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LSPStatus" + }, + "description": "LSP server status" + } + } + } + } + }, + "description": "Get LSP server status", + "summary": "Get LSP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + } + ] + } + }, + "/formatter": { + "get": { + "tags": ["instance"], + "operationId": "formatter.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Formatter status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FormatterStatus" + }, + "description": "Formatter status" + } + } + } + } + }, + "description": "Get formatter status", + "summary": "Get formatter status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + } + ] + } + }, + "/mcp": { + "get": { + "tags": ["mcp"], + "operationId": "mcp.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP server status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server status" + } + } + } + } + }, + "description": "Get the status of all Model Context Protocol (MCP) servers.", + "summary": "Get MCP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + } + ] + }, + "post": { + "tags": ["mcp"], + "operationId": "mcp.add", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP server added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server added successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", + "summary": "Add MCP server", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "required": ["name", "config"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.start", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth flow started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string" + }, + "oauthState": { + "type": "string" + } + }, + "required": ["authorizationUrl", "oauthState"], + "additionalProperties": false, + "description": "OAuth flow started" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + "summary": "Start MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["mcp"], + "operationId": "mcp.auth.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth credentials removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["success"], + "additionalProperties": false, + "description": "OAuth credentials removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Remove OAuth credentials for an MCP server.", + "summary": "Remove MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/callback": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.callback", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + "summary": "Complete MCP OAuth", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "required": ["code"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/authenticate": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.authenticate", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Start OAuth flow and wait for callback (opens browser).", + "summary": "Authenticate MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/connect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.connect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server connected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "MCP server connected successfully" + } + } + } + } + }, + "description": "Connect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/disconnect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.disconnect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server disconnected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "MCP server disconnected successfully" + } + } + } + } + }, + "description": "Disconnect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + } + ] + } + }, + "/project": { + "get": { + "tags": ["project"], + "operationId": "project.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + }, + "description": "List of projects" + } + } + } + } + }, + "description": "Get a list of projects that have been opened with OpenCode.", + "summary": "List all projects", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + } + ] + } + }, + "/project/current": { + "get": { + "tags": ["project"], + "operationId": "project.current", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "description": "Retrieve the currently active project that OpenCode is working with.", + "summary": "Get current project", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + } + ] + } + }, + "/project/git/init": { + "post": { + "tags": ["project"], + "operationId": "project.initGit", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Project information after git initialization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "description": "Create a git repository for the current project and return the refreshed project info.", + "summary": "Initialize git repository", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" + } + ] + } + }, + "/project/{projectID}": { + "patch": { + "tags": ["project"], + "operationId": "project.update", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Update project properties such as name, icon, and commands.", + "summary": "Update project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + } + ] + } + }, + "/pty/shells": { + "get": { + "tags": ["pty"], + "operationId": "pty.shells", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of shells", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "acceptable": { + "type": "boolean" + } + }, + "required": ["path", "name", "acceptable"], + "additionalProperties": false + }, + "description": "List of shells" + } + } + } + } + }, + "description": "Get a list of available shells on the system.", + "summary": "List available shells", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" + } + ] + } + }, + "/pty": { + "get": { + "tags": ["pty"], + "operationId": "pty.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + }, + "description": "List of sessions" + } + } + } + } + }, + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "summary": "List PTY sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["pty"], + "operationId": "pty.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}": { + "get": { + "tags": ["pty"], + "operationId": "pty.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "summary": "Get PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + } + ] + }, + "put": { + "tags": ["pty"], + "operationId": "pty.update", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "cols": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["pty"], + "operationId": "pty.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session removed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Session removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "summary": "Remove PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + } + ] + } + }, + "/question": { + "get": { + "tags": ["question"], + "operationId": "question.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending questions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionRequest" + }, + "description": "List of pending questions" + } + } + } + } + }, + "description": "Get all pending question requests across all sessions.", + "summary": "List pending questions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reply": { + "post": { + "tags": ["question"], + "operationId": "question.reply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Question answered successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Question answered successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Provide answers to a question request from the AI assistant.", + "summary": "Reply to question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": ["answers"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reject": { + "post": { + "tags": ["question"], + "operationId": "question.reject", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Question rejected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Question rejected successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Reject a question request from the AI assistant.", + "summary": "Reject question request", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + } + ] + } + }, + "/permission": { + "get": { + "tags": ["permission"], + "operationId": "permission.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRequest" + }, + "description": "List of pending permissions" + } + } + } + } + }, + "description": "Get all pending permission requests across all sessions.", + "summary": "List pending permissions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, + "/permission/{requestID}/reply": { + "post": { + "tags": ["permission"], + "operationId": "permission.reply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Permission processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + }, + "message": { + "type": "string" + } + }, + "required": ["reply"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" + } + ] + } + }, + "/provider": { + "get": { + "tags": ["provider"], + "operationId": "provider.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["all", "default", "connected"], + "additionalProperties": false, + "description": "List of providers" + } + } + } + } + }, + "description": "Get a list of all available AI providers, including both available and connected ones.", + "summary": "List providers", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + } + ] + } + }, + "/provider/auth": { + "get": { + "tags": ["provider"], + "operationId": "provider.auth", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Provider auth methods", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethod" + } + }, + "description": "Provider auth methods" + } + } + } + } + }, + "description": "Retrieve available authentication methods for all AI providers.", + "summary": "Get provider auth methods", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/authorize": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.authorize", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Authorization URL and method", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderAuthAuthorization" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Start the OAuth authorization flow for a provider.", + "summary": "Start OAuth authorization", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["method"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/callback": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.callback", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth callback processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "OAuth callback processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Handle the OAuth callback from a provider after user authorization.", + "summary": "Handle OAuth callback", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "code": { + "type": "string" + } + }, + "required": ["method"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + } + ] + } + }, + "/session": { + "get": { + "tags": ["session"], + "operationId": "session.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "scope", + "in": "query", "schema": { "type": "string", "enum": ["project"] }, - "description": "List all sessions for the current project" + "required": false }, { - "in": "query", "name": "path", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by project-relative path" + "required": false }, { - "in": "query", "name": "roots", + "in": "query", "schema": { "anyOf": [ { @@ -2355,35 +4110,33 @@ } ] }, - "description": "Only return root sessions (no parentID)" + "required": false }, { - "in": "query", "name": "start", + "in": "query", "schema": { "type": "number" }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "search", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" + "required": false }, { - "in": "query", "name": "limit", + "in": "query", "schema": { "type": "number" }, - "description": "Maximum number of sessions to return" + "required": false } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", "responses": { "200": { "description": "List of sessions", @@ -2393,12 +4146,15 @@ "type": "array", "items": { "$ref": "#/components/schemas/Session" - } + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", @@ -2407,25 +4163,26 @@ ] }, "post": { + "tags": ["session"], "operationId": "session.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create session", - "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", "responses": { "200": { "description": "Successfully created session", @@ -2448,6 +4205,8 @@ } } }, + "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + "summary": "Create session", "requestBody": { "content": { "application/json": { @@ -2455,8 +4214,7 @@ "type": "object", "properties": { "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "title": { "type": "string" @@ -2477,16 +4235,17 @@ "type": "string" } }, - "required": ["id", "providerID"] + "required": ["id", "providerID"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" } - } + }, + "additionalProperties": false } } } @@ -2501,25 +4260,26 @@ }, "/session/status": { "get": { + "tags": ["session"], "operationId": "session.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get session status", - "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", "responses": { "200": { "description": "Get session status", @@ -2527,12 +4287,10 @@ "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/SessionStatus" - } + }, + "description": "Get session status" } } } @@ -2548,6 +4306,8 @@ } } }, + "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", + "summary": "Get session status", "x-codeSamples": [ { "lang": "js", @@ -2558,25 +4318,28 @@ }, "/session/{sessionID}": { "get": { + "tags": ["session"], "operationId": "session.get", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2584,9 +4347,6 @@ "required": true } ], - "summary": "Get session", - "description": "Retrieve detailed information about a specific OpenCode session.", - "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2619,6 +4379,8 @@ } } }, + "description": "Retrieve detailed information about a specific OpenCode session.", + "summary": "Get session", "x-codeSamples": [ { "lang": "js", @@ -2627,25 +4389,28 @@ ] }, "delete": { + "tags": ["session"], "operationId": "session.delete", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2653,15 +4418,14 @@ "required": true } ], - "summary": "Delete session", - "description": "Delete a session and permanently remove all associated data, including messages and history.", "responses": { "200": { "description": "Successfully deleted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted session" } } } @@ -2687,6 +4451,8 @@ } } }, + "description": "Delete a session and permanently remove all associated data, including messages and history.", + "summary": "Delete session", "x-codeSamples": [ { "lang": "js", @@ -2695,25 +4461,28 @@ ] }, "patch": { + "tags": ["session"], "operationId": "session.update", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2721,8 +4490,6 @@ "required": true } ], - "summary": "Update session", - "description": "Update properties of an existing session, such as title or other metadata.", "responses": { "200": { "description": "Successfully updated session", @@ -2755,6 +4522,8 @@ } } }, + "description": "Update properties of an existing session, such as title or other metadata.", + "summary": "Update session", "requestBody": { "content": { "application/json": { @@ -2773,9 +4542,11 @@ "archived": { "type": "number" } - } + }, + "additionalProperties": false } - } + }, + "additionalProperties": false } } } @@ -2790,25 +4561,28 @@ }, "/session/{sessionID}/children": { "get": { + "tags": ["session"], "operationId": "session.children", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2816,9 +4590,6 @@ "required": true } ], - "summary": "Get session children", - "tags": ["Session"], - "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { "description": "List of children", @@ -2828,7 +4599,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Session" - } + }, + "description": "List of children" } } } @@ -2854,6 +4626,8 @@ } } }, + "description": "Retrieve all child sessions that were forked from the specified parent session.", + "summary": "Get session children", "x-codeSamples": [ { "lang": "js", @@ -2864,25 +4638,28 @@ }, "/session/{sessionID}/todo": { "get": { + "tags": ["session"], "operationId": "session.todo", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2890,8 +4667,6 @@ "required": true } ], - "summary": "Get session todos", - "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", "responses": { "200": { "description": "Todo list", @@ -2901,7 +4676,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Todo" - } + }, + "description": "Todo list" } } } @@ -2927,6 +4703,8 @@ } } }, + "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", + "summary": "Get session todos", "x-codeSamples": [ { "lang": "js", @@ -2935,43 +4713,145 @@ ] } }, - "/session/{sessionID}/init": { - "post": { - "operationId": "session.init", + "/session/{sessionID}/diff": { + "get": { + "tags": ["session"], + "operationId": "session.diff", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + }, + { + "name": "messageID", + "in": "query", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": false } ], - "summary": "Initialize session", - "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", "responses": { "200": { - "description": "200", + "description": "Successfully retrieved diff", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + }, + "description": "Successfully retrieved diff" + } + } + } + } + }, + "description": "Get the file changes (diff) that resulted from a specific user message in the session.", + "summary": "Get message diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message": { + "get": { + "tags": ["session"], + "operationId": "session.messages", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "required": false + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "List of messages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false + }, + "description": "List of messages" } } } @@ -2997,57 +4877,369 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["modelID", "providerID", "messageID"] - } - } - } - }, + "description": "Retrieve all messages in a session, including user prompts and AI responses.", + "summary": "Get session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" } ] - } - }, - "/session/{sessionID}/fork": { + }, "post": { - "operationId": "session.fork", + "tags": ["session"], + "operationId": "session.prompt", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create and send a new message to a session, streaming the AI response.", + "summary": "Send message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": ["parts"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["session"], + "operationId": "session.message", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve a specific message from a session by its message ID.", + "summary": "Get message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["session"], + "operationId": "session.deleteMessage", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted message", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully deleted message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + "summary": "Delete message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/fork": { + "post": { + "tags": ["session"], + "operationId": "session.fork", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3055,8 +5247,6 @@ "required": true } ], - "summary": "Fork session", - "description": "Create a new session by forking an existing session at a specific message point.", "responses": { "200": { "description": "200", @@ -3069,6 +5259,8 @@ } } }, + "description": "Create a new session by forking an existing session at a specific message point.", + "summary": "Fork session", "requestBody": { "content": { "application/json": { @@ -3076,10 +5268,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } - } + }, + "additionalProperties": false } } } @@ -3094,25 +5286,28 @@ }, "/session/{sessionID}/abort": { "post": { + "tags": ["session"], "operationId": "session.abort", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3120,15 +5315,14 @@ "required": true } ], - "summary": "Abort session", - "description": "Abort an active session and stop any ongoing AI processing or command execution.", "responses": { "200": { "description": "Aborted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Aborted session" } } } @@ -3154,6 +5348,8 @@ } } }, + "description": "Abort an active session and stop any ongoing AI processing or command execution.", + "summary": "Abort session", "x-codeSamples": [ { "lang": "js", @@ -3162,27 +5358,126 @@ ] } }, - "/session/{sessionID}/share": { + "/session/{sessionID}/init": { "post": { - "operationId": "session.share", + "tags": ["session"], + "operationId": "session.init", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "200" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + "summary": "Initialize session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["modelID", "providerID", "messageID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/share": { + "post": { + "tags": ["session"], + "operationId": "session.share", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3190,8 +5485,6 @@ "required": true } ], - "summary": "Share session", - "description": "Create a shareable link for a session, allowing others to view the conversation.", "responses": { "200": { "description": "Successfully shared session", @@ -3224,6 +5517,8 @@ } } }, + "description": "Create a shareable link for a session, allowing others to view the conversation.", + "summary": "Share session", "x-codeSamples": [ { "lang": "js", @@ -3232,25 +5527,28 @@ ] }, "delete": { + "tags": ["session"], "operationId": "session.unshare", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3258,8 +5556,6 @@ "required": true } ], - "summary": "Unshare session", - "description": "Remove the shareable link for a session, making it private again.", "responses": { "200": { "description": "Successfully unshared session", @@ -3292,6 +5588,8 @@ } } }, + "description": "Remove the shareable link for a session, making it private again.", + "summary": "Unshare session", "x-codeSamples": [ { "lang": "js", @@ -3300,88 +5598,30 @@ ] } }, - "/session/{sessionID}/diff": { - "get": { - "operationId": "session.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "messageID", - "schema": { - "type": "string", - "pattern": "^msg.*" - } - } - ], - "summary": "Get message diff", - "description": "Get the file changes (diff) that resulted from a specific user message in the session.", - "responses": { - "200": { - "description": "Successfully retrieved diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" - } - ] - } - }, "/session/{sessionID}/summarize": { "post": { + "tags": ["session"], "operationId": "session.summarize", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3389,15 +5629,14 @@ "required": true } ], - "summary": "Summarize session", - "description": "Generate a concise summary of the session using AI compaction to preserve key information.", "responses": { "200": { "description": "Summarized session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Summarized session" } } } @@ -3423,6 +5662,8 @@ } } }, + "description": "Generate a concise summary of the session using AI compaction to preserve key information.", + "summary": "Summarize session", "requestBody": { "content": { "application/json": { @@ -3436,11 +5677,11 @@ "type": "string" }, "auto": { - "default": false, "type": "boolean" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false } } } @@ -3453,127 +5694,30 @@ ] } }, - "/session/{sessionID}/message": { - "get": { - "operationId": "session.messages", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "description": "Maximum number of messages to return" - }, - { - "in": "query", - "name": "before", - "schema": { - "type": "string" - } - } - ], - "summary": "Get session messages", - "description": "Retrieve all messages in a session, including user prompts and AI responses.", - "responses": { - "200": { - "description": "List of messages", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" - } - ] - }, + "/session/{sessionID}/prompt_async": { "post": { - "operationId": "session.prompt", + "tags": ["session"], + "operationId": "session.prompt_async", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3581,30 +5725,9 @@ "required": true } ], - "summary": "Send message", - "description": "Create and send a new message to a session, streaming the AI response.", "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } + "204": { + "description": "Prompt accepted" }, "400": { "description": "Bad request", @@ -3627,6 +5750,8 @@ } } }, + "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + "summary": "Send async message", "requestBody": { "content": { "application/json": { @@ -3634,8 +5759,7 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "model": { "type": "object", @@ -3647,7 +5771,8 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "agent": { "type": "string" @@ -3656,11 +5781,7 @@ "type": "boolean" }, "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -3694,7 +5815,8 @@ } } }, - "required": ["parts"] + "required": ["parts"], + "additionalProperties": false } } } @@ -3702,53 +5824,190 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" } ] } }, - "/session/{sessionID}/message/{messageID}": { - "get": { - "operationId": "session.message", + "/session/{sessionID}/command": { + "post": { + "tags": ["session"], + "operationId": "session.command", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + } + ], + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Send a new command to a session for execution by the AI assistant.", + "summary": "Send command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + } + } + }, + "required": ["arguments", "command"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/shell": { + "post": { + "tags": ["session"], + "operationId": "session.shell", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", "in": "path", - "name": "messageID", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^ses.*" }, "required": true } ], - "summary": "Get message", - "description": "Retrieve a specific message from a session by its message ID.", "responses": { "200": { - "description": "Message", + "description": "Created message", "content": { "application/json": { "schema": { @@ -3764,7 +6023,9 @@ } } }, - "required": ["info", "parts"] + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Created message" } } } @@ -3790,33 +6051,240 @@ } } }, + "description": "Execute a shell command within the session context and return the AI's response.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["agent", "command"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" } ] - }, - "delete": { - "operationId": "session.deleteMessage", + } + }, + "/session/{sessionID}/revert": { + "post": { + "tags": ["session"], + "operationId": "session.revert", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", + "summary": "Revert message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/unrevert": { + "post": { + "tags": ["session"], + "operationId": "session.unrevert", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Restore all previously reverted messages in a session.", + "summary": "Restore reverted messages", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/permissions/{permissionID}": { + "post": { + "tags": ["session"], + "operationId": "permission.respond", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3824,24 +6292,23 @@ "required": true }, { + "name": "permissionID", "in": "path", - "name": "messageID", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^per.*" }, "required": true } ], - "summary": "Delete message", - "description": "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", "responses": { "200": { - "description": "Successfully deleted message", + "description": "Permission processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Permission processed successfully" } } } @@ -3867,35 +6334,58 @@ } } }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["response"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" } ] } }, "/session/{sessionID}/message/{messageID}/part/{partID}": { "delete": { + "tags": ["session"], "operationId": "part.delete", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3903,8 +6393,8 @@ "required": true }, { - "in": "path", "name": "messageID", + "in": "path", "schema": { "type": "string", "pattern": "^msg.*" @@ -3912,8 +6402,8 @@ "required": true }, { - "in": "path", "name": "partID", + "in": "path", "schema": { "type": "string", "pattern": "^prt.*" @@ -3921,14 +6411,14 @@ "required": true } ], - "description": "Delete a part from a message", "responses": { "200": { "description": "Successfully deleted part", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted part" } } } @@ -3954,6 +6444,7 @@ } } }, + "description": "Delete a part from a message.", "x-codeSamples": [ { "lang": "js", @@ -3962,25 +6453,28 @@ ] }, "patch": { + "tags": ["session"], "operationId": "part.update", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3988,8 +6482,8 @@ "required": true }, { - "in": "path", "name": "messageID", + "in": "path", "schema": { "type": "string", "pattern": "^msg.*" @@ -3997,8 +6491,8 @@ "required": true }, { - "in": "path", "name": "partID", + "in": "path", "schema": { "type": "string", "pattern": "^prt.*" @@ -4006,7 +6500,6 @@ "required": true } ], - "description": "Update a part in a message", "responses": { "200": { "description": "Successfully updated part", @@ -4039,6 +6532,7 @@ } } }, + "description": "Update a part in a message.", "requestBody": { "content": { "application/json": { @@ -4056,1305 +6550,43 @@ ] } }, - "/session/{sessionID}/prompt_async": { - "post": { - "operationId": "session.prompt_async", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Send async message", - "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - "responses": { - "204": { - "description": "Prompt accepted" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "agent": { - "type": "string" - }, - "noReply": { - "type": "boolean" - }, - "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "system": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextPartInput" - }, - { - "$ref": "#/components/schemas/FilePartInput" - }, - { - "$ref": "#/components/schemas/AgentPartInput" - }, - { - "$ref": "#/components/schemas/SubtaskPartInput" - } - ] - } - } - }, - "required": ["parts"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/command": { - "post": { - "operationId": "session.command", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Send command", - "description": "Send a new command to a session for execution by the AI assistant.", - "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "string" - }, - "arguments": { - "type": "string" - }, - "command": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - } - } - }, - "required": ["arguments", "command"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/shell": { - "post": { - "operationId": "session.shell", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Run shell command", - "description": "Execute a shell command within the session context and return the AI's response.", - "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["agent", "command"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/revert": { - "post": { - "operationId": "session.revert", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Revert message", - "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["messageID"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/unrevert": { - "post": { - "operationId": "session.unrevert", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Restore reverted messages", - "description": "Restore all previously reverted messages in a session.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/permissions/{permissionID}": { - "post": { - "operationId": "permission.respond", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "path", - "name": "permissionID", - "schema": { - "type": "string", - "pattern": "^per.*" - }, - "required": true - } - ], - "summary": "Respond to permission", - "deprecated": true, - "description": "Approve or deny a permission request from the AI assistant.", - "responses": { - "200": { - "description": "Permission processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "response": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["response"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" - } - ] - } - }, - "/permission/{requestID}/reply": { - "post": { - "operationId": "permission.reply", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^per.*" - }, - "required": true - } - ], - "summary": "Respond to permission request", - "description": "Approve or deny a permission request from the AI assistant.", - "responses": { - "200": { - "description": "Permission processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - }, - "message": { - "type": "string" - } - }, - "required": ["reply"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" - } - ] - } - }, - "/permission": { - "get": { - "operationId": "permission.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending permissions", - "description": "Get all pending permission requests across all sessions.", - "responses": { - "200": { - "description": "List of pending permissions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionRequest" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" - } - ] - } - }, - "/question": { - "get": { - "operationId": "question.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending questions", - "description": "Get all pending question requests across all sessions.", - "responses": { - "200": { - "description": "List of pending questions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionRequest" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" - } - ] - } - }, - "/question/{requestID}/reply": { - "post": { - "operationId": "question.reply", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^que.*" - }, - "required": true - } - ], - "summary": "Reply to question request", - "description": "Provide answers to a question request from the AI assistant.", - "responses": { - "200": { - "description": "Question answered successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "answers": { - "description": "User answers in order of questions (each answer is an array of selected labels)", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["answers"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" - } - ] - } - }, - "/question/{requestID}/reject": { - "post": { - "operationId": "question.reject", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^que.*" - }, - "required": true - } - ], - "summary": "Reject question request", - "description": "Reject a question request from the AI assistant.", - "responses": { - "200": { - "description": "Question rejected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" - } - ] - } - }, - "/provider": { - "get": { - "operationId": "provider.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List providers", - "description": "Get a list of all available AI providers, including both available and connected ones.", - "responses": { - "200": { - "description": "List of providers", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "all": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - }, - "default": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "connected": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["all", "default", "connected"] - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" - } - ] - } - }, - "/provider/auth": { - "get": { - "operationId": "provider.auth", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get provider auth methods", - "description": "Retrieve available authentication methods for all AI providers.", - "responses": { - "200": { - "description": "Provider auth methods", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderAuthMethod" - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" - } - ] - } - }, - "/provider/{providerID}/oauth/authorize": { - "post": { - "operationId": "provider.oauth.authorize", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true, - "description": "Provider ID" - } - ], - "summary": "OAuth authorize", - "description": "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - "responses": { - "200": { - "description": "Authorization URL and method", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderAuthAuthorization" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "inputs": { - "description": "Prompt inputs", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["method"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" - } - ] - } - }, - "/provider/{providerID}/oauth/callback": { - "post": { - "operationId": "provider.oauth.callback", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true, - "description": "Provider ID" - } - ], - "summary": "OAuth callback", - "description": "Handle the OAuth callback from a provider after user authorization.", - "responses": { - "200": { - "description": "OAuth callback processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "code": { - "description": "OAuth authorization code", - "type": "string" - } - }, - "required": ["method"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" - } - ] - } - }, "/sync/start": { "post": { + "tags": ["sync"], "operationId": "sync.start", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Start workspace sync", - "description": "Start sync loops for workspaces in the current project that have active sessions.", "responses": { "200": { "description": "Workspace sync started", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Workspace sync started" } } } } }, + "description": "Start sync loops for workspaces in the current project that have active sessions.", + "summary": "Start workspace sync", "x-codeSamples": [ { "lang": "js", @@ -5365,25 +6597,26 @@ }, "/sync/replay": { "post": { + "tags": ["sync"], "operationId": "sync.replay", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Replay sync events", - "description": "Validate and replay a complete sync event history.", "responses": { "200": { "description": "Replayed sync events", @@ -5396,7 +6629,9 @@ "type": "string" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false, + "description": "Replayed sync events" } } } @@ -5412,6 +6647,8 @@ } } }, + "description": "Validate and replay a complete sync event history.", + "summary": "Replay sync events", "requestBody": { "content": { "application/json": { @@ -5422,8 +6659,8 @@ "type": "string" }, "events": { - "minItems": 1, "type": "array", + "minItems": 1, "items": { "type": "object", "properties": { @@ -5435,25 +6672,22 @@ }, "seq": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "type": { "type": "string" }, "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "aggregateID", "seq", "type", "data"] + "required": ["id", "aggregateID", "seq", "type", "data"], + "additionalProperties": false } } }, - "required": ["directory", "events"] + "required": ["directory", "events"], + "additionalProperties": false } } } @@ -5468,25 +6702,26 @@ }, "/sync/history": { "post": { + "tags": ["sync"], "operationId": "sync.history.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List sync events", - "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", "responses": { "200": { "description": "Sync events", @@ -5504,21 +6739,20 @@ "type": "string" }, "seq": { - "type": "number" + "type": "integer", + "minimum": 0 }, "type": { "type": "string" }, "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "aggregate_id", "seq", "type", "data"] - } + "required": ["id", "aggregate_id", "seq", "type", "data"], + "additionalProperties": false + }, + "description": "Sync events" } } } @@ -5534,18 +6768,16 @@ } } }, + "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + "summary": "List sync events", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } } } @@ -5559,511 +6791,35 @@ ] } }, - "/find": { + "/api/session": { "get": { - "operationId": "find.text", + "tags": ["v2"], + "operationId": "v2.session.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "query", - "name": "pattern", - "schema": { - "type": "string" - }, - "required": true } ], - "summary": "Find text", - "description": "Search for text patterns across files in the project using ripgrep.", "responses": { "200": { - "description": "Matches", + "description": "V2SessionsResponse", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "lines": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "line_number": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "absolute_offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "submatches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "match": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["match", "start", "end"] - } - } - }, - "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" - } - ] - } - }, - "/find/file": { - "get": { - "operationId": "find.files", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "dirs", - "schema": { - "type": "string", - "enum": ["true", "false"] - } - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "string", - "enum": ["file", "directory"] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - } - } - ], - "summary": "Find files", - "description": "Search for files or directories by name or pattern in the project directory.", - "responses": { - "200": { - "description": "File paths", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" - } - ] - } - }, - "/find/symbol": { - "get": { - "operationId": "find.symbols", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Find symbols", - "description": "Search for workspace symbols like functions, classes, and variables using LSP.", - "responses": { - "200": { - "description": "Symbols", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Symbol" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" - } - ] - } - }, - "/file": { - "get": { - "operationId": "file.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "List files", - "description": "List files and directories in a specified path.", - "responses": { - "200": { - "description": "Files and directories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileNode" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" - } - ] - } - }, - "/file/content": { - "get": { - "operationId": "file.read", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Read file", - "description": "Read the content of a specified file.", - "responses": { - "200": { - "description": "File content", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileContent" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" - } - ] - } - }, - "/file/status": { - "get": { - "operationId": "file.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get file status", - "description": "Get the git status of all files in the project.", - "responses": { - "200": { - "description": "File status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/File" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" - } - ] - } - }, - "/event": { - "get": { - "operationId": "event.subscribe", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Subscribe to events", - "description": "Get events", - "responses": { - "200": { - "description": "Event stream", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/Event" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" - } - ] - } - }, - "/mcp": { - "get": { - "operationId": "mcp.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get MCP status", - "description": "Get the status of all Model Context Protocol (MCP) servers.", - "responses": { - "200": { - "description": "MCP server status", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" - } - ] - }, - "post": { - "operationId": "mcp.add", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Add MCP server", - "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", - "responses": { - "200": { - "description": "MCP server added successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } + "$ref": "#/components/schemas/V2SessionsResponse" } } } @@ -6079,27 +6835,76 @@ } } }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List v2 sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.list({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": ["v2"], + "operationId": "v2.session.prompt", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session.Message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessage" + } + } + } + } + }, + "description": "Create a v2 session message and queue it for the agent loop.", + "summary": "Send v2 message", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "name": { - "type": "string" + "prompt": { + "$ref": "#/components/schemas/Prompt" }, - "config": { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] + "delivery": { + "$ref": "#/components/schemas/SessionDelivery" } }, - "required": ["name", "config"] + "required": ["prompt"], + "additionalProperties": false } } } @@ -6107,187 +6912,197 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.prompt({\n ...\n})" } ] } }, - "/mcp/{name}/auth": { + "/api/session/{sessionID}/compact": { "post": { - "operationId": "mcp.auth.start", + "tags": ["v2"], + "operationId": "v2.session.compact", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, + } + }, + { + "name": "sessionID", "in": "path", - "name": "name", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, "required": true } ], - "summary": "Start MCP OAuth", - "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", "responses": { - "200": { - "description": "OAuth flow started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "authorizationUrl": { - "description": "URL to open in browser for authorization", - "type": "string" - } - }, - "required": ["authorizationUrl"] - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } + "204": { + "description": "" } }, + "description": "Compact a v2 session conversation.", + "summary": "Compact v2 session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "mcp.auth.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true - } - ], - "summary": "Remove MCP OAuth", - "description": "Remove OAuth credentials for an MCP server", - "responses": { - "200": { - "description": "OAuth credentials removed", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "const": true - } - }, - "required": ["success"] - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.compact({\n ...\n})" } ] } }, - "/mcp/{name}/auth/callback": { + "/api/session/{sessionID}/wait": { "post": { - "operationId": "mcp.auth.callback", + "tags": ["v2"], + "operationId": "v2.session.wait", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, + } + }, + { + "name": "sessionID", "in": "path", - "name": "name", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "" + } + }, + "description": "Wait for a v2 session agent loop to become idle.", + "summary": "Wait for v2 session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.wait({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/context": { + "get": { + "tags": ["v2"], + "operationId": "v2.session.context", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, "required": true } ], - "summary": "Complete MCP OAuth", - "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", "responses": { "200": { - "description": "OAuth authentication completed", + "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MCPStatus" + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + } + } + } + } + }, + "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + "summary": "Get v2 session context", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.context({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/message": { + "get": { + "tags": ["v2 messages"], + "operationId": "v2.session.messages", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "V2SessionMessagesResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2SessionMessagesResponse" } } } @@ -6301,235 +7116,48 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "description": "Authorization code from OAuth callback", - "type": "string" - } - }, - "required": ["code"] - } - } } }, + "description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get v2 session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/auth/authenticate": { - "post": { - "operationId": "mcp.auth.authenticate", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true - } - ], - "summary": "Authenticate MCP OAuth", - "description": "Start OAuth flow and wait for callback (opens browser)", - "responses": { - "200": { - "description": "OAuth authentication completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/connect": { - "post": { - "operationId": "mcp.connect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "schema": { - "type": "string" - }, - "required": true - } - ], - "description": "Connect an MCP server", - "responses": { - "200": { - "description": "MCP server connected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/disconnect": { - "post": { - "operationId": "mcp.disconnect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "schema": { - "type": "string" - }, - "required": true - } - ], - "description": "Disconnect an MCP server", - "responses": { - "200": { - "description": "MCP server disconnected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.messages({\n ...\n})" } ] } }, "/tui/append-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.appendPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Append TUI prompt", - "description": "Append prompt to the TUI", "responses": { "200": { "description": "Prompt processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt processed successfully" } } } @@ -6545,6 +7173,8 @@ } } }, + "description": "Append prompt to the TUI.", + "summary": "Append TUI prompt", "requestBody": { "content": { "application/json": { @@ -6555,7 +7185,8 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } } } @@ -6570,37 +7201,41 @@ }, "/tui/open-help": { "post": { + "tags": ["tui"], "operationId": "tui.openHelp", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open help dialog", - "description": "Open the help dialog in the TUI to display user assistance information.", "responses": { "200": { "description": "Help dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Help dialog opened successfully" } } } } }, + "description": "Open the help dialog in the TUI to display user assistance information.", + "summary": "Open help dialog", "x-codeSamples": [ { "lang": "js", @@ -6611,37 +7246,41 @@ }, "/tui/open-sessions": { "post": { + "tags": ["tui"], "operationId": "tui.openSessions", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open sessions dialog", - "description": "Open the session dialog", "responses": { "200": { "description": "Session dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session dialog opened successfully" } } } } }, + "description": "Open the session dialog.", + "summary": "Open sessions dialog", "x-codeSamples": [ { "lang": "js", @@ -6652,37 +7291,41 @@ }, "/tui/open-themes": { "post": { + "tags": ["tui"], "operationId": "tui.openThemes", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open themes dialog", - "description": "Open the theme dialog", "responses": { "200": { "description": "Theme dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Theme dialog opened successfully" } } } } }, + "description": "Open the theme dialog.", + "summary": "Open themes dialog", "x-codeSamples": [ { "lang": "js", @@ -6693,37 +7336,41 @@ }, "/tui/open-models": { "post": { + "tags": ["tui"], "operationId": "tui.openModels", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open models dialog", - "description": "Open the model dialog", "responses": { "200": { "description": "Model dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Model dialog opened successfully" } } } } }, + "description": "Open the model dialog.", + "summary": "Open models dialog", "x-codeSamples": [ { "lang": "js", @@ -6734,37 +7381,41 @@ }, "/tui/submit-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.submitPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI prompt", - "description": "Submit the prompt", "responses": { "200": { "description": "Prompt submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt submitted successfully" } } } } }, + "description": "Submit the prompt.", + "summary": "Submit TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6775,37 +7426,41 @@ }, "/tui/clear-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.clearPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Clear TUI prompt", - "description": "Clear the prompt", "responses": { "200": { "description": "Prompt cleared successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt cleared successfully" } } } } }, + "description": "Clear the prompt.", + "summary": "Clear TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6816,32 +7471,34 @@ }, "/tui/execute-command": { "post": { + "tags": ["tui"], "operationId": "tui.executeCommand", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Execute TUI command", - "description": "Execute a TUI command (e.g. agent_cycle)", "responses": { "200": { "description": "Command executed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Command executed successfully" } } } @@ -6857,6 +7514,8 @@ } } }, + "description": "Execute a TUI command.", + "summary": "Execute TUI command", "requestBody": { "content": { "application/json": { @@ -6867,7 +7526,8 @@ "type": "string" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } } } @@ -6882,37 +7542,41 @@ }, "/tui/show-toast": { "post": { + "tags": ["tui"], "operationId": "tui.showToast", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Show TUI toast", - "description": "Show a toast notification in the TUI", "responses": { "200": { "description": "Toast notification shown successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Toast notification shown successfully" } } } } }, + "description": "Show a toast notification in the TUI.", + "summary": "Show TUI toast", "requestBody": { "content": { "application/json": { @@ -6930,13 +7594,12 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } } } @@ -6951,32 +7614,34 @@ }, "/tui/publish": { "post": { + "tags": ["tui"], "operationId": "tui.publish", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Publish TUI event", - "description": "Publish a TUI event", "responses": { "200": { "description": "Event published successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Event published successfully" } } } @@ -6992,22 +7657,24 @@ } } }, + "description": "Publish a TUI event.", + "summary": "Publish TUI event", "requestBody": { "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" + "$ref": "#/components/schemas/EventTuiPromptAppend" }, { - "$ref": "#/components/schemas/Event.tui.command.execute" + "$ref": "#/components/schemas/EventTuiCommandExecute" }, { - "$ref": "#/components/schemas/Event.tui.toast.show" + "$ref": "#/components/schemas/EventTuiToastShow" }, { - "$ref": "#/components/schemas/Event.tui.session.select" + "$ref": "#/components/schemas/EventTuiSessionSelect" } ] } @@ -7024,32 +7691,34 @@ }, "/tui/select-session": { "post": { + "tags": ["tui"], "operationId": "tui.selectSession", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Select session", - "description": "Navigate the TUI to display the specified session.", "responses": { "200": { "description": "Session selected successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session selected successfully" } } } @@ -7075,6 +7744,8 @@ } } }, + "description": "Navigate the TUI to display the specified session.", + "summary": "Select session", "requestBody": { "content": { "application/json": { @@ -7082,12 +7753,12 @@ "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } } } @@ -7102,25 +7773,26 @@ }, "/tui/control/next": { "get": { + "tags": ["tui"], "operationId": "tui.control.next", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get next TUI request", - "description": "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", "responses": { "200": { "description": "Next TUI request", @@ -7134,12 +7806,16 @@ }, "body": {} }, - "required": ["path", "body"] + "required": ["path", "body"], + "additionalProperties": false, + "description": "Next TUI request" } } } } }, + "description": "Retrieve the next TUI request from the queue for processing.", + "summary": "Get next TUI request", "x-codeSamples": [ { "lang": "js", @@ -7150,37 +7826,41 @@ }, "/tui/control/response": { "post": { + "tags": ["tui"], "operationId": "tui.control.response", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI response", - "description": "Submit a response to the TUI request queue to complete a pending request.", "responses": { "200": { "description": "Response submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Response submitted successfully" } } } } }, + "description": "Submit a response to the TUI request queue to complete a pending request.", + "summary": "Submit TUI response", "requestBody": { "content": { "application/json": { @@ -7196,294 +7876,31 @@ ] } }, - "/instance/dispose": { - "post": { - "operationId": "instance.dispose", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Dispose instance", - "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", - "responses": { - "200": { - "description": "Instance disposed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" - } - ] - } - }, - "/path": { + "/experimental/workspace/adapter": { "get": { - "operationId": "path.get", + "tags": ["workspace"], + "operationId": "experimental.workspace.adapter.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get paths", - "description": "Retrieve the current working directory and related path information for the OpenCode instance.", "responses": { "200": { - "description": "Path", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Path" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" - } - ] - } - }, - "/vcs": { - "get": { - "operationId": "vcs.get", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get VCS info", - "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", - "responses": { - "200": { - "description": "VCS info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VcsInfo" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" - } - ] - } - }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VcsFileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" - } - ] - } - }, - "/command": { - "get": { - "operationId": "command.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List commands", - "description": "Get a list of all available commands in the OpenCode system.", - "responses": { - "200": { - "description": "List of commands", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Command" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" - } - ] - } - }, - "/agent": { - "get": { - "operationId": "app.agents", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List agents", - "description": "Get a list of all available AI agents in the OpenCode system.", - "responses": { - "200": { - "description": "List of agents", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Agent" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" - } - ] - } - }, - "/skill": { - "get": { - "operationId": "app.skills", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List skills", - "description": "Get a list of all available skills in the OpenCode system.", - "responses": { - "200": { - "description": "List of skills", + "description": "Workspace adapters", "content": { "application/json": { "schema": { @@ -7491,118 +7908,442 @@ "items": { "type": "object", "properties": { + "type": { + "type": "string" + }, "name": { "type": "string" }, "description": { "type": "string" - }, - "location": { - "type": "string" - }, - "content": { - "type": "string" } }, - "required": ["name", "description", "location", "content"] - } + "required": ["type", "name", "description"], + "additionalProperties": false + }, + "description": "Workspace adapters" } } } } }, + "description": "List all available workspace adapters for the current project.", + "summary": "List workspace adapters", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } }, - "/lsp": { + "/experimental/workspace": { "get": { - "operationId": "lsp.status", + "tags": ["workspace"], + "operationId": "experimental.workspace.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get LSP status", - "description": "Get LSP server status", "responses": { "200": { - "description": "LSP server status", + "description": "Workspaces", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/LSPStatus" - } + "$ref": "#/components/schemas/Workspace" + }, + "description": "Workspaces" } } } } }, + "description": "List all workspaces.", + "summary": "List workspaces", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" } ] - } - }, - "/formatter": { - "get": { - "operationId": "formatter.status", + }, + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get formatter status", - "description": "Get formatter status", "responses": { "200": { - "description": "Formatter status", + "description": "Workspace created", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FormatterStatus" - } + "$ref": "#/components/schemas/Workspace" } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Create a workspace for the current project.", + "summary": "Create workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["type", "branch"], + "additionalProperties": false + } + } } }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/status": { + "get": { + "tags": ["workspace"], + "operationId": "experimental.workspace.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Workspace status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, + "description": "Workspace status" + } + } + } + } + }, + "description": "Get connection status for workspaces in the current project.", + "summary": "Workspace status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}": { + "delete": { + "tags": ["workspace"], + "operationId": "experimental.workspace.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Workspace removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove an existing workspace.", + "summary": "Remove workspace", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}/session-restore": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.sessionRestore", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session replay started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["total"], + "additionalProperties": false, + "description": "Session replay started" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Replay a session's sync events into the target workspace in batches.", + "summary": "Restore session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect": { + "get": { + "tags": ["pty"], + "operationId": "pty.connect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Connected session", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Connected session" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "summary": "Connect to PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" } ] } @@ -7610,165 +8351,311 @@ }, "components": { "schemas": { - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "id": { - "type": "string" + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" }, - "type": { - "type": "string", - "const": "server.instance.disposed" + { + "$ref": "#/components/schemas/EventFileEdited" }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartDelta" + }, + { + "$ref": "#/components/schemas/EventPermissionAsked" + }, + { + "$ref": "#/components/schemas/EventPermissionReplied" + }, + { + "$ref": "#/components/schemas/EventSessionDiff" + }, + { + "$ref": "#/components/schemas/EventSessionError" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdated" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/EventWorkspaceRestore" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed" + }, + { + "$ref": "#/components/schemas/EventPtyCreated" + }, + { + "$ref": "#/components/schemas/EventPtyUpdated" + }, + { + "$ref": "#/components/schemas/EventPtyExited" + }, + { + "$ref": "#/components/schemas/EventPtyDeleted" + }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/EventSessionCreated" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolError" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" } - }, - "required": ["id", "type", "properties"] + ] }, - "Event.file.edited": { + "OAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "file.edited" + "enum": ["oauth"] }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" } }, - "required": ["id", "type", "properties"] + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false }, - "Event.file.watcher.updated": { + "ApiAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "file.watcher.updated" + "enum": ["api"] }, - "properties": { + "key": { + "type": "string" + }, + "metadata": { "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] + "additionalProperties": { + "type": "string" + } } }, - "required": ["id", "type", "properties"] + "required": ["type", "key"], + "additionalProperties": false }, - "Event.lsp.client.diagnostics": { + "WellKnownAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "lsp.client.diagnostics" + "enum": ["wellknown"] }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"] + "key": { + "type": "string" + }, + "token": { + "type": "string" } }, - "required": ["id", "type", "properties"] + "required": ["type", "key", "token"], + "additionalProperties": false }, - "Event.lsp.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" }, - "type": { - "type": "string", - "const": "lsp.updated" + { + "$ref": "#/components/schemas/ApiAuth" }, - "properties": { - "type": "object", - "properties": {} + { + "$ref": "#/components/schemas/WellKnownAuth" } - }, - "required": ["id", "type", "properties"] - }, - "Event.message.part.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.part.delta" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "field": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["sessionID", "messageID", "partID", "field", "delta"] - } - }, - "required": ["id", "type", "properties"] + ] }, "PermissionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^per.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "permission": { "type": "string" @@ -7780,11 +8667,7 @@ } }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "always": { "type": "array", @@ -7796,64 +8679,18 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["id", "type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["id", "type", "properties"] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false }, "SnapshotFileDiff": { "type": "object", @@ -7866,56 +8703,26 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] - }, - "Event.session.diff": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.diff" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["sessionID", "diff"] - } - }, - "required": ["id", "type", "properties"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "ProviderAuthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ProviderAuthError" + "enum": ["ProviderAuthError"] }, "data": { "type": "object", @@ -7927,17 +8734,19 @@ "type": "string" } }, - "required": ["providerID", "message"] + "required": ["providerID", "message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "UnknownError": { "type": "object", "properties": { "name": { "type": "string", - "const": "UnknownError" + "enum": ["UnknownError"] }, "data": { "type": "object", @@ -7946,31 +8755,34 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageOutputLengthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageOutputLengthError" + "enum": ["MessageOutputLengthError"] }, "data": { "type": "object", "properties": {} } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageAbortedError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageAbortedError" + "enum": ["MessageAbortedError"] }, "data": { "type": "object", @@ -7979,17 +8791,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "StructuredOutputError": { "type": "object", "properties": { "name": { "type": "string", - "const": "StructuredOutputError" + "enum": ["StructuredOutputError"] }, "data": { "type": "object", @@ -7999,21 +8813,22 @@ }, "retries": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["message", "retries"] + "required": ["message", "retries"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "ContextOverflowError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ContextOverflowError" + "enum": ["ContextOverflowError"] }, "data": { "type": "object", @@ -8025,17 +8840,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "APIError": { "type": "object", "properties": { "name": { "type": "string", - "const": "APIError" + "enum": ["APIError"] }, "data": { "type": "object", @@ -8045,17 +8862,13 @@ }, "statusCode": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "isRetryable": { "type": "boolean" }, "responseHeaders": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -8065,205 +8878,96 @@ }, "metadata": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["message", "isRetryable"] + "required": ["message", "isRetryable"], + "additionalProperties": false } }, - "required": ["name", "data"] - }, - "Event.session.error": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.error" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - } - } - } - }, - "required": ["id", "type", "properties"] - }, - "Event.installation.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "installation.updated" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.installation.update-available": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "installation.update-available" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["id", "type", "properties"] + "required": ["name", "data"], + "additionalProperties": false }, "QuestionOption": { "type": "object", "properties": { "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "description": "Display text (1-5 words, concise)" }, "description": { - "description": "Explanation of choice", - "type": "string" + "type": "string", + "description": "Explanation of choice" } }, - "required": ["label", "description"] + "required": ["label", "description"], + "additionalProperties": false }, "QuestionInfo": { "type": "object", "properties": { "question": { - "description": "Complete question", - "type": "string" + "type": "string", + "description": "Complete question" }, "header": { - "description": "Very short label (max 30 chars)", - "type": "string" + "type": "string", + "description": "Very short label (max 30 chars)" }, "options": { - "description": "Available choices", "type": "array", "items": { "$ref": "#/components/schemas/QuestionOption" - } + }, + "description": "Available choices" }, "multiple": { - "description": "Allow selecting multiple choices", "type": "boolean" }, "custom": { - "description": "Allow typing a custom answer (default: true)", "type": "boolean" } }, - "required": ["question", "header", "options"] + "required": ["question", "header", "options"], + "additionalProperties": false }, "QuestionTool": { "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false }, "QuestionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "questions": { - "description": "Questions to ask", "type": "array", "items": { "$ref": "#/components/schemas/QuestionInfo" - } + }, + "description": "Questions to ask" }, "tool": { "$ref": "#/components/schemas/QuestionTool" } }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["id", "type", "properties"] + "required": ["id", "sessionID", "questions"], + "additionalProperties": false }, "QuestionAnswer": { "type": "array", @@ -8275,12 +8979,10 @@ "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "requestID": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "answers": { "type": "array", @@ -8289,100 +8991,40 @@ } } }, - "required": ["sessionID", "requestID", "answers"] - }, - "Event.question.replied": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "$ref": "#/components/schemas/QuestionReplied" - } - }, - "required": ["id", "type", "properties"] + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false }, "QuestionRejected": { "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRejected" + "requestID": { + "type": "string" } }, - "required": ["id", "type", "properties"] + "required": ["sessionID", "requestID"], + "additionalProperties": false }, "Todo": { "type": "object", "properties": { "content": { - "description": "Brief description of the task", - "type": "string" + "type": "string", + "description": "Brief description of the task" }, "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" }, "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] + "description": "Priority level of the task: high, medium, low" } }, - "required": ["id", "type", "properties"] + "required": ["content", "status", "priority"], + "additionalProperties": false }, "SessionStatus": { "anyOf": [ @@ -8391,124 +9033,56 @@ "properties": { "type": { "type": "string", - "const": "idle" + "enum": ["idle"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "message": { "type": "string" }, "next": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "attempt", "message", "next"] + "required": ["type", "attempt", "message", "next"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "busy" + "enum": ["busy"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false } ] }, - "Event.session.status": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.compacted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.compacted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["id", "type", "properties"] - }, "Event.tui.prompt.append": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.prompt.append" + "enum": ["tui.prompt.append"] }, "properties": { "type": "object", @@ -8517,17 +9091,22 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.command.execute": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.command.execute" + "enum": ["tui.command.execute"] }, "properties": { "type": "object", @@ -8561,17 +9140,22 @@ ] } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.toast.show": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.toast.show" + "enum": ["tui.toast.show"] }, "properties": { "type": "object", @@ -8587,117 +9171,41 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.session.select": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.session.select" + "enum": ["tui.session.select"] }, "properties": { "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } }, - "required": ["type", "properties"] - }, - "Event.mcp.tools.changed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "mcp.tools.changed" - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.command.executed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "command.executed" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"] - } - }, - "required": ["id", "type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Project": { "type": "object", @@ -8710,7 +9218,7 @@ }, "vcs": { "type": "string", - "const": "git" + "enum": ["git"] }, "name": { "type": "string" @@ -8727,37 +9235,37 @@ "color": { "type": "string" } - } + }, + "additionalProperties": false }, "commands": { "type": "object", "properties": { "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" } - } + }, + "additionalProperties": false }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "initialized": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "sandboxes": { "type": "array", @@ -8766,206 +9274,14 @@ } } }, - "required": ["id", "worktree", "time", "sandboxes"] - }, - "Event.project.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "project.updated" - }, - "properties": { - "$ref": "#/components/schemas/Project" - } - }, - "required": ["id", "type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.worktree.ready": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "worktree.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name", "branch"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.worktree.failed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "worktree.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["id", "type", "properties"] + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false }, "Pty": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "title": { "type": "string" @@ -8988,142 +9304,43 @@ }, "pid": { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"] - }, - "Event.pty.created": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.created" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.pty.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.updated" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.pty.exited": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.exited" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "exitCode": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["id", "exitCode"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.pty.deleted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.deleted" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - } - }, - "required": ["id"] - } - }, - "required": ["id", "type", "properties"] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "additionalProperties": false }, "OutputFormatText": { "type": "object", "properties": { "type": { "type": "string", - "const": "text" + "enum": ["text"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, "JSONSchema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "OutputFormatJsonSchema": { "type": "object", "properties": { "type": { "type": "string", - "const": "json_schema" + "enum": ["json_schema"] }, "schema": { "$ref": "#/components/schemas/JSONSchema" }, "retryCount": { - "default": 2, "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "schema"] + "required": ["type", "schema"], + "additionalProperties": false }, "OutputFormat": { "anyOf": [ @@ -9139,27 +9356,25 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "user" + "enum": ["user"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -9180,7 +9395,8 @@ } } }, - "required": ["diffs"] + "required": ["diffs"], + "additionalProperties": false }, "agent": { "type": "string" @@ -9198,53 +9414,49 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "system": { "type": "string" }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"] + "required": ["id", "sessionID", "role", "time", "agent", "model"], + "additionalProperties": false }, "AssistantMessage": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "assistant" + "enum": ["assistant"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "completed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "error": { "anyOf": [ @@ -9272,8 +9484,7 @@ ] }, "parentID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "modelID": { "type": "string" @@ -9297,7 +9508,8 @@ "type": "string" } }, - "required": ["cwd", "root"] + "required": ["cwd", "root"], + "additionalProperties": false }, "summary": { "type": "boolean" @@ -9310,42 +9522,38 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false }, "structured": {}, "variant": { @@ -9368,7 +9576,8 @@ "path", "cost", "tokens" - ] + ], + "additionalProperties": false }, "Message": { "anyOf": [ @@ -9380,77 +9589,21 @@ } ] }, - "Event.message.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.message.removed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["id", "type", "properties"] - }, "TextPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "text" + "enum": ["text"] }, "text": { "type": "string" @@ -9466,45 +9619,38 @@ "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "text"] + "required": ["id", "sessionID", "messageID", "type", "text"], + "additionalProperties": false }, "SubtaskPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "subtask" + "enum": ["subtask"] }, "prompt": { "type": "string" @@ -9525,61 +9671,56 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "command": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], + "additionalProperties": false }, "ReasoningPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "reasoning" + "enum": ["reasoning"] }, "text": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "text", "time"] + "required": ["id", "sessionID", "messageID", "type", "text", "time"], + "additionalProperties": false }, "FilePartSourceText": { "type": "object", @@ -9589,16 +9730,15 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false }, "FileSource": { "type": "object", @@ -9608,13 +9748,14 @@ }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "path": { "type": "string" } }, - "required": ["text", "type", "path"] + "required": ["text", "type", "path"], + "additionalProperties": false }, "Range": { "type": "object", @@ -9624,35 +9765,34 @@ "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false }, "end": { "type": "object", "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "SymbolSource": { "type": "object", @@ -9662,7 +9802,7 @@ }, "type": { "type": "string", - "const": "symbol" + "enum": ["symbol"] }, "path": { "type": "string" @@ -9675,11 +9815,11 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["text", "type", "path", "range", "name", "kind"] + "required": ["text", "type", "path", "range", "name", "kind"], + "additionalProperties": false }, "ResourceSource": { "type": "object", @@ -9689,7 +9829,7 @@ }, "type": { "type": "string", - "const": "resource" + "enum": ["resource"] }, "clientName": { "type": "string" @@ -9698,7 +9838,8 @@ "type": "string" } }, - "required": ["text", "type", "clientName", "uri"] + "required": ["text", "type", "clientName", "uri"], + "additionalProperties": false }, "FilePartSource": { "anyOf": [ @@ -9717,20 +9858,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "mime": { "type": "string" @@ -9745,79 +9883,66 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["id", "sessionID", "messageID", "type", "mime", "url"] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "additionalProperties": false }, "ToolStatePending": { "type": "object", "properties": { "status": { "type": "string", - "const": "pending" + "enum": ["pending"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "raw": { "type": "string" } }, - "required": ["status", "input", "raw"] + "required": ["status", "input", "raw"], + "additionalProperties": false }, "ToolStateRunning": { "type": "object", "properties": { "status": { "type": "string", - "const": "running" + "enum": ["running"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "title": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["status", "input", "time"] + "required": ["status", "input", "time"], + "additionalProperties": false }, "ToolStateCompleted": { "type": "object", "properties": { "status": { "type": "string", - "const": "completed" + "enum": ["completed"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "output": { "type": "string" @@ -9826,32 +9951,26 @@ "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacted": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "attachments": { "type": "array", @@ -9860,50 +9979,43 @@ } } }, - "required": ["status", "input", "output", "title", "metadata", "time"] + "required": ["status", "input", "output", "title", "metadata", "time"], + "additionalProperties": false }, "ToolStateError": { "type": "object", "properties": { "status": { "type": "string", - "const": "error" + "enum": ["error"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "error": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false } }, - "required": ["status", "input", "error", "time"] + "required": ["status", "input", "error", "time"], + "additionalProperties": false }, "ToolState": { "anyOf": [ @@ -9925,20 +10037,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "tool" + "enum": ["tool"] }, "callID": { "type": "string" @@ -9950,58 +10059,50 @@ "$ref": "#/components/schemas/ToolState" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], + "additionalProperties": false }, "StepStartPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-start" + "enum": ["step-start"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"] + "required": ["id", "sessionID", "messageID", "type"], + "additionalProperties": false }, "StepFinishPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-finish" + "enum": ["step-finish"] }, "reason": { "type": "string" @@ -10017,89 +10118,81 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], + "additionalProperties": false }, "SnapshotPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "snapshot" + "enum": ["snapshot"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "snapshot"] + "required": ["id", "sessionID", "messageID", "type", "snapshot"], + "additionalProperties": false }, "PatchPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "patch" + "enum": ["patch"] }, "hash": { "type": "string" @@ -10111,26 +10204,24 @@ } } }, - "required": ["id", "sessionID", "messageID", "type", "hash", "files"] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"], + "additionalProperties": false }, "AgentPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "agent" + "enum": ["agent"] }, "name": { "type": "string" @@ -10143,43 +10234,39 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "name"] + "required": ["id", "sessionID", "messageID", "type", "name"], + "additionalProperties": false }, "RetryPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "error": { "$ref": "#/components/schemas/APIError" @@ -10189,33 +10276,31 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], + "additionalProperties": false }, "CompactionPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "compaction" + "enum": ["compaction"] }, "auto": { "type": "boolean" @@ -10224,11 +10309,11 @@ "type": "boolean" }, "tail_start_id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "auto"] + "required": ["id", "sessionID", "messageID", "type", "auto"], + "additionalProperties": false }, "Part": { "anyOf": [ @@ -10270,68 +10355,6 @@ } ] }, - "Event.message.part.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.part.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.message.part.removed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.part.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["id", "type", "properties"] - }, "PermissionAction": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -10349,7 +10372,8 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": ["permission", "pattern", "action"] + "required": ["permission", "pattern", "action"], + "additionalProperties": false }, "PermissionRuleset": { "type": "array", @@ -10361,8 +10385,7 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -10371,8 +10394,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -10381,26 +10403,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -10409,7 +10427,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -10418,7 +10437,8 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" @@ -10439,7 +10459,8 @@ "type": "string" } }, - "required": ["id", "providerID"] + "required": ["id", "providerID"], + "additionalProperties": false }, "version": { "type": "string" @@ -10449,24 +10470,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -10475,12 +10494,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -10489,200 +10506,12 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] - }, - "Event.session.created": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.created" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.deleted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.deleted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.agent.switched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.agent.switched" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "agent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.model.switched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.model.switched" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "id", "providerID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Prompt.Source": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - }, - "text": { - "type": "string" - } - }, - "required": ["start", "end", "text"] - }, - "Prompt.FileAttachment": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "mime": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" - } - }, - "required": ["uri", "mime"] - }, - "Prompt.AgentAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" - } - }, - "required": ["name"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false }, "Prompt": { "type": "object", @@ -10693,2724 +10522,18 @@ "files": { "type": "array", "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" + "$ref": "#/components/schemas/PromptFileAttachment" } }, "agents": { "type": "array", "items": { - "$ref": "#/components/schemas/Prompt.AgentAttachment" + "$ref": "#/components/schemas/PromptAgentAttachment" } } }, - "required": ["text"] - }, - "Event.session.next.prompted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.prompted" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - } - }, - "required": ["timestamp", "sessionID", "prompt"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.synthetic": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.synthetic" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.shell.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.shell.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "command"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.shell.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.shell.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "output"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.step.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.step.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent", "model"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.step.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.step.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "finish": { - "type": "string" - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "output": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "reasoning": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "write": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["read", "write"] - } - }, - "required": ["input", "output", "reasoning", "cache"] - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.text.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.text.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["timestamp", "sessionID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.text.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.text.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "delta"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.text.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.text.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.reasoning.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.reasoning.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.reasoning.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.reasoning.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.reasoning.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.reasoning.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.input.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.input.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "name"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.input.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.input.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "delta"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.input.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.input.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.called": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.called" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] - } - }, - "required": ["id", "type", "properties"] - }, - "Tool.TextContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - }, - "text": { - "type": "string" - } - }, - "required": ["type", "text"] - }, - "Tool.FileContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file" - }, - "uri": { - "type": "string" - }, - "mime": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["type", "uri", "mime"] - }, - "Event.session.next.tool.progress": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.progress" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.success": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.success" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.error": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.error" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"] - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"] - } - }, - "required": ["id", "type", "properties"] - }, - "session.next.retry_error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["message", "isRetryable"] - }, - "Event.session.next.retried": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.retried" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "attempt": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "error": { - "$ref": "#/components/schemas/session.next.retry_error" - } - }, - "required": ["timestamp", "sessionID", "attempt", "error"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.compaction.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.compaction.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] - } - }, - "required": ["timestamp", "sessionID", "reason"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.compaction.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.compaction.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.compaction.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.compaction.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - }, - "include": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.server.connected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "server.connected" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"] - }, - "SyncEvent.message.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.removed.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.removed.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.created.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "projectID": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "workspaceID": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk.*" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "parentID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"] - }, - { - "type": "null" - } - ] - }, - "share": { - "type": "object", - "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "agent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "model": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] - }, - { - "type": "null" - } - ] - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "updated": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "compacting": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "archived": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - } - }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] - }, - "revert": { - "anyOf": [ - { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"] - }, - { - "type": "null" - } - ] - } - } - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.deleted.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.agent.switched": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.agent.switched.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "agent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.model.switched": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.model.switched.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "id", "providerID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.prompted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.prompted.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - } - }, - "required": ["timestamp", "sessionID", "prompt"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.synthetic": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.synthetic.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.shell.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.shell.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "command"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.shell.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.shell.ended.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "output"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.step.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.step.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent", "model"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.step.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.step.ended.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "finish": { - "type": "string" - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "output": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "reasoning": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "write": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["read", "write"] - } - }, - "required": ["input", "output", "reasoning", "cache"] - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.text.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.text.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["timestamp", "sessionID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.text.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.text.delta.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "delta"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.text.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.text.ended.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.reasoning.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.reasoning.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.reasoning.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.reasoning.delta.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.reasoning.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.reasoning.ended.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.input.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.input.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "name"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.input.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.input.delta.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "delta"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.input.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.input.ended.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.called": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.called.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.progress": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.progress.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.success": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.success.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.error.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"] - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.retried": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.retried.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "attempt": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "error": { - "$ref": "#/components/schemas/session.next.retry_error" - } - }, - "required": ["timestamp", "sessionID", "attempt", "error"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.compaction.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.compaction.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] - } - }, - "required": ["timestamp", "sessionID", "reason"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.compaction.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.compaction.delta.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.compaction.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.compaction.ended.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - }, - "include": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["text"], + "additionalProperties": false }, "GlobalEvent": { "type": "object", @@ -13427,61 +10550,61 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/EventServerInstanceDisposed" }, { - "$ref": "#/components/schemas/Event.file.edited" + "$ref": "#/components/schemas/EventFileEdited" }, { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/EventFileWatcherUpdated" }, { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { - "$ref": "#/components/schemas/Event.lsp.updated" + "$ref": "#/components/schemas/EventLspUpdated" }, { - "$ref": "#/components/schemas/Event.message.part.delta" + "$ref": "#/components/schemas/EventMessagePartDelta" }, { - "$ref": "#/components/schemas/Event.permission.asked" + "$ref": "#/components/schemas/EventPermissionAsked" }, { - "$ref": "#/components/schemas/Event.permission.replied" + "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/Event.session.diff" + "$ref": "#/components/schemas/EventSessionDiff" }, { - "$ref": "#/components/schemas/Event.session.error" + "$ref": "#/components/schemas/EventSessionError" }, { - "$ref": "#/components/schemas/Event.installation.updated" + "$ref": "#/components/schemas/EventInstallationUpdated" }, { - "$ref": "#/components/schemas/Event.installation.update-available" + "$ref": "#/components/schemas/EventInstallationUpdate-available" }, { - "$ref": "#/components/schemas/Event.question.asked" + "$ref": "#/components/schemas/EventQuestionAsked" }, { - "$ref": "#/components/schemas/Event.question.replied" + "$ref": "#/components/schemas/EventQuestionReplied" }, { - "$ref": "#/components/schemas/Event.question.rejected" + "$ref": "#/components/schemas/EventQuestionRejected" }, { - "$ref": "#/components/schemas/Event.todo.updated" + "$ref": "#/components/schemas/EventTodoUpdated" }, { - "$ref": "#/components/schemas/Event.session.status" + "$ref": "#/components/schemas/EventSessionStatus" }, { - "$ref": "#/components/schemas/Event.session.idle" + "$ref": "#/components/schemas/EventSessionIdle" }, { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/EventSessionCompacted" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -13496,288 +10619,284 @@ "$ref": "#/components/schemas/Event.tui.session.select" }, { - "$ref": "#/components/schemas/Event.mcp.tools.changed" + "$ref": "#/components/schemas/EventMcpToolsChanged" }, { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, { - "$ref": "#/components/schemas/Event.command.executed" + "$ref": "#/components/schemas/EventCommandExecuted" }, { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/EventWorkspaceReady" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/EventWorkspaceFailed" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/EventWorkspaceRestore" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/EventWorkspaceStatus" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/EventWorktreeReady" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/EventWorktreeFailed" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/EventPtyCreated" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/EventPtyUpdated" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/EventPtyExited" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/EventPtyDeleted" }, { - "$ref": "#/components/schemas/Event.message.updated" + "$ref": "#/components/schemas/EventMessageUpdated" }, { - "$ref": "#/components/schemas/Event.message.removed" + "$ref": "#/components/schemas/EventMessageRemoved" }, { - "$ref": "#/components/schemas/Event.message.part.updated" + "$ref": "#/components/schemas/EventMessagePartUpdated" }, { - "$ref": "#/components/schemas/Event.message.part.removed" + "$ref": "#/components/schemas/EventMessagePartRemoved" }, { - "$ref": "#/components/schemas/Event.session.created" + "$ref": "#/components/schemas/EventSessionCreated" }, { - "$ref": "#/components/schemas/Event.session.updated" + "$ref": "#/components/schemas/EventSessionUpdated" }, { - "$ref": "#/components/schemas/Event.session.deleted" + "$ref": "#/components/schemas/EventSessionDeleted" }, { - "$ref": "#/components/schemas/Event.session.next.agent.switched" + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, { - "$ref": "#/components/schemas/Event.session.next.model.switched" + "$ref": "#/components/schemas/EventSessionNextModelSwitched" }, { - "$ref": "#/components/schemas/Event.session.next.prompted" + "$ref": "#/components/schemas/EventSessionNextPrompted" }, { - "$ref": "#/components/schemas/Event.session.next.synthetic" + "$ref": "#/components/schemas/EventSessionNextSynthetic" }, { - "$ref": "#/components/schemas/Event.session.next.shell.started" + "$ref": "#/components/schemas/EventSessionNextShellStarted" }, { - "$ref": "#/components/schemas/Event.session.next.shell.ended" + "$ref": "#/components/schemas/EventSessionNextShellEnded" }, { - "$ref": "#/components/schemas/Event.session.next.step.started" + "$ref": "#/components/schemas/EventSessionNextStepStarted" }, { - "$ref": "#/components/schemas/Event.session.next.step.ended" + "$ref": "#/components/schemas/EventSessionNextStepEnded" }, { - "$ref": "#/components/schemas/Event.session.next.text.started" + "$ref": "#/components/schemas/EventSessionNextTextStarted" }, { - "$ref": "#/components/schemas/Event.session.next.text.delta" + "$ref": "#/components/schemas/EventSessionNextTextDelta" }, { - "$ref": "#/components/schemas/Event.session.next.text.ended" + "$ref": "#/components/schemas/EventSessionNextTextEnded" }, { - "$ref": "#/components/schemas/Event.session.next.reasoning.started" + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" }, { - "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" }, { - "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" }, { - "$ref": "#/components/schemas/Event.session.next.tool.input.started" + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" }, { - "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" }, { - "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" }, { - "$ref": "#/components/schemas/Event.session.next.tool.called" + "$ref": "#/components/schemas/EventSessionNextToolCalled" }, { - "$ref": "#/components/schemas/Event.session.next.tool.progress" + "$ref": "#/components/schemas/EventSessionNextToolProgress" }, { - "$ref": "#/components/schemas/Event.session.next.tool.success" + "$ref": "#/components/schemas/EventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/Event.session.next.tool.error" + "$ref": "#/components/schemas/EventSessionNextToolError" }, { - "$ref": "#/components/schemas/Event.session.next.retried" + "$ref": "#/components/schemas/EventSessionNextRetried" }, { - "$ref": "#/components/schemas/Event.session.next.compaction.started" + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" }, { - "$ref": "#/components/schemas/Event.session.next.compaction.delta" + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" }, { - "$ref": "#/components/schemas/Event.session.next.compaction.ended" + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, { - "$ref": "#/components/schemas/Event.server.connected" + "$ref": "#/components/schemas/EventServerConnected" }, { - "$ref": "#/components/schemas/Event.global.disposed" + "$ref": "#/components/schemas/EventGlobalDisposed" }, { - "$ref": "#/components/schemas/SyncEvent.message.updated" + "$ref": "#/components/schemas/SyncEventMessageUpdated" }, { - "$ref": "#/components/schemas/SyncEvent.message.removed" + "$ref": "#/components/schemas/SyncEventMessageRemoved" }, { - "$ref": "#/components/schemas/SyncEvent.message.part.updated" + "$ref": "#/components/schemas/SyncEventMessagePartUpdated" }, { - "$ref": "#/components/schemas/SyncEvent.message.part.removed" + "$ref": "#/components/schemas/SyncEventMessagePartRemoved" }, { - "$ref": "#/components/schemas/SyncEvent.session.created" + "$ref": "#/components/schemas/SyncEventSessionCreated" }, { - "$ref": "#/components/schemas/SyncEvent.session.updated" + "$ref": "#/components/schemas/SyncEventSessionUpdated" }, { - "$ref": "#/components/schemas/SyncEvent.session.deleted" + "$ref": "#/components/schemas/SyncEventSessionDeleted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.agent.switched" + "$ref": "#/components/schemas/SyncEventSessionNextAgentSwitched" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.model.switched" + "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.prompted" + "$ref": "#/components/schemas/SyncEventSessionNextPrompted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.synthetic" + "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.shell.started" + "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.shell.ended" + "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.step.started" + "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.step.ended" + "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.text.started" + "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.text.delta" + "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.text.ended" + "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.started" + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.delta" + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.ended" + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.started" + "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.delta" + "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.ended" + "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.called" + "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.progress" + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.success" + "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.error" + "$ref": "#/components/schemas/SyncEventSessionNextToolError" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.retried" + "$ref": "#/components/schemas/SyncEventSessionNextRetried" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.compaction.started" + "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.compaction.delta" + "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" }, { - "$ref": "#/components/schemas/SyncEvent.session.next.compaction.ended" + "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" } ] } }, - "required": ["directory", "payload"] + "required": ["directory", "payload"], + "additionalProperties": false }, "LogLevel": { - "description": "Log level", "type": "string", - "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"], + "description": "Log level" }, "ServerConfig": { - "description": "Server configuration for opencode serve and web commands", "type": "object", "properties": { "port": { - "description": "Port to listen on", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "hostname": { - "description": "Hostname to listen on", "type": "string" }, "mdns": { - "description": "Enable mDNS service discovery", "type": "boolean" }, "mdnsDomain": { - "description": "Custom domain name for mDNS service (default: opencode.local)", "type": "string" }, "cors": { - "description": "Additional domains to allow for CORS", "type": "array", "items": { "type": "string" } } - } + }, + "additionalProperties": false, + "description": "Server configuration for opencode serve and web commands" }, "PermissionActionConfig": { "type": "string", @@ -13785,9 +10904,6 @@ }, "PermissionObjectConfig": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/PermissionActionConfig" } @@ -13869,7 +10985,6 @@ "type": "string" }, "variant": { - "description": "Default model variant for this agent (applies only when using the agent's configured model).", "type": "string" }, "temperature": { @@ -13882,11 +10997,7 @@ "type": "string" }, "tools": { - "description": "@deprecated Use 'permission' field instead", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -13895,7 +11006,6 @@ "type": "boolean" }, "description": { - "description": "Description of when to use the agent", "type": "string" }, "mode": { @@ -13903,18 +11013,12 @@ "enum": ["subagent", "primary", "all"] }, "hidden": { - "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", "type": "boolean" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "color": { - "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", "anyOf": [ { "type": "string", @@ -13924,19 +11028,16 @@ "type": "string", "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } - ] + ], + "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)" }, "steps": { - "description": "Maximum number of agentic iterations before forcing text-only response", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "maxSteps": { - "description": "@deprecated Use 'steps' field instead.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "permission": { "$ref": "#/components/schemas/PermissionConfig" @@ -13987,41 +11088,33 @@ "type": "string" }, "enterpriseUrl": { - "description": "GitHub Enterprise URL for copilot authentication", "type": "string" }, "setCacheKey": { - "description": "Enable promptCacheKey for this provider (default false)", "type": "boolean" }, "timeout": { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, { "type": "boolean", - "const": false + "enum": [false] } - ] + ], + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout." }, "chunkTimeout": { - "description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, "additionalProperties": {} }, "models": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -14053,7 +11146,7 @@ "anyOf": [ { "type": "boolean", - "const": true + "enum": [true] }, { "type": "object", @@ -14063,7 +11156,8 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"] + "required": ["field"], + "additionalProperties": false } ] }, @@ -14098,10 +11192,12 @@ "type": "number" } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false }, "limit": { "type": "object", @@ -14116,7 +11212,8 @@ "type": "number" } }, - "required": ["context", "output"] + "required": ["context", "output"], + "additionalProperties": false }, "modalities": { "type": "object", @@ -14136,7 +11233,8 @@ } } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false }, "experimental": { "type": "boolean" @@ -14154,166 +11252,141 @@ "api": { "type": "string" } - } + }, + "additionalProperties": false }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "variants": { - "description": "Variant-specific configuration", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { "disabled": { - "description": "Disable this variant for the model", "type": "boolean" } }, "additionalProperties": {} - } + }, + "description": "Variant-specific configuration" } - } + }, + "additionalProperties": false } } - } + }, + "additionalProperties": false }, "McpLocalConfig": { "type": "object", "properties": { "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "local" + "enum": ["local"], + "description": "Type of MCP server connection" }, "command": { - "description": "Command and arguments to run the MCP server", "type": "array", "items": { "type": "string" - } + }, + "description": "Command and arguments to run the MCP server" }, "environment": { - "description": "Environment variables to set when running the MCP server", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "enabled": { - "description": "Enable or disable the MCP server on startup", "type": "boolean" }, "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["type", "command"] + "required": ["type", "command"], + "additionalProperties": false }, "McpOAuthConfig": { "type": "object", "properties": { "clientId": { - "description": "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", "type": "string" }, "clientSecret": { - "description": "OAuth client secret (if required by the authorization server)", "type": "string" }, "scope": { - "description": "OAuth scopes to request during authorization", "type": "string" }, "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "type": "string" } - } + }, + "additionalProperties": false }, "McpRemoteConfig": { "type": "object", "properties": { "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "remote" + "enum": ["remote"], + "description": "Type of MCP server connection" }, "url": { - "description": "URL of the remote MCP server", - "type": "string" + "type": "string", + "description": "URL of the remote MCP server" }, "enabled": { - "description": "Enable or disable the MCP server on startup", "type": "boolean" }, "headers": { - "description": "Headers to send with the request", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "oauth": { - "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", "anyOf": [ { "$ref": "#/components/schemas/McpOAuthConfig" }, { "type": "boolean", - "const": false + "enum": [false] } - ] + ], + "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection." }, "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["type", "url"] + "required": ["type", "url"], + "additionalProperties": false }, "LayoutConfig": { - "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": ["auto", "stretch"] + "enum": ["auto", "stretch"], + "description": "@deprecated Always uses stretch layout." }, "Config": { "type": "object", "properties": { "$schema": { - "description": "JSON schema reference for configuration validation", "type": "string" }, "shell": { - "description": "Default shell to use for terminal and bash tool", "type": "string" }, "logLevel": { @@ -14323,11 +11396,7 @@ "$ref": "#/components/schemas/ServerConfig" }, "command": { - "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -14347,28 +11416,27 @@ "type": "boolean" } }, - "required": ["template"] + "required": ["template"], + "additionalProperties": false } }, "skills": { - "description": "Additional skill folder paths", "type": "object", "properties": { "paths": { - "description": "Additional paths to skill folders", "type": "array", "items": { "type": "string" } }, "urls": { - "description": "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", "type": "array", "items": { "type": "string" } } - } + }, + "additionalProperties": false }, "watcher": { "type": "object", @@ -14379,10 +11447,10 @@ "type": "string" } } - } + }, + "additionalProperties": false }, "snapshot": { - "description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", "type": "boolean" }, "plugin": { @@ -14399,70 +11467,59 @@ "type": "string" }, { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } - ] + ], + "maxItems": 2, + "minItems": 2 } ] } }, "share": { - "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", "enum": ["manual", "auto", "disabled"] }, "autoshare": { - "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", "type": "boolean" }, "autoupdate": { - "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", "anyOf": [ { "type": "boolean" }, { "type": "string", - "const": "notify" + "enum": ["notify"] } - ] + ], + "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications" }, "disabled_providers": { - "description": "Disable providers that are loaded automatically", "type": "array", "items": { "type": "string" } }, "enabled_providers": { - "description": "When set, ONLY these providers will be enabled. All other providers will be ignored", "type": "array", "items": { "type": "string" } }, "model": { - "description": "Model to use in the format of provider/model, eg anthropic/claude-2", "type": "string" }, "small_model": { - "description": "Small model to use for tasks like title generation in the format of provider/model", "type": "string" }, "default_agent": { - "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", "type": "string" }, "username": { - "description": "Custom username to display in conversations instead of system username", "type": "string" }, "mode": { - "description": "@deprecated Use `agent` field instead.", "type": "object", "properties": { "build": { @@ -14477,7 +11534,6 @@ } }, "agent": { - "description": "Agent configuration, see https://opencode.ai/docs/agents", "type": "object", "properties": { "plan": { @@ -14507,32 +11563,20 @@ } }, "provider": { - "description": "Custom provider configurations and model overrides", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/ProviderConfig" } }, "mcp": { - "description": "MCP (Model Context Protocol) server configurations", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "anyOf": [ { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" }, { "type": "object", @@ -14541,22 +11585,19 @@ "type": "boolean" } }, - "required": ["enabled"] + "required": ["enabled"], + "additionalProperties": false } ] } }, "formatter": { - "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", "anyOf": [ { "type": "boolean" }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -14571,9 +11612,6 @@ }, "environment": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -14584,22 +11622,20 @@ "type": "string" } } - } + }, + "additionalProperties": false } } - ] + ], + "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, "lsp": { - "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", "anyOf": [ { "type": "boolean" }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "anyOf": [ { @@ -14607,10 +11643,11 @@ "properties": { "disabled": { "type": "boolean", - "const": true + "enum": [true] } }, - "required": ["disabled"] + "required": ["disabled"], + "additionalProperties": false }, { "type": "object", @@ -14632,30 +11669,24 @@ }, "env": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "initialization": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } ] } } - ] + ], + "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, "instructions": { - "description": "Additional instruction files or patterns to include", "type": "array", "items": { "type": "string" @@ -14669,9 +11700,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -14680,59 +11708,48 @@ "type": "object", "properties": { "url": { - "description": "Enterprise URL", "type": "string" } - } + }, + "additionalProperties": false }, "tool_output": { - "description": "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", "type": "object", "properties": { "max_lines": { - "description": "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "max_bytes": { - "description": "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } - } + }, + "additionalProperties": false }, "compaction": { "type": "object", "properties": { "auto": { - "description": "Enable automatic compaction when context is full (default: true)", "type": "boolean" }, "prune": { - "description": "Enable pruning of old tool outputs (default: true)", "type": "boolean" }, "tail_turns": { - "description": "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "preserve_recent_tokens": { - "description": "Maximum number of tokens from recent turns to preserve verbatim after compaction", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reserved": { - "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } - } + }, + "additionalProperties": false }, "experimental": { "type": "object", @@ -14741,200 +11758,30 @@ "type": "boolean" }, "batch_tool": { - "description": "Enable the batch tool", "type": "boolean" }, "openTelemetry": { - "description": "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", "type": "boolean" }, "primary_tools": { - "description": "Tools that should only be available to primary agents.", "type": "array", "items": { "type": "string" } }, "continue_loop_on_deny": { - "description": "Continue the agent loop when a tool call is denied", "type": "boolean" }, "mcp_timeout": { - "description": "Timeout in milliseconds for model context protocol (MCP) requests", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } - } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "BadRequestError": { - "type": "object", - "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "success": { - "type": "boolean", - "const": false - } - }, - "required": ["data", "errors", "success"] - }, - "OAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "oauth" - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "accountId": { - "type": "string" - }, - "enterpriseUrl": { - "type": "string" - } - }, - "required": ["type", "refresh", "access", "expires"] - }, - "ApiAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "api" - }, - "key": { - "type": "string" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["type", "key"] - }, - "WellKnownAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "wellknown" - }, - "key": { - "type": "string" - }, - "token": { - "type": "string" - } - }, - "required": ["type", "key", "token"] - }, - "Auth": { - "anyOf": [ - { - "$ref": "#/components/schemas/OAuth" - }, - { - "$ref": "#/components/schemas/ApiAuth" - }, - { - "$ref": "#/components/schemas/WellKnownAuth" - } - ] - }, - "Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "projectID": { - "type": "string" - } - }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] - }, - "NotFoundError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "NotFoundError" - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["name", "data"] - }, "Model": { "type": "object", "properties": { @@ -14957,7 +11804,8 @@ "type": "string" } }, - "required": ["id", "url", "npm"] + "required": ["id", "url", "npm"], + "additionalProperties": false }, "name": { "type": "string" @@ -14999,7 +11847,8 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false }, "output": { "type": "object", @@ -15020,7 +11869,8 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false }, "interleaved": { "anyOf": [ @@ -15035,12 +11885,14 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"] + "required": ["field"], + "additionalProperties": false } ] } }, - "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"], + "additionalProperties": false }, "cost": { "type": "object", @@ -15061,7 +11913,8 @@ "type": "number" } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false }, "experimentalOver200K": { "type": "object", @@ -15082,13 +11935,16 @@ "type": "number" } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "cache"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "cache"], + "additionalProperties": false }, "limit": { "type": "object", @@ -15103,24 +11959,18 @@ "type": "number" } }, - "required": ["context", "output"] + "required": ["context", "output"], + "additionalProperties": false }, "status": { "type": "string", "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -15130,15 +11980,8 @@ }, "variants": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } } }, @@ -15154,7 +11997,8 @@ "options", "headers", "release_date" - ] + ], + "additionalProperties": false }, "Provider": { "type": "object", @@ -15179,23 +12023,17 @@ "type": "string" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "models": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/Model" } } }, - "required": ["id", "name", "source", "env", "options", "models"] + "required": ["id", "name", "source", "env", "options", "models"], + "additionalProperties": false }, "ConsoleState": { "type": "object", @@ -15211,17 +12049,11 @@ }, "switchableOrgCount": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["consoleManagedProviders", "switchableOrgCount"] - }, - "ToolIDs": { - "type": "array", - "items": { - "type": "string" - } + "required": ["consoleManagedProviders", "switchableOrgCount"], + "additionalProperties": false }, "ToolListItem": { "type": "object", @@ -15234,7 +12066,8 @@ }, "parameters": {} }, - "required": ["id", "description", "parameters"] + "required": ["id", "description", "parameters"], + "additionalProperties": false }, "ToolList": { "type": "array", @@ -15242,6 +12075,25 @@ "$ref": "#/components/schemas/ToolListItem" } }, + "ToolIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "WorktreeCreateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "startCommand": { + "type": "string", + "description": "Additional startup script to run after the project's start command" + } + }, + "additionalProperties": false + }, "Worktree": { "type": "object", "properties": { @@ -15255,19 +12107,8 @@ "type": "string" } }, - "required": ["name", "branch", "directory"] - }, - "WorktreeCreateInput": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "startCommand": { - "description": "Additional startup script to run after the project's start command", - "type": "string" - } - } + "required": ["name", "branch", "directory"], + "additionalProperties": false }, "WorktreeRemoveInput": { "type": "object", @@ -15276,7 +12117,8 @@ "type": "string" } }, - "required": ["directory"] + "required": ["directory"], + "additionalProperties": false }, "WorktreeResetInput": { "type": "object", @@ -15285,7 +12127,8 @@ "type": "string" } }, - "required": ["directory"] + "required": ["directory"], + "additionalProperties": false }, "ProjectSummary": { "type": "object", @@ -15300,14 +12143,14 @@ "type": "string" } }, - "required": ["id", "worktree"] + "required": ["id", "worktree"], + "additionalProperties": false }, "GlobalSession": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -15316,8 +12159,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -15326,26 +12168,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -15354,7 +12192,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -15363,7 +12202,8 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" @@ -15384,7 +12224,8 @@ "type": "string" } }, - "required": ["id", "providerID"] + "required": ["id", "providerID"], + "additionalProperties": false }, "version": { "type": "string" @@ -15394,24 +12235,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -15420,12 +12259,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -15434,7 +12271,8 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false }, "project": { "anyOf": [ @@ -15447,7 +12285,8 @@ ] } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"], + "additionalProperties": false }, "McpResource": { "type": "object", @@ -15468,274 +12307,8 @@ "type": "string" } }, - "required": ["name", "uri", "client"] - }, - "TextPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "text" - }, - "text": { - "type": "string" - }, - "synthetic": { - "type": "boolean" - }, - "ignored": { - "type": "boolean" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["start"] - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["type", "text"] - }, - "FilePartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - }, - "AgentPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "agent" - }, - "name": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["value", "start", "end"] - } - }, - "required": ["type", "name"] - }, - "SubtaskPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "subtask" - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["type", "prompt", "description", "agent"] - }, - "ProviderAuthMethod": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["oauth", "api"] - }, - "label": { - "type": "string" - }, - "prompts": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "placeholder": { - "type": "string" - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "select" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "options": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "hint": { - "type": "string" - } - }, - "required": ["label", "value"] - } - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message", "options"] - } - ] - } - } - }, - "required": ["type", "label"] - }, - "ProviderAuthAuthorization": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "method": { - "type": "string", - "enum": ["auto", "code"] - }, - "instructions": { - "type": "string" - } - }, - "required": ["url", "method", "instructions"] + "required": ["name", "uri", "client"], + "additionalProperties": false }, "Symbol": { "type": "object", @@ -15745,8 +12318,7 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "location": { "type": "object", @@ -15758,10 +12330,12 @@ "$ref": "#/components/schemas/Range" } }, - "required": ["uri", "range"] + "required": ["uri", "range"], + "additionalProperties": false } }, - "required": ["name", "kind", "location"] + "required": ["name", "kind", "location"], + "additionalProperties": false }, "FileNode": { "type": "object", @@ -15783,7 +12357,8 @@ "type": "boolean" } }, - "required": ["name", "path", "absolute", "type", "ignored"] + "required": ["name", "path", "absolute", "type", "ignored"], + "additionalProperties": false }, "FileContent": { "type": "object", @@ -15820,23 +12395,19 @@ "properties": { "oldStart": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "oldLines": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "newStart": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "newLines": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "lines": { "type": "array", @@ -15845,24 +12416,27 @@ } } }, - "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"], + "additionalProperties": false } }, "index": { "type": "string" } }, - "required": ["oldFileName", "newFileName", "hunks"] + "required": ["oldFileName", "newFileName", "hunks"], + "additionalProperties": false }, "encoding": { "type": "string", - "const": "base64" + "enum": ["base64"] }, "mimeType": { "type": "string" } }, - "required": ["type", "content"] + "required": ["type", "content"], + "additionalProperties": false }, "File": { "type": "object", @@ -15872,324 +12446,19 @@ }, "added": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "removed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["path", "added", "removed", "status"] - }, - "Event": { - "anyOf": [ - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" - }, - { - "$ref": "#/components/schemas/Event.lsp.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.delta" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.session.diff" - }, - { - "$ref": "#/components/schemas/Event.session.error" - }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/Event.mcp.tools.changed" - }, - { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" - }, - { - "$ref": "#/components/schemas/Event.command.executed" - }, - { - "$ref": "#/components/schemas/Event.project.updated" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.worktree.ready" - }, - { - "$ref": "#/components/schemas/Event.worktree.failed" - }, - { - "$ref": "#/components/schemas/Event.pty.created" - }, - { - "$ref": "#/components/schemas/Event.pty.updated" - }, - { - "$ref": "#/components/schemas/Event.pty.exited" - }, - { - "$ref": "#/components/schemas/Event.pty.deleted" - }, - { - "$ref": "#/components/schemas/Event.message.updated" - }, - { - "$ref": "#/components/schemas/Event.message.removed" - }, - { - "$ref": "#/components/schemas/Event.message.part.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.removed" - }, - { - "$ref": "#/components/schemas/Event.session.created" - }, - { - "$ref": "#/components/schemas/Event.session.updated" - }, - { - "$ref": "#/components/schemas/Event.session.deleted" - }, - { - "$ref": "#/components/schemas/Event.session.next.agent.switched" - }, - { - "$ref": "#/components/schemas/Event.session.next.model.switched" - }, - { - "$ref": "#/components/schemas/Event.session.next.prompted" - }, - { - "$ref": "#/components/schemas/Event.session.next.synthetic" - }, - { - "$ref": "#/components/schemas/Event.session.next.shell.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.shell.ended" - }, - { - "$ref": "#/components/schemas/Event.session.next.step.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.step.ended" - }, - { - "$ref": "#/components/schemas/Event.session.next.text.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.text.delta" - }, - { - "$ref": "#/components/schemas/Event.session.next.text.ended" - }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.delta" - }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.ended" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.delta" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.ended" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.called" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.progress" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.success" - }, - { - "$ref": "#/components/schemas/Event.session.next.tool.error" - }, - { - "$ref": "#/components/schemas/Event.session.next.retried" - }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.delta" - }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.ended" - }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - } - ] - }, - "MCPStatusConnected": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "connected" - } - }, - "required": ["status"] - }, - "MCPStatusDisabled": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "disabled" - } - }, - "required": ["status"] - }, - "MCPStatusFailed": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "failed" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatusNeedsAuth": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "needs_auth" - } - }, - "required": ["status"] - }, - "MCPStatusNeedsClientRegistration": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "needs_client_registration" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatus": { - "anyOf": [ - { - "$ref": "#/components/schemas/MCPStatusConnected" - }, - { - "$ref": "#/components/schemas/MCPStatusDisabled" - }, - { - "$ref": "#/components/schemas/MCPStatusFailed" - }, - { - "$ref": "#/components/schemas/MCPStatusNeedsAuth" - }, - { - "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" - } - ] - }, - "McpUnsupportedOAuthError": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] + "required": ["path", "added", "removed", "status"], + "additionalProperties": false }, "Path": { "type": "object", @@ -16210,7 +12479,8 @@ "type": "string" } }, - "required": ["home", "state", "config", "worktree", "directory"] + "required": ["home", "state", "config", "worktree", "directory"], + "additionalProperties": false }, "VcsInfo": { "type": "object", @@ -16221,7 +12491,8 @@ "default_branch": { "type": "string" } - } + }, + "additionalProperties": false }, "VcsFileDiff": { "type": "object", @@ -16234,20 +12505,19 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "Command": { "type": "object", @@ -16269,14 +12539,7 @@ "enum": ["command", "mcp", "skill"] }, "template": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "string" - } - ] + "type": "string" }, "subtask": { "type": "boolean" @@ -16288,7 +12551,8 @@ } } }, - "required": ["name", "template", "hints"] + "required": ["name", "template", "hints"], + "additionalProperties": false }, "Agent": { "type": "object", @@ -16331,7 +12595,8 @@ "type": "string" } }, - "required": ["modelID", "providerID"] + "required": ["modelID", "providerID"], + "additionalProperties": false }, "variant": { "type": "string" @@ -16340,17 +12605,14 @@ "type": "string" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "steps": { "type": "number" } }, - "required": ["name", "mode", "permission", "options"] + "required": ["name", "mode", "permission", "options"], + "additionalProperties": false }, "LSPStatus": { "type": "object", @@ -16365,19 +12627,12 @@ "type": "string" }, "status": { - "anyOf": [ - { - "type": "string", - "const": "connected" - }, - { - "type": "string", - "const": "error" - } - ] + "type": "string", + "enum": ["connected", "error"] } }, - "required": ["id", "name", "root", "status"] + "required": ["id", "name", "root", "status"], + "additionalProperties": false }, "FormatterStatus": { "type": "object", @@ -16395,8 +12650,5279 @@ "type": "boolean" } }, - "required": ["name", "extensions", "enabled"] + "required": ["name", "extensions", "enabled"], + "additionalProperties": false + }, + "MCPStatusConnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["connected"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusDisabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["disabled"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusFailed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatusNeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_auth"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusNeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_client_registration"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPStatusConnected" + }, + { + "$ref": "#/components/schemas/MCPStatusDisabled" + }, + { + "$ref": "#/components/schemas/MCPStatusFailed" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsAuth" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" + } + ] + }, + "McpUnsupportedOAuthError": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "additionalProperties": false + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth", "api"] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["select"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message", "options"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["type", "label"], + "additionalProperties": false + }, + "ProviderAuthAuthorization": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "type": "string", + "enum": ["auto", "code"] + }, + "instructions": { + "type": "string" + } + }, + "required": ["url", "method", "instructions"], + "additionalProperties": false + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start"], + "additionalProperties": false + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agent"] + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + "SubtaskPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["subtask"] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["type", "prompt", "description", "agent"], + "additionalProperties": false + }, + "V2SessionsResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "V2SessionMessagesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "EventTuiPromptAppend": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiCommandExecute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiToastShow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiSessionSelect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + } + }, + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "additionalProperties": false + }, + "SyncEventMessageUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessageRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.created.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "share": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "updated": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "compacting": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "archived": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "permission": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionRuleset" + }, + { + "type": "null" + } + ] + }, + "revert": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.deleted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.agent.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextModelSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.model.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextPrompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.prompted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextSynthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.synthetic.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolCalled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.called.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolSuccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.success.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.error.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextRetried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.retried.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionError": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionReplied" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRejected" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceRestore": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.restore"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "step": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name", "branch"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "PromptSource": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"], + "additionalProperties": false + }, + "PromptFileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri", "mime"], + "additionalProperties": false + }, + "PromptAgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name"], + "additionalProperties": false + }, + "EventSessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "ToolTextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "ToolFileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["file"] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"], + "additionalProperties": false + }, + "EventSessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolError": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.error"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionNextRetry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"], + "additionalProperties": false + }, + "EventSessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "parentID": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string" + }, + "path": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "title": { + "type": "string" + } + }, + "required": ["id", "projectID", "time", "title"], + "additionalProperties": false + }, + "SessionDelivery": { + "type": "string", + "enum": ["immediate", "deferred"] + }, + "SessionMessageAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent"], + "additionalProperties": false + }, + "SessionMessageModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["model-switched"] + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, + "SessionMessageUser": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + }, + "type": { + "type": "string", + "enum": ["user"] + } + }, + "required": ["id", "time", "text", "type"], + "additionalProperties": false + }, + "SessionMessageSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["synthetic"] + } + }, + "required": ["id", "time", "sessionID", "text", "type"], + "additionalProperties": false + }, + "SessionMessageShell": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["shell"] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["id", "time", "type", "callID", "command", "output"], + "additionalProperties": false + }, + "SessionMessageAssistantText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "SessionMessageAssistantReasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning"] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "id", "text"], + "additionalProperties": false + }, + "SessionMessageToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "input": { + "type": "string" + } + }, + "required": ["status", "input"], + "additionalProperties": false + }, + "SessionMessageToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running"] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["status", "input", "structured", "content"], + "additionalProperties": false + }, + "SessionMessageToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["completed"] + }, + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + } + }, + "required": ["status", "input", "content", "structured"], + "additionalProperties": false + }, + "SessionMessageToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["error"] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["status", "input", "content", "structured", "error"], + "additionalProperties": false + }, + "SessionMessageAssistantTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tool"] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageToolStatePending" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateRunning" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateCompleted" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateError" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "id", "name", "state", "time"], + "additionalProperties": false + }, + "SessionMessageAssistant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["assistant"] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAssistantText" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantReasoning" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantTool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "error": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent", "model", "content"], + "additionalProperties": false + }, + "SessionMessageCompaction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["compaction"] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "summary": { + "type": "string" + }, + "include": { + "type": "string" + }, + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "reason", "summary", "id", "time"], + "additionalProperties": false + }, + "SessionMessage": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageUser" + }, + { + "$ref": "#/components/schemas/SessionMessageSynthetic" + }, + { + "$ref": "#/components/schemas/SessionMessageShell" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistant" + }, + { + "$ref": "#/components/schemas/SessionMessageCompaction" + } + ] + }, + "EventTuiToastShow1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "BadRequestError": { + "type": "object", + "required": ["data", "errors", "success"], + "properties": { + "data": {}, + "errors": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "success": { + "type": "boolean", + "enum": [false] + } + } + }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } } } - } + }, + "security": [], + "tags": [ + { + "name": "control", + "description": "Control plane routes." + }, + { + "name": "global", + "description": "Global server routes." + }, + { + "name": "event", + "description": "Instance event stream route." + }, + { + "name": "config", + "description": "Experimental HttpApi config routes." + }, + { + "name": "experimental", + "description": "Experimental HttpApi read-only routes." + }, + { + "name": "file", + "description": "Experimental HttpApi file routes." + }, + { + "name": "instance", + "description": "Experimental HttpApi instance read routes." + }, + { + "name": "mcp", + "description": "Experimental HttpApi MCP routes." + }, + { + "name": "project", + "description": "Experimental HttpApi project routes." + }, + { + "name": "pty", + "description": "Experimental HttpApi PTY routes." + }, + { + "name": "question", + "description": "Question routes." + }, + { + "name": "permission", + "description": "Experimental HttpApi permission routes." + }, + { + "name": "provider", + "description": "Experimental HttpApi provider routes." + }, + { + "name": "session", + "description": "Experimental HttpApi session routes." + }, + { + "name": "sync", + "description": "Experimental HttpApi sync routes." + }, + { + "name": "v2", + "description": "Experimental v2 routes." + }, + { + "name": "v2 messages", + "description": "Experimental v2 message routes." + }, + { + "name": "tui", + "description": "Experimental HttpApi TUI routes." + }, + { + "name": "workspace", + "description": "Experimental HttpApi workspace routes." + }, + { + "name": "pty", + "description": "PTY websocket route." + } + ] } From 2ad1eb56d3e0e1088a69e785744d92f27d568768 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:09:45 -0400 Subject: [PATCH 118/174] feat(server): native HttpApi listener with Bun.serve + WS upgrade (#25547) --- .../opencode/src/server/httpapi-listener.ts | 244 ++++++++++++++++++ .../test/server/httpapi-listener.test.ts | 109 ++++++++ 2 files changed, 353 insertions(+) create mode 100644 packages/opencode/src/server/httpapi-listener.ts create mode 100644 packages/opencode/test/server/httpapi-listener.test.ts diff --git a/packages/opencode/src/server/httpapi-listener.ts b/packages/opencode/src/server/httpapi-listener.ts new file mode 100644 index 000000000..fd65b0ae6 --- /dev/null +++ b/packages/opencode/src/server/httpapi-listener.ts @@ -0,0 +1,244 @@ +// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library, +// and `node:http`'s `upgrade` event. +// +// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that +// drives the experimental HttpApi handler directly (no Hono in the middle) and handles +// WebSocket upgrades inline based on path-matching. It exists to validate the pattern +// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it. + +import type { ServerWebSocket } from "bun" +import { Effect, Schema } from "effect" +import { AppRuntime } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" +import { Pty } from "@/pty" +import { handlePtyInput } from "@/pty/input" +import { PtyID } from "@/pty/schema" +import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty" +import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server" +import * as Log from "@opencode-ai/core/util/log" +import type { CorsOptions } from "./cors" + +const log = Log.create({ service: "httpapi-listener" }) +const decodePtyID = Schema.decodeUnknownSync(PtyID) + +export type Listener = { + hostname: string + port: number + url: URL + stop: (close?: boolean) => Promise +} + +export type ListenOptions = CorsOptions & { + port: number + hostname: string +} + +type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string } + +type PtyHandler = { + onMessage: (message: string | ArrayBuffer) => void + onClose: () => void +} + +type WsState = WsKind & { + handler?: PtyHandler + pending: Array + ready: boolean + closed: boolean +} + +// Derive from the OpenAPI path so this stays in sync if the route literal moves. +const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`) + +function parseCursor(value: string | null): number | undefined { + if (!value) return undefined + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined + return parsed +} + +function asAdapter(ws: ServerWebSocket) { + return { + get readyState() { + return ws.readyState + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + try { + if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data)) + else ws.send(data) + } catch { + // socket likely already closed; ignore + } + }, + close: (code?: number, reason?: string) => { + try { + ws.close(code, reason) + } catch { + // ignore + } + }, + } +} + +/** + * Spin up a native Bun.serve that: + * 1. Routes all HTTP traffic through the HttpApi web handler. + * 2. Intercepts known WebSocket upgrade paths and handles them inline. + * + * This bypasses Hono entirely. The Hono code path remains untouched. + */ +export async function listen(opts: ListenOptions): Promise { + const built = ExperimentalHttpApiServer.webHandler(opts) + const handler = built.handler + const context = ExperimentalHttpApiServer.context + + const start = (port: number) => { + try { + return Bun.serve({ + hostname: opts.hostname, + port, + idleTimeout: 0, + fetch(request, server) { + const url = new URL(request.url) + const ptyMatch = url.pathname.match(ptyConnectPattern) + if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") { + const ptyID = ptyMatch[1]! + const cursor = parseCursor(url.searchParams.get("cursor")) + // Resolve the instance directory the same way the HttpApi + // `instance-context` middleware does (search params, then header, + // then process.cwd()). + const directory = + url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd() + const upgraded = server.upgrade(request, { + data: { + kind: "pty", + ptyID, + cursor, + directory, + pending: [], + ready: false, + closed: false, + } satisfies WsState, + }) + if (upgraded) return undefined + return new Response("upgrade failed", { status: 400 }) + } + + // TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a + // remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support + // that here we'd need to (a) resolve the workspace target the same way + // `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request, + // { data: { kind: "proxy", target, headers, protocols } })` and bridge the + // ServerWebSocket to a remote WebSocket inside the `websocket` handlers. + // Deferred to a follow-up — the proxy story needs more design (auth header + // forwarding, fence sync, reconnection semantics) than fits this PR. + + return handler(request as Request, context as never) + }, + websocket: { + open(ws) { + const data = ws.data + if (data.kind !== "pty") { + ws.close(1011, "unknown ws kind") + return + } + const id = (() => { + try { + return decodePtyID(data.ptyID) + } catch { + ws.close(1008, "invalid pty id") + return undefined + } + })() + if (!id) return + ;(async () => { + const result = await WithInstance.provide({ + directory: data.directory, + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.connect(id, asAdapter(ws), data.cursor) + }).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")), + ), + }) + return await result + })() + .then((handler) => { + if (data.closed) { + handler?.onClose() + return + } + if (!handler) { + ws.close(4404, "session not found") + return + } + data.handler = handler + data.ready = true + for (const msg of data.pending) { + AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined) + } + data.pending.length = 0 + }) + .catch((err) => { + log.error("pty connect failed", { error: err }) + ws.close(1011, "pty connect failed") + }) + }, + message(ws, message) { + const data = ws.data + if (data.kind !== "pty") return + const payload = + typeof message === "string" + ? message + : message instanceof Buffer + ? new Uint8Array(message.buffer, message.byteOffset, message.byteLength) + : (message as Uint8Array) + if (!data.ready || !data.handler) { + data.pending.push(payload) + return + } + AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined) + }, + close(ws) { + const data = ws.data + data.closed = true + data.handler?.onClose() + }, + }, + }) + } catch (err) { + log.error("Bun.serve failed", { error: err }) + return undefined + } + } + + const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) + if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + const port = server.port + if (port === undefined) throw new Error("Bun.serve started without a numeric port") + + const url = new URL("http://localhost") + url.hostname = opts.hostname + url.port = String(port) + + let closing: Promise | undefined + return { + hostname: opts.hostname, + port, + url, + stop(close?: boolean) { + closing ??= (async () => { + await server.stop(close) + // NOTE: we deliberately do NOT call `built.dispose()` here. The + // underlying `webHandler` is memoized at module level (same as the + // Hono path), so disposing it would tear down shared services for + // every other consumer in the process. Lifecycle teardown is owned + // by the AppRuntime itself. + })() + return closing + }, + } +} + +export * as HttpApiListener from "./httpapi-listener" diff --git a/packages/opencode/test/server/httpapi-listener.test.ts b/packages/opencode/test/server/httpapi-listener.test.ts new file mode 100644 index 000000000..de7b5987e --- /dev/null +++ b/packages/opencode/test/server/httpapi-listener.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { HttpApiListener } from "../../src/server/httpapi-listener" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 }) +} + +describe("native HttpApi listener", () => { + test("serves HTTP routes via the HttpApi web handler", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toMatchObject({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }) + } finally { + await listener.stop(true) + } + }) + + testPty("PTY websocket connect echoes input back to the client", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }), + }) + expect(created.status).toBe(200) + const info = (await created.json()) as { id: string } + + try { + const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url) + wsURL.protocol = "ws:" + wsURL.searchParams.set("directory", tmp.path) + wsURL.searchParams.set("cursor", "-1") + + const messages: string[] = [] + const ws = new WebSocket(wsURL) + ws.binaryType = "arraybuffer" + + const opened = new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true }) + }) + + const closed = new Promise((resolve) => { + ws.addEventListener("close", () => resolve(), { once: true }) + }) + + ws.addEventListener("message", (event) => { + const data = event.data + messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer)) + }) + + await opened + ws.send("ping-listener\n") + + const start = Date.now() + while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) { + await new Promise((r) => setTimeout(r, 50)) + } + ws.close(1000, "done") + + expect(messages.some((m) => m.includes("ping-listener"))).toBe(true) + // Verify close event fires (handler.onClose path runs and the + // Bun.serve websocket lifecycle reaches close). + await closed + expect(ws.readyState).toBe(WebSocket.CLOSED) + } finally { + await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, { + method: "DELETE", + headers: { "x-opencode-directory": tmp.path }, + }).catch(() => undefined) + } + } finally { + await listener.stop(true) + } + }) +}) From 7a503de606888939a64776c512ca4588267bbd8d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 18:42:24 +0530 Subject: [PATCH 119/174] fix(acp): pass server auth to internal client (#25591) --- packages/opencode/src/cli/cmd/acp.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 251c60884..1bf52a0c8 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -6,6 +6,7 @@ import { ACP } from "@/acp/agent" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "acp-command" }) @@ -26,6 +27,13 @@ export const AcpCommand = effectCmd({ const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, + headers: Flag.OPENCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from( + `${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`, + ).toString("base64")}`, + } + : undefined, }) const input = new WritableStream({ From 379600b5ab9ed46043d1674e7fb7c3dbcb9bd4ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:17:06 -0400 Subject: [PATCH 120/174] fix(sdk+cli): surface real errors instead of bare {} when server returns empty body (#25592) --- packages/opencode/src/util/error.ts | 27 ++++++++++++++--------- packages/opencode/test/util/error.test.ts | 13 +++++++++++ packages/sdk/js/src/v2/client.ts | 19 ++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index fbda2dc50..32936e993 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -7,7 +7,19 @@ export function errorFormat(error: unknown): string { if (typeof error === "object" && error !== null) { try { - return JSON.stringify(error, null, 2) + const json = JSON.stringify(error, null, 2) + // Plain objects whose own properties are all non-enumerable (or empty) + // serialize to "{}", which prints as a useless bare `{}` on stderr. + // Fall back to a custom toString first, then to ctor name + own prop names. + if (json === "{}") { + const str = String(error) + if (str && str !== "[object Object]") return str + const ctor = error.constructor?.name + const prefix = ctor && ctor !== "Object" ? ctor : "Error" + const names = Object.getOwnPropertyNames(error) + return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` + } + return json } catch { return "Unexpected error (unserializable)" } @@ -34,7 +46,7 @@ export function errorMessage(error: unknown): string { if (text && text !== "[object Object]") return text const formatted = errorFormat(error) - if (formatted && formatted !== "{}") return formatted + if (formatted) return formatted return "unknown error" } @@ -45,7 +57,7 @@ export function errorData(error: unknown) { message: errorMessage(error), stack: error.stack, cause: error.cause === undefined ? undefined : errorFormat(error.cause), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -53,7 +65,7 @@ export function errorData(error: unknown) { return { type: typeof error, message: errorMessage(error), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -71,12 +83,7 @@ export function errorData(error: unknown) { if (typeof data.message !== "string") data.message = errorMessage(error) if (typeof data.type !== "string") data.type = error.constructor?.name - data.formatted = errorFormatted(error) + data.formatted = errorFormat(error) return data } -function errorFormatted(error: unknown) { - const formatted = errorFormat(error) - if (formatted !== "{}") return formatted - return String(error) -} diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e536f3c4e..e7a02d615 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -22,6 +22,19 @@ describe("util.error", () => { expect(data.code).toBe("E_BAD") }) + test("never returns bare {} for opaque object errors", () => { + // Plain empty object — what the SDK threw before we wrapped it. + expect(errorFormat({})).not.toBe("{}") + expect(errorFormat({})).toContain("no message") + + // Object with only non-enumerable own properties (JSON.stringify drops them). + class OpaqueError {} + const opaque = new OpaqueError() + Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false }) + expect(errorFormat(opaque)).not.toBe("{}") + expect(errorFormat(opaque)).toContain("OpaqueError") + }) + test("handles opaque throwables with custom toString", () => { const err = { toString() { diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 2d71d8446..8b49e7f10 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -84,5 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) + // The generated client falls back to throwing a literal `{}` when the server + // responds with an empty / unparseable error body, which surfaces as a bare + // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so + // downstream formatters get a useful message — but pass through any parsed + // JSON error body unchanged so existing consumers can still inspect fields. + client.interceptors.error.use((error, response, request) => { + const isEmpty = + error === undefined || + error === null || + error === "" || + (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) + if (!isEmpty) return error + const method = request?.method ?? "?" + const url = request?.url ?? "?" + if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) + const status = response.status + const statusText = response.statusText ? " " + response.statusText : "" + return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) + }) return new OpencodeClient({ client }) } From 8433e8b43333232e464f618daf542ace43442b6d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 13:18:13 +0000 Subject: [PATCH 121/174] chore: generate --- packages/opencode/src/util/error.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 32936e993..dabc6dfe1 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -86,4 +86,3 @@ export function errorData(error: unknown) { data.formatted = errorFormat(error) return data } - From 101566131d15dbe73e9d246d3d35da767f28cd80 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sun, 3 May 2026 15:20:05 +0200 Subject: [PATCH 122/174] fix(httpapi): add basic auth challenge for browser login Adds a WWW-Authenticate challenge for unauthorized experimental HttpApi UI fallback responses so browsers open the Basic Auth prompt when a server password is configured. --- .../routes/instance/httpapi/middleware/authorization.ts | 8 +++++++- packages/opencode/test/server/httpapi-ui.test.ts | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index e022a568a..05b873897 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -5,6 +5,7 @@ import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstabl const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 +const WWW_AUTHENTICATE = "Basic realm=\"Secure Area\"" export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", @@ -82,7 +83,12 @@ function validateRawCredential( ) { if (!isAuthRequired(config)) return effect if (!isCredentialAuthorized(credential, config)) - return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED })) + return Effect.succeed( + HttpServerResponse.empty({ + status: UNAUTHORIZED, + headers: { "www-authenticate": WWW_AUTHENTICATE }, + }), + ) return effect } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 09b234bde..1de8a489c 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -201,6 +201,7 @@ describe("HttpApi UI fallback", () => { const response = await uiApp({ password: "secret", username: "opencode" }).request("/") expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') }) test("accepts auth token for the web UI", async () => { From fb224d8974e8ab591cb42fb62cc28b32fb261a78 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 13:21:15 +0000 Subject: [PATCH 123/174] chore: generate --- .../server/routes/instance/httpapi/middleware/authorization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 05b873897..4edd06479 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -5,7 +5,7 @@ import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstabl const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 -const WWW_AUTHENTICATE = "Basic realm=\"Secure Area\"" +const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", From e77867ef058f2e0fde159c5d6fb6b2e575f9f7a7 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 3 May 2026 21:40:15 +0800 Subject: [PATCH 124/174] ci: only build electron desktop (#19067) --- .github/workflows/publish.yml | 219 +++------------- .../electron-builder.config.ts | 2 +- .../desktop/scripts/finalize-latest-json.ts | 235 +++++++++++------- 3 files changed, 180 insertions(+), 276 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9981edad7..4614226a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,182 +209,6 @@ jobs: packages/opencode/dist/opencode-windows-x64 packages/opencode/dist/opencode-windows-x64-baseline - build-tauri: - needs: - - build-cli - - version - continue-on-error: false - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: x86_64-apple-darwin - - host: macos-latest - target: aarch64-apple-darwin - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: windows-2025 - target: aarch64-pc-windows-msvc - - host: blacksmith-4vcpu-windows-2025 - target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 - target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.settings.host }} - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: apple-actions/import-codesign-certs@v2 - if: ${{ runner.os == 'macOS' }} - with: - keychain: build - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Verify Certificate - if: ${{ runner.os == 'macOS' }} - run: | - CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - echo "Certificate imported." - - - name: Setup Apple API Key - if: ${{ runner.os == 'macOS' }} - run: | - echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8 - - - uses: ./.github/actions/setup-bun - - - name: Azure login - if: runner.os == 'Windows' - uses: azure/login@v2 - with: - client-id: ${{ env.AZURE_CLIENT_ID }} - tenant-id: ${{ env.AZURE_TENANT_ID }} - subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Cache apt packages - if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 - with: - path: ~/apt-cache - key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.settings.target }}-apt- - - - name: install dependencies (ubuntu only) - if: contains(matrix.settings.host, 'ubuntu') - run: | - mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache - sudo apt-get update - sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - sudo chmod -R a+rw ~/apt-cache - - - name: install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.settings.target }} - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: packages/desktop/src-tauri - shared-key: ${{ matrix.settings.target }} - - - name: Prepare - run: | - cd packages/desktop - bun ./scripts/prepare.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} - RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - - - name: Resolve tauri portable SHA - if: contains(matrix.settings.host, 'ubuntu') - run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV" - - # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - name: Install tauri-cli from portable appimage branch - uses: taiki-e/cache-cargo-install-action@v3 - if: contains(matrix.settings.host, 'ubuntu') - with: - tool: tauri-cli - git: https://github.com/tauri-apps/tauri - # branch: feat/truly-portable-appimage - rev: ${{ env.TAURI_PORTABLE_SHA }} - - - name: Show tauri-cli version - if: contains(matrix.settings.host, 'ubuntu') - run: cargo tauri --version - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Build and upload artifacts - uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a - timeout-minutes: 60 - with: - projectPath: packages/desktop - uploadWorkflowArtifacts: true - tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }} - args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose - updaterJsonPreferNsis: true - releaseId: ${{ needs.version.outputs.release }} - tagName: ${{ needs.version.outputs.tag }} - releaseDraft: true - releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] - repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }} - releaseCommitish: ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 - - - name: Verify signed Windows desktop artifacts - if: runner.os == 'Windows' - shell: pwsh - run: | - $files = @( - "${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe" - ) - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName - - foreach ($file in $files) { - $sig = Get-AuthenticodeSignature $file - if ($sig.Status -ne "Valid") { - throw "Invalid signature for ${file}: $($sig.Status)" - } - } - build-electron: needs: - build-cli @@ -524,6 +348,30 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + - name: Create and upload macOS .app.tar.gz + if: runner.os == 'macOS' && needs.version.outputs.release + working-directory: packages/desktop-electron/dist + env: + GH_TOKEN: ${{ steps.committer.outputs.token }} + run: | + if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then + APP_DIR="mac" + OUT_NAME="opencode-desktop-mac-x64.app.tar.gz" + elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then + APP_DIR="mac-arm64" + OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz" + else + echo "Unknown macOS target: ${{ matrix.settings.target }}" + exit 1 + fi + APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app bundle found in $APP_DIR" + exit 1 + fi + tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" + gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}" + - name: Verify signed Windows Electron artifacts if: runner.os == 'Windows' shell: pwsh @@ -542,7 +390,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: opencode-electron-${{ matrix.settings.target }} + name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop-electron/dist/* - uses: actions/upload-artifact@v4 @@ -556,7 +404,6 @@ jobs: - version - build-cli - sign-cli-windows - - build-tauri - build-electron if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -583,13 +430,6 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - uses: actions/download-artifact@v4 with: name: opencode-cli @@ -611,6 +451,13 @@ jobs: pattern: latest-yml-* path: /tmp/latest-yml + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Cache apt packages (AUR) uses: actions/cache@v4 with: @@ -639,3 +486,5 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} NPM_CONFIG_PROVENANCE: false LATEST_YML_DIR: /tmp/latest-yml + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index fa088cd65..da734dc81 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -27,7 +27,7 @@ const channel = (() => { })() const getBase = (): Configuration => ({ - artifactName: "opencode-electron-${os}-${arch}.${ext}", + artifactName: "opencode-desktop-${os}-${arch}.${ext}", directories: { output: "dist", buildResources: "resources", diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts index 855c6a387..cb0f26b94 100644 --- a/packages/desktop/scripts/finalize-latest-json.ts +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun -import { Buffer } from "node:buffer" import { $ } from "bun" +import path from "node:path" +import { parseArgs } from "node:util" const { values } = parseArgs({ args: Bun.argv.slice(2), @@ -12,8 +13,6 @@ const { values } = parseArgs({ const dryRun = values["dry-run"] -import { parseArgs } from "node:util" - const repo = process.env.GH_REPO if (!repo) throw new Error("GH_REPO is required") @@ -23,20 +22,22 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required") const version = process.env.OPENCODE_VERSION if (!version) throw new Error("OPENCODE_VERSION is required") +const dir = process.env.LATEST_YML_DIR +if (!dir) throw new Error("LATEST_YML_DIR is required") +const root = dir + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") -const apiHeaders = { - Authorization: `token ${token}`, - Accept: "application/vnd.github+json", -} - -const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { - headers: apiHeaders, +const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + }, }) -if (!releaseRes.ok) { - throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`) +if (!rel.ok) { + throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`) } type Asset = { @@ -45,115 +46,169 @@ type Asset = { } type Release = { - tag_name?: string assets?: Asset[] } -const release = (await releaseRes.json()) as Release -const assets = release.assets ?? [] -const assetByName = new Map(assets.map((asset) => [asset.name, asset])) +const assets = ((await rel.json()) as Release).assets ?? [] +const amap = new Map(assets.map((item) => [item.name, item])) -const latestAsset = assetByName.get("latest.json") -if (!latestAsset) { - console.log("latest.json not found, skipping tauri finalization") - process.exit(0) +type Item = { + url: string } -const latestRes = await fetch(latestAsset.url, { - headers: { - Authorization: `token ${token}`, - Accept: "application/octet-stream", - }, -}) - -if (!latestRes.ok) { - throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`) +type Yml = { + version: string + files: Item[] } -const latestText = new TextDecoder().decode(await latestRes.arrayBuffer()) -const latest = JSON.parse(latestText) -const base = { ...latest } -delete base.platforms +function parse(text: string): Yml { + const lines = text.split("\n") + let version = "" + const files: Item[] = [] + let url = "" -const fetchSignature = async (asset: Asset) => { - const res = await fetch(asset.url, { + const flush = () => { + if (!url) return + files.push({ url }) + url = "" + } + + for (const line of lines) { + const trim = line.trim() + if (line.startsWith("version:")) { + version = line.slice("version:".length).trim() + continue + } + if (trim.startsWith("- url:")) { + flush() + url = trim.slice("- url:".length).trim() + continue + } + const indented = line.startsWith(" ") || line.startsWith("\t") + if (!indented) flush() + } + flush() + + return { version, files } +} + +async function read(sub: string, file: string) { + const item = Bun.file(path.join(root, sub, file)) + if (!(await item.exists())) return undefined + return parse(await item.text()) +} + +function pick(list: Item[], exts: string[]) { + for (const ext of exts) { + const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext)) + if (found) return found.url + } +} + +function link(raw: string) { + if (raw.startsWith("https://") || raw.startsWith("http://")) return raw + return `https://github.com/${repo}/releases/download/v${version}/${raw}` +} + +async function sign(url: string, key: string) { + const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key) + const asset = amap.get(name) + const res = await fetch(asset?.url ?? url, { headers: { Authorization: `token ${token}`, - Accept: "application/octet-stream", + ...(asset ? { Accept: "application/octet-stream" } : {}), }, }) - if (!res.ok) { - throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`) + throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`) } - return Buffer.from(await res.arrayBuffer()).toString() + const tmp = process.env.RUNNER_TEMP ?? "/tmp" + const file = path.join(tmp, name) + await Bun.write(file, await res.arrayBuffer()) + await $`bunx @tauri-apps/cli signer sign ${file}` + const sigFile = Bun.file(`${file}.sig`) + if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`) + return (await sigFile.text()).trim() } -const entries: Record = {} -const add = (key: string, asset: Asset, signature: string) => { - if (entries[key]) return - entries[key] = { - url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`, - signature, - } +const add = async (data: Record, key: string, raw: string | undefined) => { + if (!raw) return + if (data[key]) return + const url = link(raw) + data[key] = { url, signature: await sign(url, key) } } -const targets = [ - { key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" }, - { key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" }, - { key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" }, - { key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" }, - { key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" }, - { key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" }, - { key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" }, - { - key: "darwin-aarch64-app", - asset: "opencode-desktop-darwin-aarch64.app.tar.gz", - }, -] - -for (const target of targets) { - const asset = assetByName.get(target.asset) - if (!asset) continue - - const sig = assetByName.get(`${target.asset}.sig`) - if (!sig) continue - - const signature = await fetchSignature(sig) - add(target.key, asset, signature) +const alias = (data: Record, key: string, src: string) => { + if (data[key]) return + if (!data[src]) return + data[key] = data[src] } -const alias = (key: string, source: string) => { - if (entries[key]) return - const entry = entries[source] - if (!entry) return - entries[key] = entry -} +const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml") +const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml") +const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml") +const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml") +const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml") +const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml") -alias("linux-x86_64", "linux-x86_64-deb") -alias("linux-aarch64", "linux-aarch64-deb") -alias("windows-aarch64", "windows-aarch64-nsis") -alias("windows-x86_64", "windows-x86_64-nsis") -alias("darwin-x86_64", "darwin-x86_64-app") -alias("darwin-aarch64", "darwin-aarch64-app") +const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version +if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`) + +const out: Record = {} + +const winxexe = pick(winx?.files ?? [], [".exe"]) +const winaexe = pick(wina?.files ?? [], [".exe"]) + +const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz" +const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz" + +const linxDeb = pick(linx?.files ?? [], [".deb"]) +const linxRpm = pick(linx?.files ?? [], [".rpm"]) +const linxAppImage = pick(linx?.files ?? [], [".appimage"]) +const linaDeb = pick(lina?.files ?? [], [".deb"]) +const linaRpm = pick(lina?.files ?? [], [".rpm"]) +const linaAppImage = pick(lina?.files ?? [], [".appimage"]) + +await add(out, "windows-x86_64-nsis", winxexe) +await add(out, "windows-aarch64-nsis", winaexe) +await add(out, "darwin-x86_64-app", macxTarGz) +await add(out, "darwin-aarch64-app", macaTarGz) + +await add(out, "linux-x86_64-deb", linxDeb) +await add(out, "linux-x86_64-rpm", linxRpm) +await add(out, "linux-x86_64-appimage", linxAppImage) +await add(out, "linux-aarch64-deb", linaDeb) +await add(out, "linux-aarch64-rpm", linaRpm) +await add(out, "linux-aarch64-appimage", linaAppImage) + +alias(out, "windows-x86_64", "windows-x86_64-nsis") +alias(out, "windows-aarch64", "windows-aarch64-nsis") +alias(out, "darwin-x86_64", "darwin-x86_64-app") +alias(out, "darwin-aarch64", "darwin-aarch64-app") +alias(out, "linux-x86_64", "linux-x86_64-deb") +alias(out, "linux-aarch64", "linux-aarch64-deb") const platforms = Object.fromEntries( - Object.keys(entries) + Object.keys(out) .sort() - .map((key) => [key, entries[key]]), + .map((key) => [key, out[key]]), ) -const output = { - ...base, + +if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts") + +const data = { + version, + notes: "", + pub_date: new Date().toISOString(), platforms, } -const dir = process.env.RUNNER_TEMP ?? "/tmp" -const file = `${dir}/latest.json` -await Bun.write(file, JSON.stringify(output, null, 2)) +const tmp = process.env.RUNNER_TEMP ?? "/tmp" +const file = path.join(tmp, "latest.json") +await Bun.write(file, JSON.stringify(data, null, 2)) -const tag = release.tag_name -if (!tag) throw new Error("Release tag not found") +const tag = `v${version}` if (dryRun) { console.log(`dry-run: wrote latest.json for ${tag} to ${file}`) From 0a7d02c87cea5092f34aafba846d136870ac27bc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 19:18:26 +0530 Subject: [PATCH 125/174] feat: group changelog bugfixes (#25597) --- .opencode/command/changelog.md | 5 ++++- script/raw-changelog.ts | 40 +++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 4cd30a704..b28d963d0 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution. Rules: -- Write the final file with sections in this order: +- Write the final file with release sections in this order: `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` - Only include sections that have at least one notable entry +- Within each release section, keep bug fixes grouped under `### Bugfixes` +- Keep other notable entries under `### Improvements` when a section has bug fixes too +- Omit empty subsections - Keep one bullet per commit you keep - Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing - Start each bullet with a capital letter diff --git a/script/raw-changelog.ts b/script/raw-changelog.ts index 735b078be..c571de322 100644 --- a/script/raw-changelog.ts +++ b/script/raw-changelog.ts @@ -82,6 +82,11 @@ function section(areas: Set) { return "Core" } +function type(message: string) { + if (message.match(/fix/i)) return "Bugfixes" + return "Improvements" +} + function reverted(commits: Commit[]) { const seen = new Map() @@ -193,13 +198,20 @@ async function thanks(from: string, to: string, reuse: boolean) { } function format(from: string, to: string, list: Commit[], thanks: string[]) { - const grouped = new Map() - for (const title of order) grouped.set(title, []) + const grouped = new Map>() + for (const title of order) { + grouped.set( + title, + new Map([ + ["Improvements", []], + ["Bugfixes", []], + ]), + ) + } for (const commit of list) { - const title = section(commit.areas) const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" - grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) + grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) } const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""] @@ -209,11 +221,23 @@ function format(from: string, to: string, list: Commit[], thanks: string[]) { } for (const title of order) { - const entries = grouped.get(title) - if (!entries || entries.length === 0) continue + const groups = grouped.get(title) + if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue lines.push(`## ${title}`) - lines.push(...entries) - lines.push("") + const improvements = groups.get("Improvements")! + const bugfixes = groups.get("Bugfixes")! + if (bugfixes.length === 0) { + lines.push(...improvements) + lines.push("") + continue + } + + for (const [subtitle, entries] of groups) { + if (entries.length === 0) continue + lines.push(`### ${subtitle}`) + lines.push(...entries) + lines.push("") + } } if (thanks.length > 0) { From 8694c5b68fc57e7e1bb8129b72b08e128dce9f17 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 19:28:31 +0530 Subject: [PATCH 126/174] fix(auth): respect server username in clients (#25596) --- packages/opencode/src/cli/cmd/acp.ts | 10 +--- packages/opencode/src/cli/cmd/run.ts | 9 +-- packages/opencode/src/cli/cmd/tui/attach.ts | 13 ++-- packages/opencode/src/cli/cmd/tui/worker.ts | 11 +--- packages/opencode/src/plugin/index.ts | 7 +-- packages/opencode/src/server/auth.ts | 48 +++++++++++++++ .../httpapi/middleware/authorization.ts | 49 ++++----------- .../server/routes/instance/httpapi/server.ts | 9 +-- packages/opencode/test/server/auth.test.ts | 59 +++++++++++++++++++ .../test/server/httpapi-authorization.test.ts | 13 ++-- .../opencode/test/server/httpapi-ui.test.ts | 8 +-- 11 files changed, 148 insertions(+), 88 deletions(-) create mode 100644 packages/opencode/src/server/auth.ts create mode 100644 packages/opencode/test/server/auth.test.ts diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 1bf52a0c8..e24262307 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -4,9 +4,9 @@ import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" +import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "acp-command" }) @@ -27,13 +27,7 @@ export const AcpCommand = effectCmd({ const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from( - `${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`, - ).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), }) const input = new WritableStream({ diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 75f68e8ea..2ec0b179b 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -5,6 +5,7 @@ import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -656,13 +657,7 @@ export const RunCommand = effectCmd({ } if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password }) const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) return await execute(sdk) } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index cb6b95a56..5de937fdc 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { errorMessage } from "@/util/error" import { validateSession } from "./validate-session" +import { ServerAuth } from "@/server/auth" export const AttachCommand = cmd({ command: "attach ", @@ -38,6 +39,11 @@ export const AttachCommand = cmd({ alias: ["p"], type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", }), handler: async (args) => { const unguard = win32InstallCtrlCGuard() @@ -60,12 +66,7 @@ export const AttachCommand = cmd({ return args.dir } })() - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() try { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 775f321bb..90ff2b4d4 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" -import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" @@ -50,7 +50,7 @@ let server: Awaited> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } - const auth = getAuthorizationHeader() + const auth = ServerAuth.header() if (auth && !headers["authorization"] && !headers["Authorization"]) { headers["Authorization"] = auth } @@ -102,10 +102,3 @@ export const rpc = { } Rpc.listen(rpc) - -function getAuthorizationHeader(): string | undefined { - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - return `Basic ${btoa(`${username}:${password}`)}` -} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 95af410ff..7a7f260df 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,6 +10,7 @@ import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" @@ -124,11 +125,7 @@ export const layer = Layer.effect( const client = createOpencodeClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts new file mode 100644 index 000000000..9630ddbe2 --- /dev/null +++ b/packages/opencode/src/server/auth.ts @@ -0,0 +1,48 @@ +export * as ServerAuth from "./auth" + +import { ConfigService } from "@/effect/config-service" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Config as EffectConfig, Context, Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export class Config extends ConfigService.Service()("@opencode/ServerAuthConfig", { + password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), + username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), +}) {} + +export type Info = Context.Service.Shape + +export function required(config: Info) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: Info) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 4edd06479..bd9552edc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,5 +1,5 @@ -import { ConfigService } from "@/effect/config-service" -import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { ServerAuth } from "@/server/auth" +import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" @@ -18,41 +18,18 @@ export class Authorization extends HttpApiMiddleware.Service()( }, ) {} -export class ServerAuthConfig extends ConfigService.Service()( - "@opencode/ExperimentalHttpApiServerAuthConfig", - { - password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), - username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), - }, -) {} - function validateCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { return Effect.gen(function* () { - if (!isAuthRequired(config)) return yield* effect - if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) + if (!ServerAuth.required(config)) return yield* effect + if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) return yield* effect }) } -function isAuthRequired(config: Context.Service.Shape) { - return Option.isSome(config.password) && config.password.value !== "" -} - -function isCredentialAuthorized( - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, -) { - return ( - Option.isSome(config.password) && - credential.username === config.username && - Redacted.value(credential.password) === config.password.value - ) -} - function decodeCredential(input: string) { const emptyCredential = { username: "", @@ -78,11 +55,11 @@ function decodeCredential(input: string) { function validateRawCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { - if (!isAuthRequired(config)) return effect - if (!isCredentialAuthorized(credential, config)) + if (!ServerAuth.required(config)) return effect + if (!ServerAuth.authorized(credential, config)) return Effect.succeed( HttpServerResponse.empty({ status: UNAUTHORIZED, @@ -94,8 +71,8 @@ function validateRawCredential( export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { - const config = yield* ServerAuthConfig - if (!isAuthRequired(config)) return (effect) => effect + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return (effect) => effect return (effect) => Effect.gen(function* () { @@ -122,7 +99,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { - const config = yield* ServerAuthConfig + const config = yield* ServerAuth.Config return Authorization.of({ basic: (effect, { credential }) => validateCredential(effect, credential, config), authToken: (effect, { credential }) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 650efe2b0..2944ced69 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -46,8 +46,9 @@ import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" +import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" -import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" +import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -97,7 +98,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer)) const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( Layer.provide(eventHandlers), Layer.provide(instanceRouterLayer), @@ -125,7 +126,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), + authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), @@ -137,7 +138,7 @@ const uiRoute = HttpRouter.use((router) => const client = yield* HttpClient.HttpClient yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) +).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts new file mode 100644 index 000000000..1278e8c72 --- /dev/null +++ b/packages/opencode/test/server/auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Option, Redacted } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "../../src/server/auth" + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME +}) + +describe("ServerAuth", () => { + test("does not emit auth headers without a password", () => { + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.header()).toBeUndefined() + expect(ServerAuth.headers()).toBeUndefined() + }) + + test("defaults to the opencode username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = undefined + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }) + }) + + test("uses the configured username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + }) + + test("prefers explicit credentials", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({ + Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`, + }) + }) + + test("validates decoded credentials against effect config", () => { + const config = { password: Option.some("secret"), username: "alice" } + + expect(ServerAuth.required(config)).toBe(true) + expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true) + expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false) + }) +}) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index c3bab23ac..d780b18f2 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -3,11 +3,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" -import { - Authorization, - ServerAuthConfig, - authorizationLayer, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ServerAuth } from "../../src/server/auth" +import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -27,9 +24,9 @@ const apiLayer = HttpRouter.serve( { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" }) -const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" }) -const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) +const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 1de8a489c..8b7a6a1ac 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -12,10 +12,8 @@ import { HttpServerResponse, } from "effect/unstable/http" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { - ServerAuthConfig, - authorizationRouterMiddleware, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ServerAuth } from "../../src/server/auth" +import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" @@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), Layer.provide([ AppFileSystem.defaultLayer, input?.client ?? httpClient(new Response("ui")), From 13ac849db5c378ed04d02d644006f01e70db31b6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 11:21:34 -0400 Subject: [PATCH 127/174] refactor(config+core): drop ConfigPaths.readFile, add AppFileSystem.readFileStringSafe, flatten TuiConfig.loadState (#25602) --- packages/core/src/filesystem.ts | 8 ++ .../core/test/filesystem/filesystem.test.ts | 28 ++++ .../opencode/src/cli/cmd/tui/config/tui.ts | 121 ++++++++++-------- packages/opencode/src/config/config.ts | 10 +- packages/opencode/src/config/paths.ts | 10 -- packages/opencode/test/config/tui.test.ts | 40 ++++++ 6 files changed, 147 insertions(+), 70 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 44346be8f..8a1cc3a08 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -24,6 +24,7 @@ export namespace AppFileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect readonly existsSafe: (path: string) => Effect.Effect + readonly readFileStringSafe: (path: string) => Effect.Effect readonly readJson: (path: string) => Effect.Effect readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect readonly ensureDir: (path: string) => Effect.Effect @@ -47,6 +48,12 @@ export namespace AppFileSystem { return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) }) + const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) { + return yield* fs + .readFileString(path) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + }) + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) return info?.type === "Directory" @@ -163,6 +170,7 @@ export namespace AppFileSystem { return Service.of({ ...fs, existsSafe, + readFileStringSafe, isDir, isFile, readDirectoryEntries, diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index b77f4e356..1d9405333 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -65,6 +65,34 @@ describe("AppFileSystem", () => { ) }) + describe("readFileStringSafe", () => { + it( + "returns file contents when file exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "hello") + + const result = yield* fs.readFileStringSafe(file) + expect(result).toBe("hello") + }), + ) + + it( + "returns undefined for missing file (NotFound)", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + + const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt")) + expect(result).toBeUndefined() + }), + ) + }) + describe("readJson / writeJson", () => { it( "round-trips JSON data", diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index fbedcccc1..e9824a09d 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -68,29 +68,73 @@ function normalize(raw: Record) { } } -async function resolvePlugins(config: Info, configFilepath: string) { - if (!config.plugin) return config - for (let i = 0; i < config.plugin.length; i++) { - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) - } - return config -} - -async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins -} - const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { + const afs = yield* AppFileSystem.Service + + const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const plugins = config.plugin + if (!plugins) return config + for (let i = 0; i < plugins.length; i++) { + plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath)) + } + return config + }) + + const load = (text: string, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ) + const data = ConfigParse.jsonc(expanded, configFilepath) + if (!isRecord(data)) return {} as Info + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const validated = ConfigParse.schema(Info, normalize(data), configFilepath) + return yield* resolvePlugins(validated, configFilepath) + }).pipe( + // catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema + // can sync-throw — those become defects, which orElseSucceed wouldn't catch. + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("invalid tui config", { path: configFilepath, cause }) + return {} as Info + }), + ), + ) + + const loadFile = (filepath: string): Effect.Effect => + Effect.gen(function* () { + // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. + // Matches how parse/schema/plugin failures in load() are handled — every + // broken-config path degrades gracefully rather than crashing TUI startup. + const text = yield* afs.readFileStringSafe(filepath).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("failed to read tui config", { path: filepath, cause }) + return undefined + }), + ), + ) + if (!text) return {} as Info + return yield* load(text, filepath) + }) + + const mergeFile = (acc: Acc, file: string) => + Effect.gen(function* () { + const data = yield* loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins + }) + // Every config dir we may read from: global config dir, any `.opencode` // folders between cwd and home, and OPENCODE_CONFIG_DIR. const directories = yield* ConfigPaths.directories(ctx.directory) @@ -104,19 +148,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 2. Explicit OPENCODE_TUI_CONFIG override, if set. if (Flag.OPENCODE_TUI_CONFIG) { const configFile = Flag.OPENCODE_TUI_CONFIG - yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, configFile) log.debug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while @@ -127,7 +171,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: for (const dir of dirs) { if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } } @@ -193,28 +237,3 @@ export async function get() { return runPromise((svc) => svc.get()) } -async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) -} - -async function load(text: string, configFilepath: string): Promise { - return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }) - .then((expanded) => ConfigParse.jsonc(expanded, configFilepath)) - .then((data) => { - if (!isRecord(data)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - return ConfigParse.schema(Info, normalize(data), configFilepath) - }) - .then((data) => resolvePlugins(data, configFilepath)) - .catch((error) => { - log.warn("invalid tui config", { path: configFilepath, error }) - return {} - }) -} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c6557360b..3a933f81e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -355,15 +355,7 @@ export const layer = Layer.effect( const env = yield* Env.Service const npmSvc = yield* Npm.Service - const readConfigFile = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => Effect.succeed(undefined), - ), - Effect.orDie, - ) - }) + const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) const loadConfig = Effect.fnUntraced(function* ( text: string, diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 90f49ee79..82fca570f 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -1,11 +1,9 @@ export * as ConfigPaths from "./paths" import path from "path" -import { Filesystem } from "@/util/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { unique } from "remeda" -import { JsonError } from "./error" import * as Effect from "effect/Effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -45,11 +43,3 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } - -/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ -export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return - throw new JsonError({ path: filepath }, { cause: err }) - }) -} diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a3f2a1b5f..5053a7e1f 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -627,3 +627,43 @@ test("merges plugin_enabled flags across config layers", async () => { "local.plugin": true, }) }) + +test("silently skips malformed tui.json — load failures degrade to {}", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",') + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Project tui.json is malformed → silently skipped (logs a warning) + // .opencode/tui.json (lower precedence in this path) still loads + expect(config.theme).toBe("fallback") +}) + +test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // tui.json exists as a DIRECTORY rather than a file → readFileString fails + // with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes + // that through catchCause → log + skip, so a fallback layer should still load. + await fs.mkdir(path.join(dir, "tui.json"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Did NOT crash; .opencode/tui.json (lower precedence) still loads. + expect(config.theme).toBe("fallback") +}) + +test("missing tui.json — silently treated as empty (ENOENT path)", async () => { + await using tmp = await tmpdir({}) + + // No tui.json anywhere. Should not throw. + const config = await getTuiConfig(tmp.path) + expect(config).toBeDefined() + // No theme set anywhere. + expect(config.theme).toBeUndefined() +}) From 57d5c095d83d934120d2ac88afdf208b4523f1d2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 15:22:38 +0000 Subject: [PATCH 128/174] chore: generate --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index e9824a09d..890f73622 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -236,4 +236,3 @@ export async function waitForDependencies() { export async function get() { return runPromise((svc) => svc.get()) } - From df7dd06a0fffa96bb495136cbe6f76680ed1a911 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 11:42:05 -0400 Subject: [PATCH 129/174] =?UTF-8?q?refactor(cli/github+run):=20Stage=204?= =?UTF-8?q?=20=E2=80=94=20drop=20AppRuntime.runPromise=20bridges=20(#25539?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/github.ts | 50 +++++++++++++------------ packages/opencode/src/cli/cmd/run.ts | 4 +- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a4a209ea3..ea5b35ef7 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" -import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" @@ -206,6 +205,8 @@ export const GithubInstallCommand = effectCmd({ const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const modelsDev = yield* ModelsDev.Service + const gitSvc = yield* Git.Service yield* Effect.promise(async () => { { UI.empty() @@ -213,7 +214,7 @@ export const GithubInstallCommand = effectCmd({ const app = await getAppInfo() await installGitHubApp() - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { + const providers = await Effect.runPromise(modelsDev.get()).then((p) => { // TODO: add guide for copilot, for now just hide it delete p["github-copilot"] return p @@ -261,9 +262,9 @@ export const GithubInstallCommand = effectCmd({ } // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), - ).then((x) => x.text().trim()) + const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then( + (x) => x.text().trim(), + ) const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -440,6 +441,10 @@ export const GithubRunCommand = effectCmd({ handler: Effect.fn("Cli.github.run")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return yield* Effect.die("InstanceRef not provided") + const gitSvc = yield* Git.Service + const sessionSvc = yield* Session.Service + const sessionShare = yield* SessionShare.Service + const sessionPrompt = yield* SessionPrompt.Service yield* Effect.promise(async () => { const isMock = args.token || args.event @@ -503,21 +508,20 @@ export const GithubRunCommand = effectCmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -554,24 +558,22 @@ export const GithubRunCommand = effectCmd({ // Setup opencode session const repoData = await fetchRepo() - session = await AppRuntime.runPromise( - Session.Service.use((svc) => - svc.create({ - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }), - ), + session = await Effect.runPromise( + sessionSvc.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }), ) subscribeSessionEvents() shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id))) + await Effect.runPromise(sessionShare.share(session.id)) return session.id.slice(-8) })() console.log("opencode session", session.id) @@ -944,9 +946,9 @@ export const GithubRunCommand = effectCmd({ async function chat(message: string, files: PromptFiles = []) { console.log("Sending message to opencode...") - return AppRuntime.runPromise( + return Effect.runPromise( Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const prompt = sessionPrompt const result = yield* prompt.prompt({ sessionID: session.id, messageID: MessageID.ascending(), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2ec0b179b..c20833d4b 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,7 +27,6 @@ import { ShellTool } from "../../tool/shell" import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" -import { AppRuntime } from "@/effect/app-runtime" type ToolProps = { input: Tool.InferParameters @@ -300,6 +299,7 @@ export const RunCommand = effectCmd({ default: false, }), handler: Effect.fn("Cli.run")(function* (args) { + const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) @@ -603,7 +603,7 @@ export const RunCommand = effectCmd({ return name } - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + const entry = await Effect.runPromise(agentSvc.get(name)) if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", From 40dc2fa3c1d6217d0f4fd21d813160e41f438a55 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 11:42:57 -0400 Subject: [PATCH 130/174] =?UTF-8?q?refactor(cli/providers):=20flatten=20?= =?UTF-8?q?=E2=80=94=20Effect-native=20handlers=20end-to-end=20(#25537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/providers.ts | 407 ++++++++++----------- 1 file changed, 197 insertions(+), 210 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 081bcece0..c8d897bea 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -6,8 +6,6 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" -const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())) -const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true))) import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" @@ -241,46 +239,45 @@ export const ProvidersListCommand = effectCmd({ handler: Effect.fn("Cli.providers.list")(function* (_args) { const authSvc = yield* Auth.Service const modelsDev = yield* ModelsDev.Service - yield* Effect.promise(async () => { + + UI.empty() + const authPath = path.join(Global.Path.data, "auth.json") + const homedir = os.homedir() + const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath + prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = Object.entries(yield* Effect.orDie(authSvc.all())) + const database = yield* modelsDev.get() + + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } + + prompts.outro(`${results.length} credentials`) + + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) + } + } + } + + if (activeEnvVars.length > 0) { UI.empty() - const authPath = path.join(Global.Path.data, "auth.json") - const homedir = os.homedir() - const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(await Effect.runPromise(authSvc.all())) - const database = await Effect.runPromise(modelsDev.get()) + prompts.intro("Environment") - for (const [providerID, result] of results) { - const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) } - prompts.outro(`${results.length} credentials`) - - const activeEnvVars: Array<{ provider: string; envVar: string }> = [] - - for (const [providerID, provider] of Object.entries(database)) { - for (const envVar of provider.env) { - if (process.env[envVar]) { - activeEnvVars.push({ - provider: provider.name || providerID, - envVar, - }) - } - } - } - - if (activeEnvVars.length > 0) { - UI.empty() - prompts.intro("Environment") - - for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) - } - - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) - } - }) + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + } }), }) @@ -306,185 +303,174 @@ export const ProvidersLoginCommand = effectCmd({ handler: Effect.fn("Cli.providers.login")(function* (args) { const cfgSvc = yield* Config.Service const pluginSvc = yield* Plugin.Service - yield* Effect.promise(async () => { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - stderr: "inherit", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + const modelsDev = yield* ModelsDev.Service + const authSvc = yield* Auth.Service + + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (yield* Effect.promise(() => + fetch(`${url}/.well-known/opencode`).then((x) => x.json()), + )) as { auth: { command: string[]; env: string } } + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" }) + if (!proc.stdout) { + prompts.log.error("Failed") prompts.outro("Done") return } - await refreshModels().catch(() => {}) - - const config = await Effect.runPromise(cfgSvc.get()) - - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - return filtered - }) - const hooks = await Effect.runPromise(pluginSvc.list()) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, + const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)])) + if (exit !== 0) { + prompts.log.error("Failed") + prompts.outro("Done") + return } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), - }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), + yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) + prompts.log.success("Logged into " + url) + prompts.outro("Done") + return + } + yield* Effect.ignore(modelsDev.refresh(true)) + + const config = yield* cfgSvc.get() + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const allProviders = yield* modelsDev.get() + const providers: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value + } + const hooks = yield* pluginSvc.list() + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - ...pluginProviders.map((x) => ({ + map((x) => ({ label: x.name, value: x.id, - hint: "plugin", + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], })), - ] + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + prompts.log.error(`Unknown provider "${input}"`) + process.exit(1) + } + provider = match.value + } else { + const selected = yield* Effect.promise(() => + prompts.autocomplete({ message: "Select provider", maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string - } + options: [...options, { value: "other", label: "Other" }], + }), + ) + if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) + provider = selected as string + } - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method)) + if (handled) return + } + + if (provider === "other") { + const custom = yield* Effect.promise(() => + prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }), + ) + if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError()) + provider = (custom as string).replace(/^@ai-sdk\//, "") + + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = yield* Effect.promise(() => + handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method), + ) if (handled) return } - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } - - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } - - const key = await prompts.password({ + const key = yield* Effect.promise(() => + prompts.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) + }), + ) + if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError()) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string })) - prompts.outro("Done") - }) + prompts.outro("Done") }), }) @@ -496,26 +482,27 @@ export const ProvidersLogoutCommand = effectCmd({ handler: Effect.fn("Cli.providers.logout")(function* (_args) { const authSvc = yield* Auth.Service const modelsDev = yield* ModelsDev.Service - yield* Effect.promise(async () => { - UI.empty() - const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all())) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } - const database = await Effect.runPromise(modelsDev.get()) - const selected = await prompts.select({ + + UI.empty() + const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + prompts.intro("Remove credential") + if (credentials.length === 0) { + prompts.log.error("No credentials found") + return + } + const database = yield* modelsDev.get() + const selected = yield* Effect.promise(() => + prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await Effect.runPromise(authSvc.remove(providerID)) - prompts.outro("Logout successful") - }) + }), + ) + if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) + const providerID = selected as string + yield* Effect.orDie(authSvc.remove(providerID)) + prompts.outro("Logout successful") }), }) From c06af70ab027088a1729e9b8306d5a79804ce728 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 15:44:02 +0000 Subject: [PATCH 131/174] chore: generate --- packages/opencode/src/cli/cmd/providers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index c8d897bea..44fa42015 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -310,9 +310,9 @@ export const ProvidersLoginCommand = effectCmd({ prompts.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = (yield* Effect.promise(() => - fetch(`${url}/.well-known/opencode`).then((x) => x.json()), - )) as { auth: { command: string[]; env: string } } + const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as { + auth: { command: string[]; env: string } + } prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" }) if (!proc.stdout) { From adb7cb1037d24aa18021133b5993fa81869d8ba0 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sun, 3 May 2026 19:21:33 +0200 Subject: [PATCH 132/174] fix(auth): add username option for basic auth in RunCommand (#25600) Co-authored-by: Shoubhit Dash --- packages/opencode/src/cli/cmd/run.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c20833d4b..a05b273e4 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -276,6 +276,11 @@ export const RunCommand = effectCmd({ type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }) .option("dir", { type: "string", describe: "directory to run in, path on remote server if attaching", @@ -657,7 +662,7 @@ export const RunCommand = effectCmd({ } if (args.attach) { - const headers = ServerAuth.headers({ password: args.password }) + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) return await execute(sdk) } From 387220f368ca3a31d94b4be3937d9d825ebd888c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 14:23:29 -0400 Subject: [PATCH 133/174] fix(server): support desktop PTY websockets with HttpApi (#25598) --- packages/app/src/components/terminal.tsx | 28 +- .../src/utils/terminal-websocket-url.test.ts | 36 +++ .../app/src/utils/terminal-websocket-url.ts | 16 ++ packages/opencode/package.json | 5 + .../opencode/src/server/httpapi-listener.ts | 244 ------------------ .../src/server/httpapi-server.node.ts | 34 +++ .../opencode/src/server/httpapi-server.ts | 9 + .../routes/instance/httpapi/handlers/pty.ts | 24 +- .../httpapi/middleware/authorization.ts | 67 +++-- .../instance/httpapi/middleware/proxy.ts | 25 ++ .../instance/httpapi/websocket-tracker.ts | 52 ++++ packages/opencode/src/server/server.ts | 143 +++++++++- packages/opencode/src/util/timeout.ts | 4 +- .../test/server/httpapi-authorization.test.ts | 44 +++- .../test/server/httpapi-listen.test.ts | 155 +++++++++++ .../test/server/httpapi-listener.test.ts | 109 -------- .../test/server/httpapi-mcp-oauth.test.ts | 5 +- 17 files changed, 564 insertions(+), 436 deletions(-) create mode 100644 packages/app/src/utils/terminal-websocket-url.test.ts create mode 100644 packages/app/src/utils/terminal-websocket-url.ts delete mode 100644 packages/opencode/src/server/httpapi-listener.ts create mode 100644 packages/opencode/src/server/httpapi-server.node.ts create mode 100644 packages/opencode/src/server/httpapi-server.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts create mode 100644 packages/opencode/test/server/httpapi-listen.test.ts delete mode 100644 packages/opencode/test/server/httpapi-listener.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ff5ff9dad..998936bc6 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" +import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" @@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } -const errorName = (err: unknown) => { - if (!err || typeof err !== "object") return - if (!("name" in err)) return - const errorName = err.name - return typeof errorName === "string" ? errorName : undefined -} - const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -478,10 +472,9 @@ export const Terminal = (props: TerminalProps) => { const gone = () => client.pty - .get({ ptyID: id }) - .then(() => false) + .get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) .catch((err) => { - if (errorName(err) === "NotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) @@ -509,18 +502,9 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const next = new URL(url + `/pty/${id}/connect`) - next.searchParams.set("directory", directory) - next.searchParams.set("cursor", String(seek)) - next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!sameOrigin && password) { - next.searchParams.set("auth_token", btoa(`${username}:${password}`)) - // For same-origin requests, let the browser reuse the page's existing auth. - next.username = username - next.password = password - } - - const socket = new WebSocket(next) + const socket = new WebSocket( + terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }), + ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts new file mode 100644 index 000000000..c85863abd --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { terminalWebSocketURL } from "./terminal-websocket-url" + +describe("terminalWebSocketURL", () => { + test("uses query auth without embedding credentials in websocket URL", () => { + const url = terminalWebSocketURL({ + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + sameOrigin: false, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) + + test("omits query auth for same-origin websocket URL", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.has("auth_token")).toBe(false) + }) +}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts new file mode 100644 index 000000000..146df16b7 --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -0,0 +1,16 @@ +export function terminalWebSocketURL(input: { + url: string + id: string + directory: string + cursor: number + sameOrigin: boolean + username: string + password?: string +}) { + const next = new URL(`${input.url}/pty/${input.id}/connect`) + next.searchParams.set("directory", input.directory) + next.searchParams.set("cursor", String(input.cursor)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (!input.sameOrigin && input.password) next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) + return next +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8c5aa3499..adb4a7db1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -37,6 +37,11 @@ "bun": "./src/server/adapter.bun.ts", "node": "./src/server/adapter.node.ts", "default": "./src/server/adapter.bun.ts" + }, + "#httpapi-server": { + "bun": "./src/server/httpapi-server.node.ts", + "node": "./src/server/httpapi-server.node.ts", + "default": "./src/server/httpapi-server.node.ts" } }, "devDependencies": { diff --git a/packages/opencode/src/server/httpapi-listener.ts b/packages/opencode/src/server/httpapi-listener.ts deleted file mode 100644 index fd65b0ae6..000000000 --- a/packages/opencode/src/server/httpapi-listener.ts +++ /dev/null @@ -1,244 +0,0 @@ -// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library, -// and `node:http`'s `upgrade` event. -// -// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that -// drives the experimental HttpApi handler directly (no Hono in the middle) and handles -// WebSocket upgrades inline based on path-matching. It exists to validate the pattern -// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it. - -import type { ServerWebSocket } from "bun" -import { Effect, Schema } from "effect" -import { AppRuntime } from "@/effect/app-runtime" -import { WithInstance } from "@/project/with-instance" -import { Pty } from "@/pty" -import { handlePtyInput } from "@/pty/input" -import { PtyID } from "@/pty/schema" -import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty" -import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server" -import * as Log from "@opencode-ai/core/util/log" -import type { CorsOptions } from "./cors" - -const log = Log.create({ service: "httpapi-listener" }) -const decodePtyID = Schema.decodeUnknownSync(PtyID) - -export type Listener = { - hostname: string - port: number - url: URL - stop: (close?: boolean) => Promise -} - -export type ListenOptions = CorsOptions & { - port: number - hostname: string -} - -type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string } - -type PtyHandler = { - onMessage: (message: string | ArrayBuffer) => void - onClose: () => void -} - -type WsState = WsKind & { - handler?: PtyHandler - pending: Array - ready: boolean - closed: boolean -} - -// Derive from the OpenAPI path so this stays in sync if the route literal moves. -const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`) - -function parseCursor(value: string | null): number | undefined { - if (!value) return undefined - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined - return parsed -} - -function asAdapter(ws: ServerWebSocket) { - return { - get readyState() { - return ws.readyState - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - try { - if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data)) - else ws.send(data) - } catch { - // socket likely already closed; ignore - } - }, - close: (code?: number, reason?: string) => { - try { - ws.close(code, reason) - } catch { - // ignore - } - }, - } -} - -/** - * Spin up a native Bun.serve that: - * 1. Routes all HTTP traffic through the HttpApi web handler. - * 2. Intercepts known WebSocket upgrade paths and handles them inline. - * - * This bypasses Hono entirely. The Hono code path remains untouched. - */ -export async function listen(opts: ListenOptions): Promise { - const built = ExperimentalHttpApiServer.webHandler(opts) - const handler = built.handler - const context = ExperimentalHttpApiServer.context - - const start = (port: number) => { - try { - return Bun.serve({ - hostname: opts.hostname, - port, - idleTimeout: 0, - fetch(request, server) { - const url = new URL(request.url) - const ptyMatch = url.pathname.match(ptyConnectPattern) - if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") { - const ptyID = ptyMatch[1]! - const cursor = parseCursor(url.searchParams.get("cursor")) - // Resolve the instance directory the same way the HttpApi - // `instance-context` middleware does (search params, then header, - // then process.cwd()). - const directory = - url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd() - const upgraded = server.upgrade(request, { - data: { - kind: "pty", - ptyID, - cursor, - directory, - pending: [], - ready: false, - closed: false, - } satisfies WsState, - }) - if (upgraded) return undefined - return new Response("upgrade failed", { status: 400 }) - } - - // TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a - // remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support - // that here we'd need to (a) resolve the workspace target the same way - // `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request, - // { data: { kind: "proxy", target, headers, protocols } })` and bridge the - // ServerWebSocket to a remote WebSocket inside the `websocket` handlers. - // Deferred to a follow-up — the proxy story needs more design (auth header - // forwarding, fence sync, reconnection semantics) than fits this PR. - - return handler(request as Request, context as never) - }, - websocket: { - open(ws) { - const data = ws.data - if (data.kind !== "pty") { - ws.close(1011, "unknown ws kind") - return - } - const id = (() => { - try { - return decodePtyID(data.ptyID) - } catch { - ws.close(1008, "invalid pty id") - return undefined - } - })() - if (!id) return - ;(async () => { - const result = await WithInstance.provide({ - directory: data.directory, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.connect(id, asAdapter(ws), data.cursor) - }).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")), - ), - }) - return await result - })() - .then((handler) => { - if (data.closed) { - handler?.onClose() - return - } - if (!handler) { - ws.close(4404, "session not found") - return - } - data.handler = handler - data.ready = true - for (const msg of data.pending) { - AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined) - } - data.pending.length = 0 - }) - .catch((err) => { - log.error("pty connect failed", { error: err }) - ws.close(1011, "pty connect failed") - }) - }, - message(ws, message) { - const data = ws.data - if (data.kind !== "pty") return - const payload = - typeof message === "string" - ? message - : message instanceof Buffer - ? new Uint8Array(message.buffer, message.byteOffset, message.byteLength) - : (message as Uint8Array) - if (!data.ready || !data.handler) { - data.pending.push(payload) - return - } - AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined) - }, - close(ws) { - const data = ws.data - data.closed = true - data.handler?.onClose() - }, - }, - }) - } catch (err) { - log.error("Bun.serve failed", { error: err }) - return undefined - } - } - - const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) - if (!server) throw new Error(`Failed to start server on port ${opts.port}`) - const port = server.port - if (port === undefined) throw new Error("Bun.serve started without a numeric port") - - const url = new URL("http://localhost") - url.hostname = opts.hostname - url.port = String(port) - - let closing: Promise | undefined - return { - hostname: opts.hostname, - port, - url, - stop(close?: boolean) { - closing ??= (async () => { - await server.stop(close) - // NOTE: we deliberately do NOT call `built.dispose()` here. The - // underlying `webHandler` is memoized at module level (same as the - // Hono path), so disposing it would tear down shared services for - // every other consumer in the process. Lifecycle teardown is owned - // by the AppRuntime itself. - })() - return closing - }, - } -} - -export * as HttpApiListener from "./httpapi-listener" diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts new file mode 100644 index 000000000..5d29fae33 --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -0,0 +1,34 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import type { Opts } from "./adapter" +import { Service } from "./httpapi-server" + +export { Service } + +export const name = "node-http-server" + +export const layer = (opts: Opts) => { + const server = createServer() + const serverRef = { closeStarted: false, forceStop: false } + const close = server.close.bind(server) + // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by + // force-closing active HTTP sockets when its finalizer calls server.close(). + server.close = ((callback?: Parameters[0]) => { + serverRef.closeStarted = true + const result = close(callback) + if (serverRef.forceStop) server.closeAllConnections() + return result + }) as typeof server.close + return Layer.mergeAll( + NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + Layer.succeed(Service)( + Service.of({ + closeAll: Effect.sync(() => { + serverRef.forceStop = true + if (serverRef.closeStarted) server.closeAllConnections() + }), + }), + ), + ) +} diff --git a/packages/opencode/src/server/httpapi-server.ts b/packages/opencode/src/server/httpapi-server.ts new file mode 100644 index 000000000..5f3804c10 --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiServer") {} + +export * as HttpApiServer from "./httpapi-server" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index cc7c385b3..2e2c4ee1c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -2,12 +2,14 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" +import { EffectBridge } from "@/effect/bridge" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" +import { WebSocketTracker } from "../websocket-tracker" export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { @@ -80,9 +82,22 @@ export const ptyConnectRoute = HttpRouter.use((router) => : undefined const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) const write = yield* socket.writer - const services = yield* Effect.context() + const closeAccepted = (event: Socket.CloseEvent) => + socket + .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT())) + if (!registered) { + yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT()) + return HttpServerResponse.empty() + } + const bridge = yield* EffectBridge.make() const writeScoped = (effect: Effect.Effect) => { - Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void))) + bridge.fork(effect.pipe(Effect.catch(() => Effect.void))) } let closed = false const adapter = { @@ -100,7 +115,10 @@ export const ptyConnectRoute = HttpRouter.use((router) => }, } const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() + if (!handler) { + yield* closeAccepted(new Socket.CloseEvent(4404, "session not found")) + return HttpServerResponse.empty() + } yield* socket .runRaw((message) => handlePtyInput(handler, message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index bd9552edc..2a8f1cf4d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,23 +1,29 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' +// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the +// full handler, so a downstream failure can make the next auth alternative run +// and remap an authorized NotFound into Unauthorized. export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { error: HttpApiError.UnauthorizedNoContent, - security: { - basic: HttpApiSecurity.basic, - authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }), - }, }, ) {} +function emptyCredential() { + return { + username: "", + password: Redacted.make(""), + } +} + function validateCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, @@ -31,19 +37,14 @@ function validateCredential( } function decodeCredential(input: string) { - const emptyCredential = { - username: "", - password: Redacted.make(""), - } - return Encoding.decodeBase64String(input) .asEffect() .pipe( Effect.match({ - onFailure: () => emptyCredential, + onFailure: emptyCredential, onSuccess: (header) => { const parts = header.split(":") - if (parts.length !== 2) return emptyCredential + if (parts.length !== 2) return emptyCredential() return { username: parts[0], password: Redacted.make(parts[1]), @@ -53,6 +54,14 @@ function decodeCredential(input: string) { ) } +function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { + const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) + if (token) return decodeCredential(token) + const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") + if (match) return decodeCredential(match[1]) + return Effect.succeed(emptyCredential()) +} + function validateRawCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, @@ -77,21 +86,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") - if (match) { - return yield* decodeCredential(match[1]).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) - if (token) { - return yield* decodeCredential(token).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config) + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + ) }) }), ) @@ -100,12 +97,14 @@ export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { const config = yield* ServerAuth.Config - return Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential, config), - authToken: (effect, { credential }) => - decodeCredential(Redacted.value(credential)).pipe( - Effect.flatMap((decoded) => validateCredential(effect, decoded, config)), - ), - }) + if (!ServerAuth.required(config)) return Authorization.of((effect) => effect) + return Authorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => validateCredential(effect, credential, config)), + ) + }), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e354dccbf..0a1745f93 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WebSocketTracker } from "../websocket-tracker" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { return request.source instanceof Request ? request.source : undefined @@ -28,6 +29,30 @@ export function websocket( }) const writeInbound = yield* inbound.writer const writeOutbound = yield* outbound.writer + const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect) => + socket + .runRaw(() => Effect.void, { + onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)), + }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const closeAccepted = Effect.all( + [closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], + { concurrency: "unbounded", discard: true }, + ) + const registered = yield* WebSocketTracker.register( + Effect.all( + [writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT())], + { concurrency: "unbounded", discard: true }, + ), + ) + if (!registered) { + yield* closeAccepted + return HttpServerResponse.empty() + } yield* outbound .runRaw((message) => writeInbound(message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts new file mode 100644 index 000000000..4463c9c59 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -0,0 +1,52 @@ +import { Context, Effect, Layer, Option } from "effect" +import * as Socket from "effect/unstable/socket/Socket" + +export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") + +type Close = Effect.Effect + +export interface Interface { + readonly add: (close: Close) => Effect.Effect + readonly remove: (close: Close) => Effect.Effect + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} + +export const layer = Layer.sync(Service)(() => { + const sockets = new Set() + let closing = false + return Service.of({ + add: (close) => + Effect.gen(function* () { + if (closing) return false + sockets.add(close) + return true + }), + remove: (close) => + Effect.sync(() => { + sockets.delete(close) + }), + closeAll: Effect.gen(function* () { + closing = true + const active = Array.from(sockets) + sockets.clear() + yield* Effect.all( + active.map((close) => close.pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))), + { concurrency: "unbounded", discard: true }, + ) + }), + }) +}) + +export const register = (close: Close) => + Effect.gen(function* () { + const tracker = yield* Effect.serviceOption(Service) + if (Option.isNone(tracker)) return true + const registered = yield* tracker.value.add(close) + if (!registered) return false + yield* Effect.addFinalizer(() => tracker.value.remove(close)) + return true + }) + +export * as WebSocketTracker from "./websocket-tracker" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 13ec70616..0383dc66f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,10 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" +import { Context, Effect, Exit, Layer, Scope } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" +import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -18,6 +21,8 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" +import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" @@ -182,37 +187,147 @@ export async function openapiHono() { export let url: URL export async function listen(opts: ListenOptions): Promise { - const built = create(opts) - const server = await built.runtime.listen(opts) + const selected = select() + const inner: Listener = + selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) - const next = new URL("http://localhost") - next.hostname = opts.hostname - next.port = String(server.port) + const next = new URL(inner.url) url = next const mdns = opts.mdns && - server.port && + inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { - MDNS.publish(server.port, opts.mdnsDomain) + MDNS.publish(inner.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } let closing: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } + return { + hostname: inner.hostname, + port: inner.port, + url: next, + stop(close?: boolean) { + unpublish() + // Always forward stop(true), even if a graceful stop was requested + // first, so native listeners can escalate shutdown in-place. + const next = inner.stop(close) + closing ??= next + return close ? next.then(() => closing!) : closing + }, + } +} + +async function listenLegacy(opts: ListenOptions): Promise { + const built = create(opts) + const server = await built.runtime.listen(opts) + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(server.port) return { hostname: opts.hostname, port: server.port, - url: next, - stop(close?: boolean) { - closing ??= (async () => { - if (mdns) MDNS.unpublish() - await server.stop(close) - })() - return closing + url: innerUrl, + stop: (close?: boolean) => server.stop(close), + } +} + +/** + * Run the effect-httpapi backend on a native Effect HTTP server. This + * lets HttpApi routes that call `request.upgrade` (PTY connect, the + * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono + * adapter path can't surface `request.upgrade` because its fetch handler has + * no reference to the platform server instance for websocket upgrades. + */ +async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { + log.info("server backend selected", { + ...ServerBackend.attributes(selection), + "opencode.server.runtime": HttpApiServer.name, + }) + + const buildLayer = (port: number) => + HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { + middleware: disposeMiddleware, + disableLogger: true, + disableListenLog: true, + }).pipe( + Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + ) + + const start = async (port: number) => { + const scope = Scope.makeUnsafe() + try { + // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by + // design, which leaks `R = any` through `HttpRouter.serve`. The actual + // requirements at this point are fully satisfied by `createRoutes` and the + // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. + const layer = buildLayer(port) as Layer.Layer< + HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, + unknown, + never + > + const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope)) + return { scope, ctx } + } catch (err) { + await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined) + throw err + } + } + + // Match the legacy adapter port-resolution behavior: explicit `0` prefers + // 4096 first, then any free port. + let resolved: Awaited> | undefined + if (opts.port === 0) { + resolved = await start(4096).catch(() => undefined) + if (!resolved) resolved = await start(0) + } else { + resolved = await start(opts.port) + } + if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`) + + const server = Context.get(resolved.ctx, HttpServer.HttpServer) + if (server.address._tag !== "TcpAddress") { + await Effect.runPromise(Scope.close(resolved.scope, Exit.void)) + throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`) + } + const port = server.address.port + + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(port) + let forceStopPromise: Promise | undefined + let stopPromise: Promise | undefined + const forceStop = () => { + forceStopPromise ??= Effect.runPromiseExit( + Effect.gen(function* () { + yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll + yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll + }), + ).then(() => undefined) + return forceStopPromise + } + + return { + hostname: opts.hostname, + port, + url: innerUrl, + stop: (close?: boolean) => { + const requested = close ? forceStop() : Promise.resolve() + // The first call starts scope shutdown. A later stop(true) cannot undo + // that, but it still runs forceStop() before awaiting the original close. + stopPromise ??= requested.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))).then(() => undefined) + return requested.then(() => stopPromise!) }, } } diff --git a/packages/opencode/src/util/timeout.ts b/packages/opencode/src/util/timeout.ts index 31ac48146..22f2648c9 100644 --- a/packages/opencode/src/util/timeout.ts +++ b/packages/opencode/src/util/timeout.ts @@ -1,4 +1,4 @@ -export function withTimeout(promise: Promise, ms: number): Promise { +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { let timeout: NodeJS.Timeout return Promise.race([ promise.finally(() => { @@ -6,7 +6,7 @@ export function withTimeout(promise: Promise, ms: number): Promise { }), new Promise((_, reject) => { timeout = setTimeout(() => { - reject(new Error(`Operation timed out after ${ms}ms`)) + reject(new Error(label ?? `Operation timed out after ${ms}ms`)) }, ms) }), ]) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index d780b18f2..850098926 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi" import { ServerAuth } from "../../src/server/auth" import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" @@ -13,11 +13,19 @@ const Api = HttpApi.make("test-authorization").add( HttpApiEndpoint.get("probe", "/probe", { success: Schema.String, }), + HttpApiEndpoint.get("missing", "/missing", { + success: Schema.String, + error: HttpApiError.NotFound, + }), ) .middleware(Authorization), ) -const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok"))) +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => + handlers + .handle("probe", () => Effect.succeed("ok")) + .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))), +) const apiLayer = HttpRouter.serve( HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), @@ -32,8 +40,7 @@ const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) -const basic = (username: string, password: string) => - `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? "" const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") @@ -90,6 +97,35 @@ describe("HttpApi authorization middleware", () => { }), ) + itSecret.live("prefers auth token query credentials over basic auth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get( + `/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`, + ).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("preserves handler errors when basic auth succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get("/missing").pipe( + HttpClientRequest.setHeader("authorization", basic("opencode", "secret")), + HttpClient.execute, + ) + + expect(response.status).toBe(404) + }), + ) + + itSecret.live("preserves handler errors when auth token query succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(404) + }), + ) + itSecret.live("rejects malformed auth token query credentials", () => Effect.gen(function* () { const response = yield* HttpClient.get("/probe?auth_token=not-base64") diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts new file mode 100644 index 000000000..3ee57dc10 --- /dev/null +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, +} +const auth = { username: "opencode", password: "listen-secret" } +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword + if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = original.envUsername + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_SERVER_PASSWORD = auth.password + Flag.OPENCODE_SERVER_USERNAME = auth.username + process.env.OPENCODE_SERVER_PASSWORD = auth.password + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +function authorization() { + return `Basic ${btoa(`${auth.username}:${auth.password}`)}` +} + +function socketURL(listener: Awaited>, id: string, dir: string) { + const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) + url.protocol = "ws:" + url.searchParams.set("directory", dir) + url.searchParams.set("cursor", "-1") + url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`)) + return url +} + +async function createCat(listener: Awaited>, dir: string) { + const response = await fetch(new URL(PtyPaths.create, listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { id: string } +} + +async function openSocket(url: URL) { + const ws = new WebSocket(url) + ws.binaryType = "arraybuffer" + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true }) + }), + 5_000, + "timed out waiting for websocket open", + ) + return ws +} + +function stop(listener: Awaited>, label: string) { + return withTimeout(listener.stop(true), 10_000, label) +} + +function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) { + const decoder = new TextDecoder() + let onMessage: ((event: MessageEvent) => void) | undefined + return withTimeout( + new Promise((resolve) => { + onMessage = (event: MessageEvent) => { + const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer) + if (!predicate(message)) return + resolve(message) + } + ws.addEventListener("message", onMessage) + }), + 5_000, + "timed out waiting for websocket message", + ).finally(() => { + if (onMessage) ws.removeEventListener("message", onMessage) + }) +} + +describe("HttpApi Server.listen", () => { + testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const response = await fetch(new URL(PtyPaths.shells, listener.url), { + headers: { authorization: authorization(), "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) + + const message = waitForMessage(ws, (message) => message.includes("ping-listen")) + ws.send("ping-listen\n") + expect(await message).toContain("ping-listen") + + await stop(listener, "timed out waiting for listener.stop(true)") + stopped = true + await withTimeout(closed, 5_000, "timed out waiting for websocket close") + expect(ws.readyState).toBe(WebSocket.CLOSED) + + const restarted = await startListener() + try { + const nextInfo = await createCat(restarted, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path)) + const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) + nextWs.send("ping-restarted\n") + expect(await nextMessage).toContain("ping-restarted") + nextWs.close(1000) + } finally { + await stop(restarted, "timed out waiting for restarted listener.stop(true)") + } + } finally { + if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) + } + }) +}) diff --git a/packages/opencode/test/server/httpapi-listener.test.ts b/packages/opencode/test/server/httpapi-listener.test.ts deleted file mode 100644 index de7b5987e..000000000 --- a/packages/opencode/test/server/httpapi-listener.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { HttpApiListener } from "../../src/server/httpapi-listener" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const testPty = process.platform === "win32" ? test.skip : test - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -async function startListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 }) -} - -describe("native HttpApi listener", () => { - test("serves HTTP routes via the HttpApi web handler", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener() - try { - const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, { - headers: { "x-opencode-directory": tmp.path }, - }) - expect(response.status).toBe(200) - const body = await response.json() - expect(Array.isArray(body)).toBe(true) - expect(body[0]).toMatchObject({ - path: expect.any(String), - name: expect.any(String), - acceptable: expect.any(Boolean), - }) - } finally { - await listener.stop(true) - } - }) - - testPty("PTY websocket connect echoes input back to the client", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener() - try { - const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, { - method: "POST", - headers: { - "x-opencode-directory": tmp.path, - "content-type": "application/json", - }, - body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }), - }) - expect(created.status).toBe(200) - const info = (await created.json()) as { id: string } - - try { - const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url) - wsURL.protocol = "ws:" - wsURL.searchParams.set("directory", tmp.path) - wsURL.searchParams.set("cursor", "-1") - - const messages: string[] = [] - const ws = new WebSocket(wsURL) - ws.binaryType = "arraybuffer" - - const opened = new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }) - ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true }) - }) - - const closed = new Promise((resolve) => { - ws.addEventListener("close", () => resolve(), { once: true }) - }) - - ws.addEventListener("message", (event) => { - const data = event.data - messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer)) - }) - - await opened - ws.send("ping-listener\n") - - const start = Date.now() - while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) { - await new Promise((r) => setTimeout(r, 50)) - } - ws.close(1000, "done") - - expect(messages.some((m) => m.includes("ping-listener"))).toBe(true) - // Verify close event fires (handler.onClose path runs and the - // Bun.serve websocket lifecycle reaches close). - await closed - expect(ws.readyState).toBe(WebSocket.CLOSED) - } finally { - await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, { - method: "DELETE", - headers: { "x-opencode-directory": tmp.path }, - }).catch(() => undefined) - } - } finally { - await listener.stop(true) - } - }) -}) diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index 829f89960..d3ca4ae68 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => const passthroughAuthorization = Layer.succeed( Authorization, - Authorization.of({ - basic: (effect) => effect, - authToken: (effect) => effect, - }), + Authorization.of((effect) => effect), ) const passthroughInstanceContext = Layer.succeed( From 28112fbd12d16d21563eead2a188e0ecae11303e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 18:24:37 +0000 Subject: [PATCH 134/174] chore: generate --- packages/app/src/utils/terminal-websocket-url.ts | 3 ++- .../routes/instance/httpapi/middleware/proxy.ts | 13 ++++++++----- .../routes/instance/httpapi/websocket-tracker.ts | 7 ++++++- packages/opencode/src/server/server.ts | 10 ++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index 146df16b7..d364762d7 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -11,6 +11,7 @@ export function terminalWebSocketURL(input: { next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!input.sameOrigin && input.password) next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) + if (!input.sameOrigin && input.password) + next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) return next } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index 0a1745f93..230f5b105 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -39,13 +39,16 @@ export function websocket( Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), Effect.catch(() => Effect.void), ) - const closeAccepted = Effect.all( - [closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], - { concurrency: "unbounded", discard: true }, - ) + const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], { + concurrency: "unbounded", + discard: true, + }) const registered = yield* WebSocketTracker.register( Effect.all( - [writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT())], + [ + writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + ], { concurrency: "unbounded", discard: true }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts index 4463c9c59..7cbac4ed5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -32,7 +32,12 @@ export const layer = Layer.sync(Service)(() => { const active = Array.from(sockets) sockets.clear() yield* Effect.all( - active.map((close) => close.pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))), + active.map((close) => + close.pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.void), + ), + ), { concurrency: "unbounded", discard: true }, ) }), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 0383dc66f..6c7a6743d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -195,11 +195,7 @@ export async function listen(opts: ListenOptions): Promise { url = next const mdns = - opts.mdns && - inner.port && - opts.hostname !== "127.0.0.1" && - opts.hostname !== "localhost" && - opts.hostname !== "::1" + opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { MDNS.publish(inner.port, opts.mdnsDomain) } else if (opts.mdns) { @@ -326,7 +322,9 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec const requested = close ? forceStop() : Promise.resolve() // The first call starts scope shutdown. A later stop(true) cannot undo // that, but it still runs forceStop() before awaiting the original close. - stopPromise ??= requested.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))).then(() => undefined) + stopPromise ??= requested + .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) + .then(() => undefined) return requested.then(() => stopPromise!) }, } From 7749d8e85f2bf4879ee98af90066c167153bb19b Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 3 May 2026 14:45:48 -0400 Subject: [PATCH 135/174] Add v2 session failure events (#25628) --- .../src/cli/cmd/tui/context/sync-v2.tsx | 11 +++- packages/opencode/src/session/processor.ts | 13 ++++- .../opencode/src/session/projectors-next.ts | 7 ++- packages/opencode/src/v2/session-event.ts | 29 ++++++++--- .../src/v2/session-message-updater.ts | 13 ++++- packages/opencode/src/v2/session-message.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 51 ++++++++++++++++--- 7 files changed, 104 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index f82bb4d96..9801f0a2f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -143,6 +143,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } }) break + case "session.next.step.failed": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = "error" + currentAssistant.error = event.properties.error + }) + break case "session.next.text.started": update(event.properties.sessionID, (draft) => { activeAssistant(draft)?.content.push({ type: "text", text: "" }) @@ -210,7 +219,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( match.time.completed = event.properties.timestamp }) break - case "session.next.tool.error": + case "session.next.tool.failed": update(event.properties.sessionID, (draft) => { const match = latestTool(activeAssistant(draft), event.properties.callID) if (match?.state.status !== "running") return diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e2a47f180..cf1a7e0ae 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -405,7 +405,7 @@ export const layer: Layer.Layer< case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Error.Sync, { + EventV2.run(SessionEvent.Tool.Failed.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, error: { @@ -650,6 +650,17 @@ export const layer: Layer.Layer< yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: error.name, + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 951e3e874..88f73acf1 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -161,6 +161,9 @@ export default [ SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) }), + SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) + }), SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) }), @@ -181,8 +184,8 @@ export default [ SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) }), - SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) + SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) }), SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 3af5932f0..47938dcbe 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -22,6 +22,11 @@ const Base = { sessionID: SessionID, } +const Error = Schema.Struct({ + type: Schema.String, + message: Schema.String, +}) + export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", aggregate: "sessionID", @@ -128,6 +133,16 @@ export namespace Step { }, }) export type Ended = Schema.Schema.Type + + export const Failed = EventV2.define({ + type: "session.next.step.failed", + aggregate: "sessionID", + schema: { + ...Base, + error: Error, + }, + }) + export type Failed = Schema.Schema.Type } export namespace Text { @@ -275,23 +290,20 @@ export namespace Tool { }) export type Success = Schema.Schema.Type - export const Error = EventV2.define({ - type: "session.next.tool.error", + export const Failed = EventV2.define({ + type: "session.next.tool.failed", aggregate: "sessionID", schema: { ...Base, callID: Schema.String, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: Error, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), }, }) - export type Error = Schema.Schema.Type + export type Failed = Schema.Schema.Type } export const RetryError = Schema.Struct({ @@ -359,6 +371,7 @@ export const All = Schema.Union( Shell.Ended, Step.Started, Step.Ended, + Step.Failed, Text.Started, Text.Delta, Text.Ended, @@ -368,7 +381,7 @@ export const All = Schema.Union( Tool.Called, Tool.Progress, Tool.Success, - Tool.Error, + Tool.Failed, Reasoning.Started, Reasoning.Delta, Reasoning.Ended, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index ad1aa32e7..d5d5aac7b 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -199,6 +199,17 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, + "session.next.step.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = "error" + draft.error = event.data.error + }), + ) + } + }, "session.next.text.started": () => { if (currentAssistant) { adapter.updateAssistant( @@ -314,7 +325,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, - "session.next.tool.error": (event) => { + "session.next.tool.failed": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 8ec99bc20..94f6b1cac 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -152,7 +152,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan write: Schema.Finite, }), }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), + error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index caa3d4c76..79ef42d9e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -58,6 +58,7 @@ export type Event = | EventSessionNextShellEnded | EventSessionNextStepStarted | EventSessionNextStepEnded + | EventSessionNextStepFailed | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded @@ -70,7 +71,7 @@ export type Event = | EventSessionNextToolCalled | EventSessionNextToolProgress | EventSessionNextToolSuccess - | EventSessionNextToolError + | EventSessionNextToolFailed | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta @@ -823,6 +824,7 @@ export type GlobalEvent = { | EventSessionNextShellEnded | EventSessionNextStepStarted | EventSessionNextStepEnded + | EventSessionNextStepFailed | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded @@ -835,7 +837,7 @@ export type GlobalEvent = { | EventSessionNextToolCalled | EventSessionNextToolProgress | EventSessionNextToolSuccess - | EventSessionNextToolError + | EventSessionNextToolFailed | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta @@ -857,6 +859,7 @@ export type GlobalEvent = { | SyncEventSessionNextShellEnded | SyncEventSessionNextStepStarted | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed | SyncEventSessionNextTextStarted | SyncEventSessionNextTextDelta | SyncEventSessionNextTextEnded @@ -869,7 +872,7 @@ export type GlobalEvent = { | SyncEventSessionNextToolCalled | SyncEventSessionNextToolProgress | SyncEventSessionNextToolSuccess - | SyncEventSessionNextToolError + | SyncEventSessionNextToolFailed | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionDelta @@ -1973,6 +1976,22 @@ export type SyncEventSessionNextStepEnded = { } } +export type SyncEventSessionNextStepFailed = { + type: "sync" + name: "session.next.step.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + export type SyncEventSessionNextTextStarted = { type: "sync" name: "session.next.text.started.1" @@ -2157,9 +2176,9 @@ export type SyncEventSessionNextToolSuccess = { } } -export type SyncEventSessionNextToolError = { +export type SyncEventSessionNextToolFailed = { type: "sync" - name: "session.next.tool.error.1" + name: "session.next.tool.failed.1" id: string seq: number aggregateID: "sessionID" @@ -2710,6 +2729,19 @@ export type EventSessionNextStepEnded = { } } +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + export type EventSessionNextTextStarted = { id: string type: "session.next.text.started" @@ -2870,9 +2902,9 @@ export type EventSessionNextToolSuccess = { } } -export type EventSessionNextToolError = { +export type EventSessionNextToolFailed = { id: string - type: "session.next.tool.error" + type: "session.next.tool.failed" properties: { timestamp: number sessionID: string @@ -3162,7 +3194,10 @@ export type SessionMessageAssistant = { write: number } } - error?: string + error?: { + type: string + message: string + } } export type SessionMessageCompaction = { From a9dc0fae3d808baf3cbb6f5529877da20db164e7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 18:46:50 +0000 Subject: [PATCH 136/174] chore: generate --- packages/sdk/openapi.json | 126 +++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index df00c1726..21c547c85 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8512,6 +8512,9 @@ { "$ref": "#/components/schemas/EventSessionNextStepEnded" }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, { "$ref": "#/components/schemas/EventSessionNextTextStarted" }, @@ -8549,7 +8552,7 @@ "$ref": "#/components/schemas/EventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/EventSessionNextToolError" + "$ref": "#/components/schemas/EventSessionNextToolFailed" }, { "$ref": "#/components/schemas/EventSessionNextRetried" @@ -10708,6 +10711,9 @@ { "$ref": "#/components/schemas/EventSessionNextStepEnded" }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, { "$ref": "#/components/schemas/EventSessionNextTextStarted" }, @@ -10745,7 +10751,7 @@ "$ref": "#/components/schemas/EventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/EventSessionNextToolError" + "$ref": "#/components/schemas/EventSessionNextToolFailed" }, { "$ref": "#/components/schemas/EventSessionNextRetried" @@ -10810,6 +10816,9 @@ { "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" + }, { "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" }, @@ -10847,7 +10856,7 @@ "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolError" + "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" }, { "$ref": "#/components/schemas/SyncEventSessionNextRetried" @@ -14161,6 +14170,57 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "SyncEventSessionNextStepFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, "SyncEventSessionNextTextStarted": { "type": "object", "properties": { @@ -14729,7 +14789,7 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SyncEventSessionNextToolError": { + "SyncEventSessionNextToolFailed": { "type": "object", "properties": { "type": { @@ -14738,7 +14798,7 @@ }, "name": { "type": "string", - "enum": ["session.next.tool.error.1"] + "enum": ["session.next.tool.failed.1"] }, "id": { "type": "string" @@ -16399,6 +16459,46 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNextTextStarted": { "type": "object", "properties": { @@ -16869,7 +16969,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolError": { + "EventSessionNextToolFailed": { "type": "object", "properties": { "id": { @@ -16877,7 +16977,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.error"] + "enum": ["session.next.tool.failed"] }, "properties": { "type": "object", @@ -17700,7 +17800,17 @@ "additionalProperties": false }, "error": { - "type": "string" + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false } }, "required": ["id", "time", "type", "agent", "model", "content"], From 6312c55d55e83a3d9a68ffd56f9cc4298b245901 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 15:44:23 -0400 Subject: [PATCH 137/174] fix(server): serve embedded UI from bunfs (#25632) --- packages/opencode/src/server/shared/ui.ts | 39 ++++++++++++------- .../opencode/test/server/httpapi-ui.test.ts | 35 ++++++++++++++++- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index db67749e0..c1558a1a4 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -45,6 +45,31 @@ export function embeddedUI() { return embeddedUIPromise } +function notFound() { + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) +} + +function embeddedUIResponse(file: string, body: Uint8Array) { + const mime = AppFileSystem.mimeType(file) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(body, { headers }) +} + +export function serveEmbeddedUIEffect( + requestPath: string, + fs: AppFileSystem.Interface, + embeddedWebUI: Record, +) { + const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!file) return Effect.succeed(notFound()) + + return fs.readFile(file).pipe( + Effect.map((body) => embeddedUIResponse(file, body)), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), + ) +} + export function serveUIEffect( request: HttpServerRequest.HttpServerRequest, services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, @@ -53,19 +78,7 @@ export function serveUIEffect( const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) const path = new URL(request.url, "http://localhost").pathname - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - - if (yield* services.fs.existsSafe(match)) { - const mime = AppFileSystem.mimeType(match) - const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) - } - - return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - } + if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) const response = yield* services.client.execute( HttpClientRequest.make(request.method)(upstreamURL(path), { diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 8b7a6a1ac..332ad16c6 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -15,7 +15,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { ServerAuth } from "../../src/server/auth" import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { serveUIEffect } from "../../src/server/shared/ui" +import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) @@ -184,6 +184,39 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + let readPath: string | undefined + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/assets/app.js", + { + ...fs, + existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), + readFile: (path) => { + readPath = path + return path === "/$bunfs/root/assets/app.js" + ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "assets/app.js": "/$bunfs/root/assets/app.js" }, + ) + }).pipe( + Effect.provide(AppFileSystem.defaultLayer), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(readPath).toBe("/$bunfs/root/assets/app.js") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('embedded')") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true From 755cd561ec9f6be6cb3de75790aa44501c6d385c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 19:45:26 +0000 Subject: [PATCH 138/174] chore: generate --- packages/opencode/test/server/httpapi-ui.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 332ad16c6..f364491ac 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -205,10 +205,7 @@ describe("HttpApi UI fallback", () => { }, { "assets/app.js": "/$bunfs/root/assets/app.js" }, ) - }).pipe( - Effect.provide(AppFileSystem.defaultLayer), - Effect.map(HttpServerResponse.toWeb), - ), + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), ) expect(response.status).toBe(200) From 825ab2e38d1f41074bb536b6ba5771f30594b197 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 16:41:10 -0400 Subject: [PATCH 139/174] refactor(cli): effectify provider commands (#25633) --- packages/opencode/src/cli/cmd/providers.ts | 274 +++++++++++---------- packages/opencode/src/cli/effect/prompt.ts | 24 +- 2 files changed, 157 insertions(+), 141 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 44fa42015..749139e2d 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,9 +1,8 @@ import { Auth } from "../../auth" -import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" -import { effectCmd } from "../effect-cmd" -import * as prompts from "@clack/prompts" +import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import * as Prompt from "../effect/prompt" import { ModelsDev } from "@/provider/models" import { map, pipe, sortBy, values } from "remeda" @@ -14,44 +13,57 @@ import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" +import { errorMessage } from "@/util/error" import { text } from "node:stream/consumers" -import { Effect } from "effect" +import { Effect, Option } from "effect" type PluginAuth = NonNullable -const put = (key: string, info: Auth.Info) => - AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set(key, info) - }), - ) +const promptValue = (value: Option.Option) => { + if (Option.isNone(value)) return Effect.die(new UI.CancelledError()) + return Effect.succeed(value.value) +} -async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise { - let index = 0 - if (methodName) { +const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) { + const auth = yield* Auth.Service + yield* Effect.orDie(auth.set(key, info)) +}) + +const cliTry = (message: string, fn: () => PromiseLike) => + Effect.tryPromise({ + try: fn, + catch: (error) => new CliError({ message: message + errorMessage(error) }), + }) + +const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( + plugin: { auth: PluginAuth }, + provider: string, + methodName?: string, +) { + const index = yield* Effect.gen(function* () { + if (!methodName) { + if (plugin.auth.methods.length <= 1) return 0 + return yield* promptValue( + yield* Prompt.select({ + message: "Login method", + options: plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index, + })), + }), + ) + } const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase()) if (match === -1) { - prompts.log.error( + return yield* fail( `Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`, ) - process.exit(1) } - index = match - } else if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } + return match + }) const method = plugin.auth.methods[index] - await new Promise((r) => setTimeout(r, 10)) + yield* Effect.sleep("10 millis") const inputs: Record = {} if (method.prompts) { for (const prompt of method.prompts) { @@ -63,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (prompt.condition && !prompt.condition(inputs)) continue if (prompt.type === "select") { - const value = await prompts.select({ + const value = yield* Prompt.select({ message: prompt.message, options: prompt.options, }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value + inputs[prompt.key] = yield* promptValue(value) + continue } + const value = yield* Prompt.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + inputs[prompt.key] = yield* promptValue(value) } } if (method.type === "oauth") { - const authorize = await method.authorize(inputs) + const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs)) if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) + yield* Prompt.log.info("Go to: " + authorize.url) } if (authorize.method === "auto") { if (authorize.instructions) { - prompts.log.info(authorize.instructions) + yield* Prompt.log.info(authorize.instructions) } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() + const spinner = Prompt.spinner() + yield* spinner.start("Waiting for authorization...") + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback()) if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) + yield* spinner.stop("Failed to authorize", 1) } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -111,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - spinner.stop("Login successful") + yield* spinner.stop("Login successful") } } if (authorize.method === "code") { - const code = await prompts.text({ + const code = yield* Prompt.text({ message: "Paste the authorization code here: ", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) + const authorizationCode = yield* promptValue(code) + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -143,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } if (method.type === "api") { - const key = await prompts.password({ + const key = yield* Prompt.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(key)) throw new UI.CancelledError() + const apiKey = yield* promptValue(key) const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} - if (!method.authorize) { - await put(provider, { + const authorizeApi = method.authorize + if (!authorizeApi) { + yield* put(provider, { type: "api", - key, + key: apiKey, ...metadata, }) - prompts.outro("Done") + yield* Prompt.outro("Done") return true } - const result = await method.authorize(inputs) + const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider - await put(saveProvider, { + yield* put(saveProvider, { type: "api", - key: result.key ?? key, + key: result.key ?? apiKey, ...metadata, }) - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } return false -} +}) export function resolvePluginProviders(input: { hooks: Hooks[] @@ -244,16 +255,16 @@ export const ProvidersListCommand = effectCmd({ const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) const results = Object.entries(yield* Effect.orDie(authSvc.all())) const database = yield* modelsDev.get() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } - prompts.outro(`${results.length} credentials`) + yield* Prompt.outro(`${results.length} credentials`) const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -270,13 +281,13 @@ export const ProvidersListCommand = effectCmd({ if (activeEnvVars.length > 0) { UI.empty() - prompts.intro("Environment") + yield* Prompt.intro("Environment") for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) } - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } }), }) @@ -301,36 +312,42 @@ export const ProvidersLoginCommand = effectCmd({ type: "string", }), handler: Effect.fn("Cli.providers.login")(function* (args) { - const cfgSvc = yield* Config.Service - const pluginSvc = yield* Plugin.Service - const modelsDev = yield* ModelsDev.Service const authSvc = yield* Auth.Service UI.empty() - prompts.intro("Add credential") + yield* Prompt.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as { + const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => + fetch(`${url}/.well-known/opencode`).then((x) => x.json()), + )) as { auth: { command: string[]; env: string } } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" }) + yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const abort = new AbortController() + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal }) if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") return } - const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)])) + const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () => + Promise.all([proc.exited, text(proc.stdout!)]), + ).pipe(Effect.ensuring(Effect.sync(() => abort.abort()))) if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") return } yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) - prompts.log.success("Logged into " + url) - prompts.outro("Done") + yield* Prompt.log.success("Logged into " + url) + yield* Prompt.outro("Done") return } + + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + const modelsDev = yield* ModelsDev.Service yield* Effect.ignore(modelsDev.refresh(true)) const config = yield* cfgSvc.get() @@ -392,53 +409,46 @@ export const ProvidersLoginCommand = effectCmd({ const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) const match = byID ?? byName if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) + return yield* fail(`Unknown provider "${input}"`) } provider = match.value } else { - const selected = yield* Effect.promise(() => - prompts.autocomplete({ + provider = yield* promptValue( + yield* Prompt.autocomplete({ message: "Select provider", maxItems: 8, options: [...options, { value: "other", label: "Other" }], }), ) - if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) - provider = selected as string } const plugin = hooks.findLast((x) => x.auth?.provider === provider) if (plugin && plugin.auth) { - const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method)) + const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method) if (handled) return } if (provider === "other") { - const custom = yield* Effect.promise(() => - prompts.text({ + provider = (yield* promptValue( + yield* Prompt.text({ message: "Enter provider id", validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), }), - ) - if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError()) - provider = (custom as string).replace(/^@ai-sdk\//, "") + )).replace(/^@ai-sdk\//, "") const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) if (customPlugin && customPlugin.auth) { - const handled = yield* Effect.promise(() => - handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method), - ) + const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method) if (handled) return } - prompts.log.warn( + yield* Prompt.log.warn( `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, ) } if (provider === "amazon-bedrock") { - prompts.log.info( + yield* Prompt.log.info( "Amazon Bedrock authentication priority:\n" + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + @@ -448,29 +458,27 @@ export const ProvidersLoginCommand = effectCmd({ } if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") + yield* Prompt.log.info("Create an api key at https://opencode.ai/auth") } if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( + yield* Prompt.log.info( "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", ) } - const key = yield* Effect.promise(() => - prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }), - ) - if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError()) - yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string })) + const key = yield* Prompt.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + const apiKey = yield* promptValue(key) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey })) - prompts.outro("Done") + yield* Prompt.outro("Done") }), }) @@ -485,24 +493,20 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) - prompts.intro("Remove credential") + yield* Prompt.intro("Remove credential") if (credentials.length === 0) { - prompts.log.error("No credentials found") + yield* Prompt.log.error("No credentials found") return } const database = yield* modelsDev.get() - const selected = yield* Effect.promise(() => - prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), - }), - ) - if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) - const providerID = selected as string - yield* Effect.orDie(authSvc.remove(providerID)) - prompts.outro("Logout successful") + const selected = yield* Prompt.select({ + message: "Select provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + yield* Effect.orDie(authSvc.remove(yield* promptValue(selected))) + yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts index 7f9cd8cfe..2713f1a5b 100644 --- a/packages/opencode/src/cli/effect/prompt.ts +++ b/packages/opencode/src/cli/effect/prompt.ts @@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg)) export const log = { info: (msg: string) => Effect.sync(() => prompts.log.info(msg)), + error: (msg: string) => Effect.sync(() => prompts.log.error(msg)), + warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)), + success: (msg: string) => Effect.sync(() => prompts.log.success(msg)), +} + +const optional = (result: Value | symbol) => { + if (prompts.isCancel(result)) return Option.none() + return Option.some(result) } export const select = (opts: Parameters>[0]) => - Effect.tryPromise(() => prompts.select(opts)).pipe( - Effect.map((result) => { - if (prompts.isCancel(result)) return Option.none() - return Option.some(result) - }), - ) + Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result))) + +export const autocomplete = (opts: Parameters>[0]) => + Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result))) + +export const text = (opts: Parameters[0]) => + Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result))) + +export const password = (opts: Parameters[0]) => + Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result))) export const spinner = () => { const s = prompts.spinner() From ca6150d6f092cc8761d6072b0b07b6a7de8748cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 17:13:42 -0400 Subject: [PATCH 140/174] fix(app): preserve auth token credentials (#25636) --- packages/app/src/components/terminal.tsx | 11 +++- packages/app/src/context/server.test.ts | 53 +++++++++++++++++++ packages/app/src/context/server.tsx | 51 ++++++++++-------- packages/app/src/entry.tsx | 19 ++++++- packages/app/src/utils/server.test.ts | 23 ++++++++ packages/app/src/utils/server.ts | 18 ++++++- .../src/utils/terminal-websocket-url.test.ts | 18 ++++++- .../app/src/utils/terminal-websocket-url.ts | 10 +++- 8 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 packages/app/src/context/server.test.ts create mode 100644 packages/app/src/utils/server.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 998936bc6..d4212e32e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -503,7 +503,16 @@ export const Terminal = (props: TerminalProps) => { drop?.() const socket = new WebSocket( - terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }), + terminalWebSocketURL({ + url, + id, + directory, + cursor: seek, + sameOrigin, + username, + password, + authToken: server.current?.type === "http" ? server.current.authToken : false, + }), ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts new file mode 100644 index 000000000..1fa35247c --- /dev/null +++ b/packages/app/src/context/server.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { resolveServerList, ServerConnection } from "./server" + +describe("resolveServerList", () => { + test("lets startup auth_token credentials override a persisted same-url server", () => { + const list = resolveServerList({ + stored: [{ url: "https://server.example.test" }], + props: [ + { + type: "http", + authToken: true, + http: { + url: "https://server.example.test", + username: "opencode", + password: "secret", + }, + }, + ], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "secret", + }) + expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true) + expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test") + }) + + test("keeps persisted credentials when startup has no auth_token", () => { + const list = resolveServerList({ + stored: [ + { + url: "https://server.example.test", + username: "opencode", + password: "saved", + }, + ], + props: [{ type: "http", http: { url: "https://server.example.test" } }], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "saved", + }) + expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1204fba55..a981d99fa 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -33,6 +33,33 @@ function isLocalHost(url: string) { if (host === "localhost" || host === "127.0.0.1") return "local" } +export function resolveServerList(input: { + props?: Array + stored: StoredServer[] +}): Array { + const servers = [ + ...input.stored.map((value) => + typeof value === "string" + ? { + type: "http" as const, + http: { url: value }, + } + : value, + ), + ...(input.props ?? []), + ] + + const deduped = new Map() + for (const value of servers) { + const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } + const key = ServerConnection.key(conn) + if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue + deduped.set(key, conn) + } + + return [...deduped.values()] +} + export namespace ServerConnection { type Base = { displayName?: string } @@ -46,6 +73,7 @@ export namespace ServerConnection { export type Http = { type: "http" http: HttpBase + authToken?: boolean } & Base export type Sidecar = { @@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) const allServers = createMemo((): Array => { - const servers = [ - ...(props.servers ?? []), - ...store.list.map((value) => - typeof value === "string" - ? { - type: "http" as const, - http: { url: value }, - } - : value, - ), - ] - - const deduped = new Map( - servers.map((value) => { - const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } - return [ServerConnection.key(conn), conn] - }), - ) - - return [...deduped.values()] + return resolveServerList({ stored: store.list, props: props.servers }) }) const [state, setState] = createStore({ @@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) if (!url_) return - const conn = { ...input, http: { ...input.http, url: url_ } } + const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } } return batch(() => { const existing = store.list.findIndex((x) => url(x) === url_) if (existing !== -1) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index ade572c2f..5115f0348 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" +import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -111,6 +112,13 @@ const getDefaultUrl = () => { return getCurrentUrl() } +const clearAuthToken = () => { + const params = new URLSearchParams(location.search) + if (!params.has("auth_token")) return + params.delete("auth_token") + history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash) +} + const platform: Platform = { platform: "web", version: pkg.version, @@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) { } if (root instanceof HTMLElement) { - const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } + const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) + clearAuthToken() + const server: ServerConnection.Http = { + type: "http", + authToken: !!auth, + http: { + url: getCurrentUrl(), + ...auth, + }, + } render( () => ( diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts new file mode 100644 index 000000000..4666b7d6d --- /dev/null +++ b/packages/app/src/utils/server.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { authFromToken, authTokenFromCredentials } from "./server" + +describe("authFromToken", () => { + test("decodes basic auth credentials from auth_token", () => { + expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" }) + }) + + test("defaults blank username to opencode", () => { + expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" }) + }) + + test("ignores malformed tokens", () => { + expect(authFromToken("not base64")).toBeUndefined() + expect(authFromToken(btoa("missing-separator"))).toBeUndefined() + }) +}) + +describe("authTokenFromCredentials", () => { + test("encodes credentials with the default username", () => { + expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index ae849b71e..603784e4d 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,21 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function authTokenFromCredentials(input: { username?: string; password: string }) { + return btoa(`${input.username ?? "opencode"}:${input.password}`) +} + +export function authFromToken(token: string | null) { + const decoded = decode64(token ?? undefined) + if (!decoded) return + const separator = decoded.indexOf(":") + if (separator === -1) return + return { + username: decoded.slice(0, separator) || "opencode", + password: decoded.slice(separator + 1), + } +} export function createSdkForServer({ server, @@ -10,7 +26,7 @@ export function createSdkForServer({ const auth = (() => { if (!server.password) return return { - Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`, + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, } })() diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index c85863abd..5fa1506b1 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -19,7 +19,7 @@ describe("terminalWebSocketURL", () => { expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) - test("omits query auth for same-origin websocket URL", () => { + test("omits query auth for same-origin saved credentials", () => { const url = terminalWebSocketURL({ url: "https://app.example.test", id: "pty_test", @@ -33,4 +33,20 @@ describe("terminalWebSocketURL", () => { expect(url.protocol).toBe("wss:") expect(url.searchParams.has("auth_token")).toBe(false) }) + + test("uses query auth for same-origin credentials from auth_token", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + authToken: true, + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index d364762d7..c1c7abad4 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,3 +1,5 @@ +import { authTokenFromCredentials } from "@/utils/server" + export function terminalWebSocketURL(input: { url: string id: string @@ -6,12 +8,16 @@ export function terminalWebSocketURL(input: { sameOrigin: boolean username: string password?: string + authToken?: boolean }) { const next = new URL(`${input.url}/pty/${input.id}/connect`) next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!input.sameOrigin && input.password) - next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) + if (input.password && (!input.sameOrigin || input.authToken)) + next.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: input.username, password: input.password }), + ) return next } From c2b1974dddd51a08f2e995743aa9d377e0046fdf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 18:07:10 -0400 Subject: [PATCH 141/174] Effectify plugin agent regression test (#25646) --- .../agent/plugin-agent-regression.test.ts | 105 ++++++++++-------- 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 72e538aa3..3ac923c43 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,52 +1,65 @@ -import { afterEach, expect, test } from "bun:test" +import { expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" -import { AppRuntime } from "../../src/effect/app-runtime" import { Agent } from "../../src/agent/agent" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() -}) +const pluginAgent = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const -test("plugin-registered agents appear in Agent.list", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginFile = path.join(dir, "plugin.ts") - await Bun.write( - pluginFile, - [ - "export default async () => ({", - " config: async (cfg) => {", - " cfg.agent = cfg.agent ?? {}", - " cfg.agent.plugin_added = {", - ' description: "Added by a plugin via the config hook",', - ' mode: "subagent",', - " }", - " },", - "})", - "", - ].join("\n"), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ) - }, - }) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const added = agents.find((agent) => agent.name === "plugin_added") - expect(added?.description).toBe("Added by a plugin via the config hook") - expect(added?.mode).toBe("subagent") - }, - }) -}) +it.live("plugin-registered agents appear in Agent.list", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const pluginFile = path.join(dir, "plugin.ts") + + yield* Effect.promise(async () => { + await Promise.all([ + Bun.write( + pluginFile, + [ + "export default async () => ({", + " config: async (cfg) => {", + " cfg.agent = cfg.agent ?? {}", + ` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`, + ` description: ${JSON.stringify(pluginAgent.description)},`, + ` mode: ${JSON.stringify(pluginAgent.mode)},`, + " }", + " },", + "})", + "", + ].join("\n"), + ), + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ), + ]) + }) + + const agents = yield* InstanceStore.Service.use((store) => + Effect.gen(function* () { + const ctx = yield* store.load({ directory: dir }) + yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore)) + return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx)) + }), + ) + const added = agents.find((agent) => agent.name === pluginAgent.name) + + expect(added?.description).toBe(pluginAgent.description) + expect(added?.mode).toBe(pluginAgent.mode) + }), +) From ce89bcb8e238401ea8fee000dc54539057d47dc4 Mon Sep 17 00:00:00 2001 From: Utkub24 <76127062+Utkub24@users.noreply.github.com> Date: Mon, 4 May 2026 01:58:16 +0300 Subject: [PATCH 142/174] fix: allow Codex Spark with Codex OAuth (#25640) --- packages/opencode/src/plugin/codex.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index a97f3e9e8..d52075003 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 -const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]) +const ALLOWED_MODELS = new Set([ + "gpt-5.5", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", +]) interface PkceCodes { verifier: string From 7bc26dafae09d326a0f66d2b69b379bc19b3b26e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 22:56:14 -0400 Subject: [PATCH 143/174] feat(server): pty websocket auth tickets (#25660) --- packages/app/src/components/terminal.tsx | 25 +++- .../app/src/utils/terminal-websocket-url.ts | 9 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/pty/ticket.ts | 66 +++++++++ packages/opencode/src/server/cors.ts | 20 +++ packages/opencode/src/server/error.ts | 3 + packages/opencode/src/server/middleware.ts | 3 + .../routes/instance/httpapi/groups/pty.ts | 15 +- .../routes/instance/httpapi/handlers/pty.ts | 34 ++++- .../httpapi/middleware/authorization.ts | 11 +- .../server/routes/instance/httpapi/server.ts | 5 +- .../src/server/routes/instance/index.ts | 8 +- .../src/server/routes/instance/pty.ts | 86 ++++++++++-- packages/opencode/src/server/server.ts | 4 +- .../opencode/src/server/shared/pty-ticket.ts | 15 ++ packages/opencode/test/pty/ticket.test.ts | 59 ++++++++ .../test/server/httpapi-listen.test.ts | 131 +++++++++++++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 34 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 45 ++++++ 19 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/pty/ticket.ts create mode 100644 packages/opencode/src/server/shared/pty-ticket.ts create mode 100644 packages/opencode/test/pty/ticket.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d4212e32e..7bcc02d62 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -479,6 +479,21 @@ export const Terminal = (props: TerminalProps) => { return false }) + const connectToken = async () => { + const result = await client.pty.connectToken( + { ptyID: id }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if ((result.response.status === 404 || result.response.status === 405) && password) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) + } + const retry = (err: unknown) => { if (disposed) return if (reconn !== undefined) return @@ -498,16 +513,24 @@ export const Terminal = (props: TerminalProps) => { }, ms) } - const open = () => { + const open = async () => { if (disposed) return drop?.() + const ticket = await connectToken().catch((err) => { + fail(err) + return undefined + }) + if (once.value) return + if (disposed) return + const socket = new WebSocket( terminalWebSocketURL({ url, id, directory, cursor: seek, + ticket, sameOrigin, username, password, diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index c1c7abad4..06facdc7d 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -5,8 +5,9 @@ export function terminalWebSocketURL(input: { id: string directory: string cursor: number - sameOrigin: boolean - username: string + ticket?: string + sameOrigin?: boolean + username?: string password?: string authToken?: boolean }) { @@ -14,6 +15,10 @@ export function terminalWebSocketURL(input: { next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + next.searchParams.set("ticket", input.ticket) + return next + } if (input.password && (!input.sameOrigin || input.authToken)) next.searchParams.set( "auth_token", diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index e8c8025ea..76ed26d30 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -46,6 +46,7 @@ import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" @@ -98,6 +99,7 @@ export const AppLayer = Layer.mergeAll( Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts new file mode 100644 index 000000000..d40301cad --- /dev/null +++ b/packages/opencode/src/pty/ticket.ts @@ -0,0 +1,66 @@ +export * as PtyTicket from "./ticket" + +import { WorkspaceID } from "@/control-plane/schema" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { PtyID } from "@/pty/schema" +import { PositiveInt } from "@/util/schema" +import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" + +const DEFAULT_TTL = Duration.seconds(60) +const CAPACITY = 10_000 + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}) + +export type Scope = { + readonly ptyID: PtyID + readonly directory?: string + readonly workspaceID?: WorkspaceID +} + +export interface Interface { + issue(input: Scope): Effect.Effect + consume(input: Scope & { readonly ticket: string }): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PtyTicket") {} + +function matches(record: Scope, input: Scope) { + return record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID +} + +// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is +// never invoked; it dies if it ever is, which would signal a misuse of the Service interface. +const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") + +// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. +export const make = (ttl: Duration.Input = DEFAULT_TTL) => + Effect.gen(function* () { + const cache = yield* Cache.make({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl }) + const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl)))) + return Service.of({ + issue: Effect.fn("PtyTicket.issue")(function* (input) { + const ticket = crypto.randomUUID() + yield* Cache.set(cache, ticket, input) + return { ticket, expires_in: expiresIn } + }), + consume: Effect.fn("PtyTicket.consume")(function* (input) { + return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input)) + }), + }) + }) + +export const layer = Layer.effect(Service, make()) + +export const defaultLayer = layer + +export const scope = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { + directory: instance?.directory, + workspaceID, + } +}) diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 62a181af3..92296a3b7 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,7 +1,13 @@ +import { Context } from "effect" + const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ export type CorsOptions = { readonly cors?: ReadonlyArray } +export const CorsConfig = Context.Reference("@opencode/ServerCorsConfig", { + defaultValue: () => undefined, +}) + export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true @@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption if (opencodeOrigin.test(input)) return true return opts?.cors?.includes(input) ?? false } + +export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (host && sameHost(input, host)) return true + return isAllowedCorsOrigin(input, opts) +} + +function sameHost(origin: string, host: string) { + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index 7c5861d91..506e79818 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -21,6 +21,9 @@ export const ERRORS = { }, }, }, + 403: { + description: "Forbidden", + }, 404: { description: "Not found", content: { diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index d2cc9b538..898acaf08 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -12,6 +12,7 @@ import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" +import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" const log = Log.create({ service: "server" }) @@ -44,6 +45,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) @@ -58,6 +60,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M const attributes = { method: c.req.method, path: c.req.path, + // If this logger grows full-URL fields, redact auth_token and ticket query params. ...backendAttributes, } log.info("request", attributes) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index d54bda4a8..3304ab9fb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,4 +1,5 @@ import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -23,6 +24,7 @@ export const PtyPaths = { get: `${root}/:ptyID`, update: `${root}/:ptyID`, remove: `${root}/:ptyID`, + connectToken: `${root}/:ptyID/connect-token`, connect: `${root}/:ptyID/connect`, } as const @@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty") description: "Remove and terminate a specific pseudo-terminal (PTY) session.", }), ), + HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { + params: { ptyID: PtyID }, + success: described(PtyTicket.ConnectToken, "WebSocket connect token"), + error: [HttpApiError.Forbidden, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived ticket for opening a PTY WebSocket connection.", + }), + ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) @@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: described(Schema.Boolean, "Connected session"), - error: HttpApiError.NotFound, + error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connect", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 2e2c4ee1c..e5ff300a2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,8 +1,15 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" import { EffectBridge } from "@/effect/bridge" +import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" @@ -11,9 +18,15 @@ import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" import { WebSocketTracker } from "../websocket-tracker" +function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) { + return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts) +} + export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig const shells = Effect.fn("PtyHttpApi.shells")(function* () { return yield* Effect.promise(() => Shell.list()) @@ -54,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler return true }) + const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) + return yield* new HttpApiError.Forbidden({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) + }) + return handlers .handle("shells", shells) .handle("list", list) @@ -61,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("get", get) .handle("update", update) .handle("remove", remove) + .handle("connectToken", connectToken) }), ) export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig yield* router.add( "GET", PtyPaths.connect, @@ -75,12 +99,20 @@ export const ptyConnectRoute = HttpRouter.use((router) => if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const request = yield* HttpServerRequest.HttpServerRequest + const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + const valid = validOrigin(request, cors) + ? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) const cursor = parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const socket = yield* Effect.orDie(request.upgrade) const write = yield* socket.writer const closeAccepted = (event: Socket.CloseEvent) => socket diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 2a8f1cf4d..6c6d0cd1f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -2,6 +2,7 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" +import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -55,7 +56,11 @@ function decodeCredential(input: string) { } function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) + return credentialFromURL(new URL(request.url, "http://localhost"), request) +} + +function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) { + const token = url.searchParams.get(AUTH_TOKEN_QUERY) if (token) return decodeCredential(token) const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") if (match) return decodeCredential(match[1]) @@ -86,7 +91,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* credentialFromRequest(request).pipe( + const url = new URL(request.url, "http://localhost") + if (hasPtyConnectTicketURL(url)) return yield* effect + return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), ) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 2944ced69..a3754c2e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -25,6 +25,7 @@ import { ProviderAuth } from "@/provider/auth" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -44,7 +45,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -163,6 +164,7 @@ export function createRoutes(corsOptions?: CorsOptions) { ProviderAuth.defaultLayer, Provider.defaultLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, @@ -187,6 +189,7 @@ export function createRoutes(corsOptions?: CorsOptions) { FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 3f9f3f660..89b5641e5 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -39,10 +39,11 @@ import { SessionPaths } from "./httpapi/groups/session" import { SyncPaths } from "./httpapi/groups/sync" import { TuiPaths } from "./httpapi/groups/tui" import { WorkspacePaths } from "./httpapi/groups/workspace" +import type { CorsOptions } from "@/server/cors" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { +export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() - const handler = ExperimentalHttpApiServer.webHandler().handler + const handler = ExperimentalHttpApiServer.webHandler(opts).handler const context = Context.empty() as Context.Context app.all("/api/*", (c) => handler(c.req.raw, context)) @@ -107,6 +108,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) @@ -158,7 +160,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { return app .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) + .route("/pty", PtyRoutes(upgrade, opts)) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index bff0b7191..fb8d5e356 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import type { Context } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" import { Effect, Schema } from "effect" @@ -6,10 +7,19 @@ import z from "zod" import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { Shell } from "@/shell/shell" import { NotFoundError } from "@/storage/storage" import { errors } from "../../error" import { jsonRequest, runRequest } from "./trace" +import { HTTPException } from "hono/http-exception" +import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" +import { zod as effectZod } from "@/util/effect-zod" const ShellItem = z.object({ path: z.string(), @@ -18,7 +28,11 @@ const ShellItem = z.object({ }) const decodePtyID = Schema.decodeUnknownSync(PtyID) -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { +function validOrigin(c: Context, opts?: CorsOptions) { + return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) +} + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { return new Hono() .get( "/shells", @@ -175,6 +189,43 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return true }), ) + .post( + "/:ptyID/connect-token", + describeRoute({ + summary: "Create PTY WebSocket token", + description: "Create a short-lived token for opening a PTY WebSocket connection.", + operationId: "pty.connectToken", + responses: { + 200: { + description: "WebSocket connect token", + content: { + "application/json": { + schema: resolver(effectZod(PtyTicket.ConnectToken)), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) + throw new HTTPException(403) + const result = await runRequest( + "PtyRoutes.connectToken", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = c.req.valid("param").ptyID + if (!(yield* pty.get(id))) return + const tickets = yield* PtyTicket.Service + return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!result) throw new NotFoundError({ message: "Session not found" }) + return c.json(result) + }, + ) .get( "/:ptyID/connect", describeRoute({ @@ -190,7 +241,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }, - ...errors(404), + ...errors(403, 404), }, }), validator("param", z.object({ ptyID: PtyID.zod })), @@ -201,14 +252,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { } const id = decodePtyID(c.req.param("ptyID")) - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined if ( !(await runRequest( "PtyRoutes.connect", @@ -219,8 +262,29 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), )) ) { - throw new Error("Session not found") + throw new NotFoundError({ message: "Session not found" }) } + const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + if (!validOrigin(c, opts)) throw new HTTPException(403) + const valid = await runRequest( + "PtyRoutes.connect.ticket", + c, + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!valid) throw new HTTPException(403) + } + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Handler | undefined type Socket = { readyState: number diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6c7a6743d..3971214f3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -120,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), runtime, } } @@ -136,7 +136,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .route("/", ControlPlaneRoutes()) .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) .route("/", UIRoutes()), runtime, } diff --git a/packages/opencode/src/server/shared/pty-ticket.ts b/packages/opencode/src/server/shared/pty-ticket.ts new file mode 100644 index 000000000..0efd06e6a --- /dev/null +++ b/packages/opencode/src/server/shared/pty-ticket.ts @@ -0,0 +1,15 @@ +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/ + +// Auth middleware skips Basic Auth when this matches; the PTY connect handler +// is then responsible for validating the ticket. +export function isPtyConnectPath(pathname: string) { + return PTY_CONNECT_PATH.test(pathname) +} + +export function hasPtyConnectTicketURL(url: URL) { + return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts new file mode 100644 index 000000000..1b7d6005b --- /dev/null +++ b/packages/opencode/test/pty/ticket.test.ts @@ -0,0 +1,59 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { WorkspaceID } from "../../src/control-plane/schema" +import { PtyID } from "../../src/pty/schema" +import { PtyTicket } from "../../src/pty/ticket" +import { testEffect } from "../lib/effect" + +const it = testEffect(PtyTicket.layer) +const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) + +describe("PTY websocket tickets", () => { + it.live("consumes tickets once", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" } + const issued = yield* tickets.issue(scope) + + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true) + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different request", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) + + expect( + yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket }), + ).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) + }), + ) + + itExpiring.live("rejects tickets after the TTL elapses", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID }) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))) + + expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different workspace", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const workspaceID = WorkspaceID.ascending() + const issued = yield* tickets.issue({ ptyID, workspaceID }) + + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 3ee57dc10..af4c0a01c 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -31,8 +31,8 @@ afterEach(async () => { await resetDatabase() }) -async function startListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true +async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -40,19 +40,53 @@ async function startListener() { return Server.listen({ hostname: "127.0.0.1", port: 0 }) } +async function startNoAuthListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = auth.username + delete process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + function authorization() { return `Basic ${btoa(`${auth.username}:${auth.password}`)}` } -function socketURL(listener: Awaited>, id: string, dir: string) { +function socketURL(listener: Awaited>, id: string, dir: string, ticket?: string) { const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) url.protocol = "ws:" url.searchParams.set("directory", dir) url.searchParams.set("cursor", "-1") - url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`)) + if (ticket) url.searchParams.set("ticket", ticket) return url } +async function requestTicket( + listener: Awaited>, + id: string, + dir: string, + options?: { ticketHeader?: boolean; origin?: string }, +) { + const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + ...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }), + ...(options?.origin ? { origin: options.origin } : {}), + }, + }) + + return response +} + +async function connectTicket(listener: Awaited>, id: string, dir: string) { + const response = await requestTicket(listener, id, dir) + expect(response.status).toBe(200) + return (await response.json()) as { ticket: string; expires_in: number } +} + async function createCat(listener: Awaited>, dir: string) { const response = await fetch(new URL(PtyPaths.create, listener.url), { method: "POST", @@ -81,6 +115,28 @@ async function openSocket(url: URL) { return ws } +async function expectSocketRejected(url: URL, init?: { headers?: Record }) { + // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that. + const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record }) => WebSocket + const ws = new Ctor(url, init) + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener( + "open", + () => { + ws.close(1000) + reject(new Error("websocket opened")) + }, + { once: true }, + ) + ws.addEventListener("error", () => resolve(), { once: true }) + ws.addEventListener("close", () => resolve(), { once: true }) + }), + 5_000, + "timed out waiting for websocket rejection", + ) +} + function stop(listener: Awaited>, label: string) { return withTimeout(listener.stop(true), 10_000, label) } @@ -125,7 +181,9 @@ describe("HttpApi Server.listen", () => { ) const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const ticket = await connectTicket(listener, info.id, tmp.path) + expect(ticket.expires_in).toBeGreaterThan(0) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) const message = waitForMessage(ws, (message) => message.includes("ping-listen")) @@ -140,7 +198,8 @@ describe("HttpApi Server.listen", () => { const restarted = await startListener() try { const nextInfo = await createCat(restarted, tmp.path) - const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path)) + const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket)) const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) nextWs.send("ping-restarted\n") expect(await nextMessage).toContain("ping-restarted") @@ -152,4 +211,64 @@ describe("HttpApi Server.listen", () => { if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) } }) + + testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener("hono") + try { + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) + ws.send("ping-hono-ticket\n") + expect(await message).toContain("ping-hono-ticket") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) + } + }) + + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403) + expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403) + + await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket")) + + const reusable = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket)) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket)) + ws.close(1000) + + const other = await createCat(listener, tmp.path) + const scoped = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket)) + + const crossOrigin = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), { + headers: { origin: "https://evil.example" }, + }) + } finally { + await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined) + } + }) + + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 74c584462..e94132c2b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -99,6 +99,8 @@ import type { ProviderOauthCallbackResponses, PtyConnectErrors, PtyConnectResponses, + PtyConnectTokenErrors, + PtyConnectTokenResponses, PtyCreateErrors, PtyCreateResponses, PtyGetErrors, @@ -2345,6 +2347,38 @@ export class Pty extends HeyApiClient { }) } + /** + * Create PTY WebSocket token + * + * Create a short-lived ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + /** * Connect to PTY session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 79ef42d9e..86c5a762b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1563,6 +1563,10 @@ export type McpUnsupportedOAuthError = { error: string } +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + export type ProviderAuthMethod = { type: "oauth" | "api" label: string @@ -4671,6 +4675,43 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] +export type PtyConnectTokenData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect-token" +} + +export type PtyConnectTokenErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] + +export type PtyConnectTokenResponses = { + /** + * WebSocket connect token + */ + 200: { + ticket: string + expires_in: number + } +} + +export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] + export type QuestionListData = { body?: never path?: never @@ -6652,6 +6693,10 @@ export type PtyConnectData = { } export type PtyConnectErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden /** * Not found */ From 9f708e748af34cf63c0b1010c4a07ddab1b10ef6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 4 May 2026 02:57:18 +0000 Subject: [PATCH 144/174] chore: generate --- packages/opencode/src/pty/ticket.ts | 4 +- packages/opencode/test/pty/ticket.test.ts | 4 +- packages/sdk/openapi.json | 106 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index d40301cad..b5e5747c5 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -28,7 +28,9 @@ export interface Interface { export class Service extends Context.Service()("@opencode/PtyTicket") {} function matches(record: Scope, input: Scope) { - return record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID + return ( + record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID + ) } // Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts index 1b7d6005b..4886f250f 100644 --- a/packages/opencode/test/pty/ticket.test.ts +++ b/packages/opencode/test/pty/ticket.test.ts @@ -26,9 +26,7 @@ describe("PTY websocket tickets", () => { const ptyID = PtyID.ascending() const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) - expect( - yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket }), - ).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false) expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) }), ) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 21c547c85..6ff18b515 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3414,6 +3414,91 @@ ] } }, + "/pty/{ptyID}/connect-token": { + "post": { + "tags": ["pty"], + "operationId": "pty.connectToken", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "WebSocket connect token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false, + "description": "WebSocket connect token" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create a short-lived ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connectToken({\n ...\n})" + } + ] + } + }, "/question": { "get": { "tags": ["question"], @@ -8327,6 +8412,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -12752,6 +12847,17 @@ "required": ["error"], "additionalProperties": false }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, "ProviderAuthMethod": { "type": "object", "properties": { From a366128a93869ff5868223d3b4116764220b4266 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 23:24:57 +1000 Subject: [PATCH 145/174] fix(app): prevent terminal recovery loops (#25710) --- packages/app/src/components/terminal.tsx | 22 ++++--- packages/app/src/context/terminal.test.ts | 42 ++++++++++++- packages/app/src/context/terminal.tsx | 62 ++++++++++++++----- packages/app/src/pages/layout.tsx | 3 +- .../app/src/pages/session/terminal-panel.tsx | 20 +++++- 5 files changed, 122 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 7bcc02d62..d8ed63b8d 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -480,15 +480,21 @@ export const Terminal = (props: TerminalProps) => { }) const connectToken = async () => { - const result = await client.pty.connectToken( - { ptyID: id }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) + const result = await client.pty + .connectToken( + { ptyID: id }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if ((result.response.status === 404 || result.response.status === 405) && password) return + if (result.response.status === 404 || result.response.status === 405) return if (result.response.status === 403) throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") throw new Error(`PTY connect ticket failed with ${result.response.status}`) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 6e07e0312..623303fbf 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,6 +1,9 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" -let getWorkspaceTerminalCacheKey: (dir: string) => string +type ServerKey = Parameters[1] + +let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string +let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let migrateTerminalState: (value: unknown) => unknown @@ -17,6 +20,7 @@ beforeAll(async () => { })) const mod = await import("./terminal") getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey + getTerminalServerScope = mod.getTerminalServerScope getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys migrateTerminalState = mod.migrateTerminalState }) @@ -25,6 +29,42 @@ describe("getWorkspaceTerminalCacheKey", () => { test("uses workspace-only directory cache key", () => { expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") }) + + test("can include a server scope", () => { + expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__") + }) +}) + +describe("getTerminalServerScope", () => { + test("preserves local server keys", () => { + expect( + getTerminalServerScope( + { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }, + "sidecar" as ServerKey, + ), + ).toBeUndefined() + expect( + getTerminalServerScope( + { type: "http", http: { url: "http://localhost:4096" } }, + "http://localhost:4096" as ServerKey, + ), + ).toBeUndefined() + expect( + getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey), + ).toBeUndefined() + }) + + test("scopes non-local server keys", () => { + expect( + getTerminalServerScope( + { type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } }, + "wsl:Debian" as ServerKey, + ), + ).toBe("wsl:Debian" as ServerKey) + expect( + getTerminalServerScope({ type: "http", http: { url: "https://example.com" } }, "https://example.com" as ServerKey), + ).toBe("https://example.com" as ServerKey) + }) }) describe("getLegacyTerminalStorageKeys", () => { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 31d2d6e04..0dcebd567 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" +import { ServerConnection, useServer } from "./server" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" @@ -82,10 +83,26 @@ export function migrateTerminalState(value: unknown) { } } -export function getWorkspaceTerminalCacheKey(dir: string) { +export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) { + if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}` return `${dir}:${WORKSPACE_KEY}` } +export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) { + if (!conn) return + if (conn.type === "sidecar" && conn.variant === "base") return + if (conn.type === "http") { + try { + const url = new URL(conn.http.url) + if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]") + return + } catch { + return key + } + } + return key +} + export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { if (!legacySessionID) return [`${dir}/terminal.v1`] return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] @@ -110,15 +127,21 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { - const key = getWorkspaceTerminalCacheKey(dir) +export function clearWorkspaceTerminals( + dir: string, + sessionIDs?: string[], + platform?: Platform, + scope?: string, +) { + const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) entry?.value.clear() } - void removePersisted(Persist.workspace(dir, "terminal"), platform) + void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform) + if (scope) return const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { for (const key of getLegacyTerminalStorageKeys(dir, id)) { @@ -130,12 +153,17 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } -function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { - const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) +function createWorkspaceTerminalSession( + sdk: ReturnType, + dir: string, + legacySessionID?: string, + scope?: string, +) { + const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID) const [store, setStore, _, ready] = persisted( { - ...Persist.workspace(dir, "terminal", legacy), + ...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy), migrate: migrateTerminalState, }, createStore<{ @@ -357,8 +385,12 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() + const server = useServer() const params = useParams() const cache = new Map() + const scope = createMemo(() => { + return getTerminalServerScope(server.current, server.key) + }) caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -382,9 +414,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, legacySessionID?: string) => { + const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => { // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. - const key = getWorkspaceTerminalCacheKey(dir) + const key = getWorkspaceTerminalCacheKey(dir, serverScope) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -393,7 +425,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope), dispose, })) @@ -402,16 +434,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope())) createEffect( on( - () => ({ dir: params.dir, id: params.id }), + () => ({ dir: params.dir, id: params.id, scope: scope() }), (next, prev) => { if (!prev?.dir) return - if (next.dir === prev.dir && next.id === prev.id) return - if (next.dir === prev.dir && next.id) return - loadWorkspace(prev.dir, prev.id).trimAll() + if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return + if (next.dir === prev.dir && next.id && next.scope === prev.scope) return + loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() }, { defer: true }, ), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7e9e2d32a..a08372649 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" -import { clearWorkspaceTerminals } from "@/context/terminal" +import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { clearSessionPrefetchInflight, @@ -1557,6 +1557,7 @@ export default function Layout(props: ParentProps) { directory, sessions.map((s) => s.id), platform, + getTerminalServerScope(server.current, server.key), ) await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 2c2d9817f..d7868d917 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -37,6 +37,7 @@ export function TerminalPanel() { const [store, setStore] = createStore({ autoCreated: false, activeDraggable: undefined as string | undefined, + recovered: {} as Record, view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) @@ -145,6 +146,21 @@ export function TerminalPanel() { const all = terminal.all const ids = createMemo(() => all().map((pty) => pty.id)) + const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => { + if (store.recovered[key]) return + setStore("recovered", key, true) + void clone(id) + } + + const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => { + return String(pty.titleNumber || pty.title || pty.id) + } + + const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => { + setStore("recovered", key, false) + trim(id) + } + const handleTerminalDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -280,9 +296,9 @@ export function TerminalPanel() { ops.trim(id)} + onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onCleanup={ops.update} - onConnectError={() => ops.clone(id)} + onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} />
)} From 67047fa7669e17670ae40595cee648a1ad8f0ad8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 4 May 2026 13:26:08 +0000 Subject: [PATCH 146/174] chore: generate --- packages/app/src/context/terminal.test.ts | 5 ++++- packages/app/src/context/terminal.tsx | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 623303fbf..5bca1b4b7 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -62,7 +62,10 @@ describe("getTerminalServerScope", () => { ), ).toBe("wsl:Debian" as ServerKey) expect( - getTerminalServerScope({ type: "http", http: { url: "https://example.com" } }, "https://example.com" as ServerKey), + getTerminalServerScope( + { type: "http", http: { url: "https://example.com" } }, + "https://example.com" as ServerKey, + ), ).toBe("https://example.com" as ServerKey) }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0dcebd567..f6751c3f0 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -94,7 +94,12 @@ export function getTerminalServerScope(conn: ServerConnection.Any | undefined, k if (conn.type === "http") { try { const url = new URL(conn.http.url) - if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]") + if ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]" + ) return } catch { return key @@ -127,12 +132,7 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals( - dir: string, - sessionIDs?: string[], - platform?: Platform, - scope?: string, -) { +export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) { const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) From 1251a870cb384543c150c4a72fb101b55eec971b Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 4 May 2026 15:43:03 +0200 Subject: [PATCH 147/174] fix(opencode): strip transfer-encoding in UI proxy and allow public manifest assets (#25698) Co-authored-by: Kit Langton --- packages/app/src/components/terminal.tsx | 2 +- packages/opencode/src/server/middleware.ts | 2 ++ .../instance/httpapi/middleware/authorization.ts | 2 ++ packages/opencode/src/server/shared/public-ui.ts | 12 ++++++++++++ packages/opencode/src/server/shared/ui.ts | 1 + 5 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/server/shared/public-ui.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d8ed63b8d..6dae9de95 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -482,7 +482,7 @@ export const Terminal = (props: TerminalProps) => { const connectToken = async () => { const result = await client.pty .connectToken( - { ptyID: id }, + { ptyID: id, directory }, { throwOnError: false, headers: { "x-opencode-ticket": "1" }, diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 898acaf08..160d25879 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -13,6 +13,7 @@ import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" +import { isPublicUIPath } from "./shared/public-ui" const log = Log.create({ service: "server" }) @@ -45,6 +46,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPublicUIPath(c.req.method, c.req.path)) return next() if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 6c6d0cd1f..6f5648f30 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -3,6 +3,7 @@ import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" +import { isPublicUIPath } from "@/server/shared/public-ui" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -92,6 +93,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") + if (isPublicUIPath(request.method, url.pathname)) return yield* effect if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts new file mode 100644 index 000000000..fece09592 --- /dev/null +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -0,0 +1,12 @@ +// Static UI assets the browser fetches without app-managed credentials, e.g. +// the manifest link in . These bypass auth so the page can install/render +// the manifest icons even when a server password is configured. +export const PUBLIC_UI_PATHS = new Set([ + "/site.webmanifest", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", +]) + +export function isPublicUIPath(method: string, pathname: string) { + return method === "GET" && PUBLIC_UI_PATHS.has(pathname) +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c1558a1a4..40d8aa7af 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -33,6 +33,7 @@ function proxyResponseHeaders(headers: Record) { // transfer metadata makes browsers decode already-decoded assets again. result.delete("content-encoding") result.delete("content-length") + result.delete("transfer-encoding") return result } From 6e9f10ad3fbace5df1e3955404c8210528918349 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 09:54:19 -0400 Subject: [PATCH 148/174] test(server): regression reproducers for #25698 (#25714) --- .../test/server/httpapi-listen.test.ts | 40 ++++++++++++ .../opencode/test/server/httpapi-ui.test.ts | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index af4c0a01c..7258b32a9 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -257,6 +257,46 @@ describe("HttpApi Server.listen", () => { } }) + // Regression for #25698 (Ope): the app's SDK call to + // `client.pty.connectToken({ ptyID })` originally omitted `directory`, so + // the server resolved the PTY in its own cwd context — where the project + // PTY isn't registered — and returned 404. The fix is to always pass + // `directory` from the app side; this test locks in two contracts: + // 1. Mint without directory cannot find a PTY registered in another dir. + // 2. Mint with the project directory succeeds; the resulting ticket + // consumes cleanly when the WS upgrade carries the same directory. + testPty("PTY connect token requires matching directory across mint and connect", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + // Mint without directory — server uses its own cwd, can't find the PTY. + const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }) + expect(ambiguous.status).toBe(404) + + // Mint with the project directory — succeeds, ticket binds to that scope. + const scoped = await fetch( + new URL(`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, listener.url), + { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }, + ) + expect(scoped.status).toBe(200) + const mint = (await scoped.json()) as { ticket: string } + + // Same directory on the WS upgrade → consume succeeds. + const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket)) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined) + } + }) + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startNoAuthListener() diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index f364491ac..85162f6a9 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -184,6 +184,52 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was + // forwarded through the proxy while the proxy itself re-frames the body, + // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. + test("strips upstream transfer-encoding header from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { + fs, + client, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), + ), + ), + ), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(await response.text()).toBe("opencode") + }) + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined @@ -257,6 +303,25 @@ describe("HttpApi UI fallback", () => { expect(response.status).toBe(200) }) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and + // its icons via flows that don't carry app-managed credentials (the + // `` request is not under page-auth control), so the + // server returning 401 breaks PWA install. These specific public assets + // should bypass auth. + test("serves the PWA manifest without auth even when a server password is set", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }) + test("allows web UI preflight without auth", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true From 2c819f290fcb3db83ec12638749959cdc973b5ad Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 4 May 2026 13:55:28 +0000 Subject: [PATCH 149/174] chore: generate --- packages/opencode/test/server/httpapi-listen.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 7258b32a9..98ae30e8a 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -280,7 +280,10 @@ describe("HttpApi Server.listen", () => { // Mint with the project directory — succeeds, ticket binds to that scope. const scoped = await fetch( - new URL(`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, listener.url), + new URL( + `${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, + listener.url, + ), { method: "POST", headers: { authorization: authorization(), "x-opencode-ticket": "1" }, From c1f607d206e7d723d8093650559fffb8a144738e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 4 May 2026 09:58:21 -0500 Subject: [PATCH 150/174] fix: ensure anthropic sdk properly resolves when using azure (#25721) --- packages/opencode/src/provider/provider.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 939110e04..4013dcee3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -138,6 +138,14 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + function custom(dep: CustomDep): Record { return { anthropic: () => @@ -222,12 +230,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { resourceName: resource, @@ -247,12 +250,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, From 1aed6b1d8bfa5502cdc6997234a0d5be9933ec52 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 11:16:23 -0400 Subject: [PATCH 151/174] sync --- packages/console/app/src/routes/zen/util/handler.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 2f75668e6..8bab495b7 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -158,11 +158,13 @@ export async function handler( Object.entries(obj).flatMap(([k, v]) => { if (Array.isArray(v)) return [[k, v]] if (typeof v === "object") return [[k, replacer(v)]] - if (v === "$ip") return [[k, ip]] - if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] - if (v.startsWith("$header.")) { - const headerValue = input.request.headers.get(v.slice(8)) - return headerValue ? [[k, headerValue]] : [] + if (typeof v === "string") { + if (v === "$ip") return [[k, ip]] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v.startsWith("$header.")) { + const headerValue = input.request.headers.get(v.slice(8)) + return headerValue ? [[k, headerValue]] : [] + } } return [[k, v]] }), From b70e2700ef38c166730d8af26ac97e36baa660c1 Mon Sep 17 00:00:00 2001 From: Colby Gilbert Date: Mon, 4 May 2026 08:27:03 -0700 Subject: [PATCH 152/174] chore(docs): rename firmware provider to frogbot (#25453) --- packages/web/src/content/docs/ar/providers.mdx | 8 ++++---- packages/web/src/content/docs/bs/providers.mdx | 8 ++++---- packages/web/src/content/docs/da/providers.mdx | 8 ++++---- packages/web/src/content/docs/de/providers.mdx | 8 ++++---- packages/web/src/content/docs/es/providers.mdx | 8 ++++---- packages/web/src/content/docs/fr/providers.mdx | 6 +++--- packages/web/src/content/docs/it/providers.mdx | 8 ++++---- packages/web/src/content/docs/ja/providers.mdx | 4 ++-- packages/web/src/content/docs/ko/providers.mdx | 8 ++++---- packages/web/src/content/docs/nb/providers.mdx | 8 ++++---- packages/web/src/content/docs/pl/providers.mdx | 8 ++++---- packages/web/src/content/docs/providers.mdx | 8 ++++---- packages/web/src/content/docs/pt-br/providers.mdx | 8 ++++---- packages/web/src/content/docs/ru/providers.mdx | 8 ++++---- packages/web/src/content/docs/th/providers.mdx | 8 ++++---- packages/web/src/content/docs/tr/providers.mdx | 8 ++++---- packages/web/src/content/docs/zh-cn/providers.mdx | 8 ++++---- packages/web/src/content/docs/zh-tw/providers.mdx | 8 ++++---- 18 files changed, 69 insertions(+), 69 deletions(-) diff --git a/packages/web/src/content/docs/ar/providers.mdx b/packages/web/src/content/docs/ar/providers.mdx index 07a19b8ad..c4812fe5d 100644 --- a/packages/web/src/content/docs/ar/providers.mdx +++ b/packages/web/src/content/docs/ar/providers.mdx @@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص --- -### Firmware +### FrogBot -1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. +1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. -2. شغّل الأمر `/connect` وابحث عن **Firmware**. +2. شغّل الأمر `/connect` وابحث عن **FrogBot**. ```txt /connect ``` -3. أدخل مفتاح API الخاص بـ Firmware. +3. أدخل مفتاح API الخاص بـ FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx index 4087db8cd..f6e54fc6a 100644 --- a/packages/web/src/content/docs/bs/providers.mdx +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju. --- -### Firmware +### FrogBot -1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ. +1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ. -2. Pokrenite naredbu `/connect` i potražite **Firmware**. +2. Pokrenite naredbu `/connect` i potražite **FrogBot**. ```txt /connect ``` -3. Unesite svoj Firmware API ključ. +3. Unesite svoj FrogBot API ključ. ```txt ┌ API key diff --git a/packages/web/src/content/docs/da/providers.mdx b/packages/web/src/content/docs/da/providers.mdx index 8817d2319..9b04d6be8 100644 --- a/packages/web/src/content/docs/da/providers.mdx +++ b/packages/web/src/content/docs/da/providers.mdx @@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle. +1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle. -2. Kør kommandoen `/connect` og søg efter **Firmware**. +2. Kør kommandoen `/connect` og søg efter **FrogBot**. ```txt /connect ``` -3. Indtast firmware API-nøglen. +3. Indtast frogbot API-nøglen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/de/providers.mdx b/packages/web/src/content/docs/de/providers.mdx index 87f78c9d2..929814693 100644 --- a/packages/web/src/content/docs/de/providers.mdx +++ b/packages/web/src/content/docs/de/providers.mdx @@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf --- -### Firmware +### FrogBot -1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. +1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. -2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**. +2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**. ```txt /connect ``` -3. Geben Sie Ihren Firmware API-Schlüssel ein. +3. Geben Sie Ihren FrogBot API-Schlüssel ein. ```txt ┌ API key diff --git a/packages/web/src/content/docs/es/providers.mdx b/packages/web/src/content/docs/es/providers.mdx index b44ce9ee9..11489609b 100644 --- a/packages/web/src/content/docs/es/providers.mdx +++ b/packages/web/src/content/docs/es/providers.mdx @@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API. +1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API. -2. Ejecute el comando `/connect` y busque **Firmware**. +2. Ejecute el comando `/connect` y busque **FrogBot**. ```txt /connect ``` -3. Ingrese su clave de firmware API. +3. Ingrese su clave de frogbot API. ```txt ┌ API key diff --git a/packages/web/src/content/docs/fr/providers.mdx b/packages/web/src/content/docs/fr/providers.mdx index 6a902ab02..90bdb1fbc 100644 --- a/packages/web/src/content/docs/fr/providers.mdx +++ b/packages/web/src/content/docs/fr/providers.mdx @@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode. --- -### Firmware +### FrogBot -1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API. +1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API. -2. Exécutez la commande `/connect` et recherchez **Firmware**. +2. Exécutez la commande `/connect` et recherchez **FrogBot**. ```txt /connect diff --git a/packages/web/src/content/docs/it/providers.mdx b/packages/web/src/content/docs/it/providers.mdx index 96da8c4df..f2d195d72 100644 --- a/packages/web/src/content/docs/it/providers.mdx +++ b/packages/web/src/content/docs/it/providers.mdx @@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API. +1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API. -2. Esegui il comando `/connect` e cerca **Firmware**. +2. Esegui il comando `/connect` e cerca **FrogBot**. ```txt /connect ``` -3. Inserisci la tua chiave API di Firmware. +3. Inserisci la tua chiave API di FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ja/providers.mdx b/packages/web/src/content/docs/ja/providers.mdx index 8017d0882..c969c6d4a 100644 --- a/packages/web/src/content/docs/ja/providers.mdx +++ b/packages/web/src/content/docs/ja/providers.mdx @@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。 --- -### Firmware +### FrogBot -1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 +1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 2. `/connect` コマンドを実行し、**ファームウェア**を検索します。 diff --git a/packages/web/src/content/docs/ko/providers.mdx b/packages/web/src/content/docs/ko/providers.mdx index 6ca3afccc..87278bef2 100644 --- a/packages/web/src/content/docs/ko/providers.mdx +++ b/packages/web/src/content/docs/ko/providers.mdx @@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세 --- -### Firmware +### FrogBot -1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. +1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. -2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오. +2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오. ```txt /connect ``` -3. Firmware API 키를 입력하십시오. +3. FrogBot API 키를 입력하십시오. ```txt ┌ API key diff --git a/packages/web/src/content/docs/nb/providers.mdx b/packages/web/src/content/docs/nb/providers.mdx index 1fe8812e6..bf276918a 100644 --- a/packages/web/src/content/docs/nb/providers.mdx +++ b/packages/web/src/content/docs/nb/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel. +1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel. -2. Kjør kommandoen `/connect` og søk etter **Firmware**. +2. Kjør kommandoen `/connect` og søk etter **FrogBot**. ```txt /connect ``` -3. Skriv inn firmware API nøkkelen. +3. Skriv inn frogbot API nøkkelen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pl/providers.mdx b/packages/web/src/content/docs/pl/providers.mdx index deadd07d6..0e722d5fd 100644 --- a/packages/web/src/content/docs/pl/providers.mdx +++ b/packages/web/src/content/docs/pl/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API. +1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API. -2. Uruchom polecenie `/connect` i wyszukaj **Firmware**. +2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**. ```txt /connect ``` -3. Wprowadź klucz API Firmware. +3. Wprowadź klucz API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7c395022c..8410c549f 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire --- -### Firmware +### FrogBot -1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. +1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key. -2. Run the `/connect` command and search for **Firmware**. +2. Run the `/connect` command and search for **FrogBot**. ```txt /connect ``` -3. Enter your Firmware API key. +3. Enter your FrogBot API key. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pt-br/providers.mdx b/packages/web/src/content/docs/pt-br/providers.mdx index 50f841cf3..174bc1679 100644 --- a/packages/web/src/content/docs/pt-br/providers.mdx +++ b/packages/web/src/content/docs/pt-br/providers.mdx @@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API. +1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API. -2. Execute o comando `/connect` e procure por **Firmware**. +2. Execute o comando `/connect` e procure por **FrogBot**. ```txt /connect ``` -3. Insira sua chave da API Firmware. +3. Insira sua chave da API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx index f5868ceaa..39aae9e09 100644 --- a/packages/web/src/content/docs/ru/providers.mdx +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к --- -### Firmware +### FrogBot -1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API. +1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API. -2. Запустите команду `/connect` и найдите **Firmware**. +2. Запустите команду `/connect` и найдите **FrogBot**. ```txt /connect ``` -3. Введите ключ API Firmware. +3. Введите ключ API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/th/providers.mdx b/packages/web/src/content/docs/th/providers.mdx index 818f39213..07008de21 100644 --- a/packages/web/src/content/docs/th/providers.mdx +++ b/packages/web/src/content/docs/th/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม --- -### Firmware +### FrogBot -1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API +1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API -2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware** +2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot** ```txt /connect ``` -3. ป้อนคีย์ Firmware API ของคุณ +3. ป้อนคีย์ FrogBot API ของคุณ ```txt ┌ API key diff --git a/packages/web/src/content/docs/tr/providers.mdx b/packages/web/src/content/docs/tr/providers.mdx index 527c20e15..8c6ef23fe 100644 --- a/packages/web/src/content/docs/tr/providers.mdx +++ b/packages/web/src/content/docs/tr/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model --- -### Firmware +### FrogBot -1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. +1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. -2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın. +2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın. ```txt /connect ``` -3. Firmware API anahtarınızı girin. +3. FrogBot API anahtarınızı girin. ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 80dfe1e93..9c0a5d8a3 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。 +1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。 -2. 执行 `/connect` 命令并搜索 **Firmware**。 +2. 执行 `/connect` 命令并搜索 **FrogBot**。 ```txt /connect ``` -3. 输入你的 Firmware API 密钥。 +3. 输入你的 FrogBot API 密钥。 ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-tw/providers.mdx b/packages/web/src/content/docs/zh-tw/providers.mdx index c87417095..d4e55ed71 100644 --- a/packages/web/src/content/docs/zh-tw/providers.mdx +++ b/packages/web/src/content/docs/zh-tw/providers.mdx @@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。 +1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。 -2. 執行 `/connect` 指令並搜尋 **Firmware**。 +2. 執行 `/connect` 指令並搜尋 **FrogBot**。 ```txt /connect ``` -3. 輸入您的 Firmware API 金鑰。 +3. 輸入您的 FrogBot API 金鑰。 ```txt ┌ API key From 25dc6f09bca2f9b90b7594e0a696f451f22f1254 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 12:01:13 -0400 Subject: [PATCH 153/174] fix(worktree): fork workspace worktree boot (#25723) --- packages/opencode/src/worktree/index.ts | 11 +- .../opencode/test/project/worktree.test.ts | 4 +- .../server/worktree-endpoint-repro.test.ts | 148 ++++++++++++++++++ 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/server/worktree-endpoint-repro.test.ts diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 43453b561..f4e4d2721 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -291,16 +291,15 @@ export const layer: Layer.Layer< const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { yield* setup(info) - yield* boot(info, startCommand) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) + yield* createFromInfo(info, input?.startCommand) return info }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index a89fda6ca..b191a3c95 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -178,12 +178,13 @@ describe("Worktree", () => { }) describe("createFromInfo", () => { - wintest("creates and bootstraps git worktree", () => + wintest("creates git worktree and boots asynchronously", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo("from-info-test") + const ready = waitReady() yield* svc.createFromInfo(info) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) @@ -191,6 +192,7 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) + yield* Effect.promise(() => ready) yield* svc.remove({ directory: info.directory }) }), { git: true }, diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 000000000..768a261a0 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,148 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + } + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +type TestServer = ReturnType + +function serverScoped() { + return Effect.acquireRelease( + Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })), + (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), + ) +} + +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + ) +} + +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) +} + +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = (yield* Effect.promise(() => current.json())) as { id: string } + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) + expect(updated.status).toBe(200) + }) +} + +describe("worktree endpoint reproduction", () => { + it.instance( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create", + 8_000, + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create with project start command", + 6_000, + ) + + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) +}) From fb07c2070cba705bf0e9766a5a7ce6a3452797fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 13:06:29 -0400 Subject: [PATCH 154/174] fix(server): provide fresh ConfigProvider per HttpApi listener (#25726) --- packages/opencode/src/server/server.ts | 8 ++++- .../test/server/httpapi-listen.test.ts | 34 ++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3971214f3..ca8659995 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,7 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" -import { Context, Effect, Exit, Layer, Scope } from "effect" +import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import * as HttpApiServer from "#httpapi-server" @@ -259,6 +259,12 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec }).pipe( Layer.provideMerge(WebSocketTracker.layer), Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + // Install a fresh `ConfigProvider` per listener so `Config.string(...)` + // reads reflect the current `process.env`. Effect's default + // `ConfigProvider` snapshots `process.env` on first read and caches the + // result on a module-singleton Reference; without overriding it here, + // every later `Server.listen()` keeps observing that initial snapshot. + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), ) const start = async (port: number) => { diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 98ae30e8a..b49fbe98b 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -40,8 +40,8 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -300,18 +300,20 @@ describe("HttpApi Server.listen", () => { } }) - testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener() - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) - ws.send("ping-no-auth\n") - expect(await message).toContain("ping-no-auth") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } }) From 007b57f0788b129a993228b5f1c340c640e94ea9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 13:11:33 -0400 Subject: [PATCH 155/174] test(agent): skip InstanceBootstrap in plugin-agent regression test (#25737) --- .../agent/plugin-agent-regression.test.ts | 73 +++++-------------- .../test/fixture/agent-plugin.constants.ts | 6 ++ .../opencode/test/fixture/agent-plugin.ts | 12 +++ 3 files changed, 36 insertions(+), 55 deletions(-) create mode 100644 packages/opencode/test/fixture/agent-plugin.constants.ts create mode 100644 packages/opencode/test/fixture/agent-plugin.ts diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 3ac923c43..dff972d10 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,65 +1,28 @@ import { expect } from "bun:test" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" -import { InstanceStore } from "../../src/project/instance-store" -import { tmpdirScoped } from "../fixture/fixture" +import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" -const pluginAgent = { - name: "plugin_added", - description: "Added by a plugin via the config hook", - mode: "subagent", -} as const +// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin +// up — those services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) -it.live("plugin-registered agents appear in Agent.list", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const pluginFile = path.join(dir, "plugin.ts") - - yield* Effect.promise(async () => { - await Promise.all([ - Bun.write( - pluginFile, - [ - "export default async () => ({", - " config: async (cfg) => {", - " cfg.agent = cfg.agent ?? {}", - ` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`, - ` description: ${JSON.stringify(pluginAgent.description)},`, - ` mode: ${JSON.stringify(pluginAgent.mode)},`, - " }", - " },", - "})", - "", - ].join("\n"), - ), - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ), - ]) - }) - - const agents = yield* InstanceStore.Service.use((store) => - Effect.gen(function* () { - const ctx = yield* store.load({ directory: dir }) - yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore)) - return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx)) - }), - ) - const added = agents.find((agent) => agent.name === pluginAgent.name) - - expect(added?.description).toBe(pluginAgent.description) - expect(added?.mode).toBe(pluginAgent.mode) - }), +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.Service.use((svc) => svc.list()) + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, ) diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts new file mode 100644 index 000000000..9dd5f3910 --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -0,0 +1,6 @@ +// Separate file because every export in `agent-plugin.ts` must be a function. +export const PLUGIN_AGENT = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts new file mode 100644 index 000000000..892f63646 --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -0,0 +1,12 @@ +// Every export in this file must be a plugin function — `getLegacyPlugins` +// (src/plugin/index.ts) throws on anything else. Test constants live in +// `agent-plugin.constants.ts`. +export default async () => ({ + config: async (cfg: { agent?: Record }) => { + cfg.agent = cfg.agent ?? {} + cfg.agent["plugin_added"] = { + description: "Added by a plugin via the config hook", + mode: "subagent", + } + }, +}) From 5720883d5d8b2e823cb7a6c81350973f7b7f0b79 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 15:51:29 -0400 Subject: [PATCH 156/174] sync --- packages/console/app/src/routes/zen/util/handler.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 8bab495b7..7f36246ee 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -919,6 +919,13 @@ export async function handler( "tokens.cache_read": cacheReadTokens, "tokens.cache_write_5m": cacheWrite5mTokens, "tokens.cache_write_1h": cacheWrite1hTokens, + "cost.input.microcents": centsToMicroCents(inputCost), + "cost.output.microcents": centsToMicroCents(outputCost), + "cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined, + "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, + "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, + "cost.total.microcents": centsToMicroCents(totalCostInCent), + // deprecated - remove after May 20, 2026 "cost.input": Math.round(inputCost), "cost.output": Math.round(outputCost), "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, From d431a0e4b47fbf586ad3d23390b3c5e36911fb37 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 4 May 2026 17:29:00 -0500 Subject: [PATCH 157/174] fix: ensure effect server middleware properly parses errors (#25717) --- .../instance/httpapi/middleware/error.ts | 58 +++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 2 + 2 files changed, 60 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts new file mode 100644 index 000000000..6f3c33a64 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -0,0 +1,58 @@ +import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { NotFoundError } from "@/storage/storage" +import { iife } from "@/util/iife" +import { NamedError } from "@opencode-ai/core/util/error" +import * as Log from "@opencode-ai/core/util/log" +import { Cause, Effect } from "effect" +import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" + +const log = Log.create({ service: "server" }) + +// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. +export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + effect.pipe( + Effect.catchCause((cause) => { + const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { + if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false + if (HttpServerError.isHttpServerError(reason.defect)) return false + if (HttpServerRespondable.isRespondable(reason.defect)) return false + return true + }) + if (!defect) return Effect.failCause(cause) + + const error = defect.defect + log.error("failed", { error, cause: Cause.pretty(cause) }) + + if (error instanceof NamedError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(error.toObject(), { + status: iife(() => { + if (error instanceof NotFoundError) return 404 + if (error instanceof Provider.ModelNotFoundError) return 400 + if (error.name === "ProviderAuthValidationFailed") return 400 + if (error.name.startsWith("Worktree")) return 400 + return 500 + }), + }), + ) + } + if (error instanceof Session.BusyError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), { + status: 400, + }), + ) + } + + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ + message: error instanceof Error && error.stack ? error.stack : String(error), + }).toObject(), + { status: 500 }, + ), + ) + }), + ), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a3754c2e1..ef966036a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -73,6 +73,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { errorLayer } from "./middleware/error" export const context = Context.makeUnsafe(new Map()) @@ -144,6 +145,7 @@ const uiRoute = HttpRouter.use((router) => export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ + errorLayer, cors(corsOptions), runtime, Account.defaultLayer, From 4b65b1e0532b6f6cab101f2aba0c26a318fb36d8 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 4 May 2026 23:26:02 +0000 Subject: [PATCH 158/174] sync release versions for v1.14.34 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 25068f3d9..3cf2d9ce9 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.33", + "version": "1.14.34", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.33", + "version": "1.14.34", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 5f4d79e44..ac9bfd590 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.34", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index cb5b4bf9a..85e855c55 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index bfb7f7db8..d5157a372 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.34", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index f6072bd37..0bb126541 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.34", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d73a23e08..b685bb1aa 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 4ba8d1401..5f3371b98 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.33", + "version": "1.14.34", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 7a26516a9..8a6fcf578 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1327423e5..2ec5cd059 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 16e142b9c..fe9de8584 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.34", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d9e71219f..17b51a625 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.33" +version = "1.14.34" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 1eb790cce..f9044078b 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.34", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index adb4a7db1..08d317151 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.33", + "version": "1.14.34", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d6bfdd844..a8c17f19f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index de69e685c..b3e12fc25 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 04b996aca..8a2ba85b0 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index cd210c4d6..0c8621623 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index c346fe5e7..8187602b0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.33", + "version": "1.14.34", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 67617771f..43f07930e 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.33", + "version": "1.14.34", "publisher": "sst-dev", "repository": { "type": "git", From 6a5e329427458619749f9c83e5374b249f87322c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 10:34:06 +1000 Subject: [PATCH 159/174] fix(vcs): preserve batched patch boundaries (#25787) --- packages/opencode/src/project/vcs.ts | 4 ++- packages/opencode/test/project/vcs.test.ts | 27 ++++++++++++++ .../ui/src/components/session-diff.test.ts | 16 +++++++++ packages/ui/src/components/session-diff.ts | 35 ++++++++++--------- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 28ac143ee..8b3bedbf5 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -85,7 +85,9 @@ const fileFromPatchChunk = (chunk: string) => { } const splitGitPatch = (patch: Git.Patch) => { - const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) if (!patch.truncated) return chunks return chunks.slice(0, -1) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 53ff547ac..06da6ccba 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { parsePatch } from "diff" import { Effect } from "effect" import fs from "fs/promises" import path from "path" @@ -288,6 +289,32 @@ describe("Vcs diff", () => { }) }) + test( + "diff('git') keeps carriage returns inside patch hunks", + async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, + 20_000, + ) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index 463a72977..edaa15b84 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -34,4 +34,20 @@ describe("session diff", () => { expect(text(view, "deletions")).toBe("one\n") expect(text(view, "additions")).toBe("two\n") }) + + test("ignores malformed persisted patches", () => { + const diff = { + file: "a.ts", + patch: + "diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) }) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index a5fbdbc5c..2da8c61a7 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -27,26 +27,29 @@ const cache = new Map() function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { - const [patch] = parsePatch(diff.patch) + try { + const [patch] = parsePatch(diff.patch) + const beforeLines = [] + const afterLines = [] - const beforeLines = [] - const afterLines = [] - - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) - } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) - } else { - // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("-")) { + beforeLines.push(line.slice(1)) + } else if (line.startsWith("+")) { + afterLines.push(line.slice(1)) + } else { + // context line (starts with ' ') + beforeLines.push(line.slice(1)) + afterLines.push(line.slice(1)) + } } } - } - return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + } catch { + return { before: "", after: "", patch: diff.patch } + } } return { before: "before" in diff && typeof diff.before === "string" ? diff.before : "", From f14784d5319c5fc4f6e298819d8112ee6aa5342c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 00:35:18 +0000 Subject: [PATCH 160/174] chore: generate --- packages/opencode/test/project/vcs.test.ts | 42 ++++++++++------------ 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 06da6ccba..82eacfb6d 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -289,31 +289,27 @@ describe("Vcs diff", () => { }) }) - test( - "diff('git') keeps carriage returns inside patch hunks", - async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + test("diff('git') keeps carriage returns inside patch hunks", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - const file = diff.find((item) => item.file === "file.txt") + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") - expect(file?.patch).toContain(" same\rdiff --git inside") - expect(file?.patch).toContain("-delete") - expect(() => parsePatch(file?.patch ?? "")).not.toThrow() - }) - }, - 20_000, - ) + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, 20_000) test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) From 6b852774e18c2bdabfd8754d3e1c506c7db76bff Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 5 May 2026 01:01:47 +0000 Subject: [PATCH 161/174] sync release versions for v1.14.35 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 3cf2d9ce9..07415dd79 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.34", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.34", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index ac9bfd590..cde4986d1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.34", + "version": "1.14.35", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 85e855c55..fb2e71d22 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index d5157a372..7301b23e5 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.34", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0bb126541..06fb0affd 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.34", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index b685bb1aa..674fc55fd 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 5f3371b98..e90ab7628 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.34", + "version": "1.14.35", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 8a6fcf578..ba981e637 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2ec5cd059..e60320300 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index fe9de8584..dce25e204 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.34", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 17b51a625..775f826d4 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.34" +version = "1.14.35" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index f9044078b..1039677b5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.34", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 08d317151..bafa532de 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.34", + "version": "1.14.35", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a8c17f19f..661201d2d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index b3e12fc25..bef0fee14 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 8a2ba85b0..448df6640 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0c8621623..dcf52499d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 8187602b0..a243a4707 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.34", + "version": "1.14.35", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 43f07930e..22d8adc54 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.34", + "version": "1.14.35", "publisher": "sst-dev", "repository": { "type": "git", From ca2411d332f4f7a98f44aa974a1b9d992d27dc8f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 11:05:53 +1000 Subject: [PATCH 162/174] Run UI unit tests in CI (#25792) --- packages/ui/package.json | 2 ++ .../ui/src/components/session-diff.test.ts | 15 ++++++++ packages/ui/src/components/session-diff.ts | 34 +++++++++++++++---- turbo.json | 9 +++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index dcf52499d..1bc70c15a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,8 @@ }, "scripts": { "typecheck": "tsgo --noEmit", + "test": "bun test src", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index edaa15b84..172fe8d6c 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -19,6 +19,21 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("one\nthree\n") }) + test("keeps missing final newlines from unified patches", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(text(view, "deletions")).toBe("one\ntwo") + expect(text(view, "additions")).toBe("one\nthree") + }) + test("converts legacy content into a patch", () => { const diff = { file: "a.ts", diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 2da8c61a7..bd6bed88d 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -29,24 +29,44 @@ function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { try { const [patch] = parsePatch(diff.patch) - const beforeLines = [] - const afterLines = [] + const beforeLines: Array<{ text: string; newline: boolean }> = [] + const afterLines: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined for (const hunk of patch.hunks) { for (const line of hunk.lines) { + if (line.startsWith("\\")) { + if (previous === "-" || previous === " ") { + const before = beforeLines.at(-1) + if (before) before.newline = false + } + if (previous === "+" || previous === " ") { + const after = afterLines.at(-1) + if (after) after.newline = false + } + continue + } + if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) + beforeLines.push({ text: line.slice(1), newline: true }) + previous = "-" } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) + afterLines.push({ text: line.slice(1), newline: true }) + previous = "+" } else { // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + beforeLines.push({ text: line.slice(1), newline: true }) + afterLines.push({ text: line.slice(1), newline: true }) + previous = " " } } } - return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + return { + before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + patch: diff.patch, + } } catch { return { before: "", after: "", patch: diff.patch } } diff --git a/turbo.json b/turbo.json index 28c2fa2de..0183fabca 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,15 @@ "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] + }, + "@opencode-ai/ui#test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "@opencode-ai/ui#test:ci": { + "dependsOn": ["^build"], + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] } } } From 84afd2bef8d114b41a6cb9b38074ea5cb4c6d4f9 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 5 May 2026 09:19:13 +0800 Subject: [PATCH 163/174] update: normalize download asset names to match new naming convention (#25796) --- .../app/src/routes/download/[channel]/[platform].ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index b486acb99..4ae8e2465 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start" import type { DownloadPlatform } from "../types" const prodAssetNames: Record = { - "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", - "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", - "windows-x64-nsis": "opencode-desktop-windows-x64.exe", + "darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg", + "darwin-x64-dmg": "opencode-desktop-mac-x64.dmg", + "windows-x64-nsis": "opencode-desktop-win-x64.exe", "linux-x64-deb": "opencode-desktop-linux-amd64.deb", - "linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage", + "linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage", "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", } satisfies Record From 22a4a9df8b98f998f526df983393df885388d569 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 4 May 2026 21:28:38 -0400 Subject: [PATCH 164/174] feat(core): session warping (#25768) --- .../migration.sql | 1 + .../snapshot.json | 1429 +++++++++++++++++ packages/opencode/script/httpapi-exercise.ts | 4 +- .../cmd/tui/component/dialog-session-list.tsx | 120 +- .../tui/component/dialog-workspace-create.tsx | 288 ++-- .../cli/cmd/tui/component/prompt/index.tsx | 357 ++-- .../cli/cmd/tui/component/workspace-label.tsx | 19 + .../cli/cmd/tui/routes/session/sidebar.tsx | 29 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 53 +- .../opencode/src/control-plane/workspace.ts | 281 ++-- .../src/server/routes/control/workspace.ts | 74 +- .../routes/instance/httpapi/groups/sync.ts | 16 + .../instance/httpapi/groups/workspace.ts | 29 +- .../routes/instance/httpapi/handlers/sync.ts | 24 +- .../instance/httpapi/handlers/workspace.ts | 15 +- .../src/server/routes/instance/index.ts | 2 +- .../src/server/routes/instance/sync.ts | 47 + packages/opencode/src/sync/event.sql.ts | 1 + packages/opencode/src/sync/index.ts | 34 +- .../test/control-plane/workspace.test.ts | 506 ++---- .../test/server/httpapi-workspace.test.ts | 19 +- packages/opencode/test/sync/index.test.ts | 73 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 266 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 631 +++++++- packages/sdk/openapi.json | 809 +++++++++- 25 files changed, 4032 insertions(+), 1095 deletions(-) create mode 100644 packages/opencode/migration/20260504145000_add_sync_owner/migration.sql create mode 100644 packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json create mode 100644 packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql new file mode 100644 index 000000000..3bdf2b85e --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `event_sequence` ADD `owner_id` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json new file mode 100644 index 000000000..4f6ebe00c --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -0,0 +1,1429 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "27114226-085b-421a-9a40-29b88747e29a", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 9755cf401..771e1e417 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -776,9 +776,9 @@ const scenarios: Scenario[] = [ })) .status(200), http - .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .post("/experimental/workspace/warp", "experimental.workspace.warp") .at((ctx) => ({ - path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + path: "/experimental/workspace/warp", headers: ctx.headers(), body: {}, })) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 04c6b9945..09d952ef8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" @@ -10,15 +10,13 @@ import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" - -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +import { WorkspaceLabel } from "./workspace-label" export function DialogSessionList() { const dialog = useDialog() @@ -44,26 +42,39 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => searchResults() ?? sync.data.session) - function createWorkspace() { - dialog.replace(() => ( - - openWorkspaceSession({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - }) - } - /> - )) - } - function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) + const warp = async (selection: WorkspaceSelection) => { + const workspaceID = await (async () => { + if (selection.type === "none") return null + if (selection.type === "existing") return selection.workspaceID + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + const workspace = result?.data + if (!workspace) { + toast.show({ + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + await project.workspace.sync() + return workspace.id + })() + if (workspaceID === undefined) return + await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID, + sessionID: session.id, + done: list, + }) + } dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID, - sessionID: session.id, - done: list, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warp(selection) + }, + }) return false }} /> @@ -124,30 +128,17 @@ export function DialogSessionList() { .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - let workspaceStatus: WorkspaceStatus | null = null - if (x.workspaceID) { - workspaceStatus = project.workspace.status(x.workspaceID) || "error" - } - - let footer = "" + let footer: JSX.Element | string = "" if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (x.workspaceID) { - let desc = "unknown" - if (workspace) { - desc = `${workspace.type}: ${workspace.name}` - } - - footer = ( - <> - {desc}{" "} - - ● - - + footer = workspace ? ( + + ) : ( + ) } } else { @@ -250,15 +241,6 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, - { - keybind: Keybind.parse("ctrl+w")[0], - title: "new workspace", - side: "right", - disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - onTrigger: () => { - createWorkspace() - }, - }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 0aa61c313..e2af0d63e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -1,11 +1,9 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" -import { setTimeout as sleep } from "node:timers/promises" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" @@ -16,184 +14,212 @@ type Adapter = { description: string } -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} +export type WorkspaceSelection = + | { + type: "none" + } + | { + type: "new" + workspaceType: string + workspaceName: string + } + | { + type: "existing" + workspaceID: string + workspaceType: string + workspaceName: string + } -export async function openWorkspaceSession(input: { - dialog: ReturnType - route: ReturnType +type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } +type ExistingWorkspaceSelectValue = { workspace: Workspace } + +async function loadWorkspaceAdapters(input: { sdk: ReturnType sync: ReturnType toast: ReturnType - workspaceID: string }) { - const client = scoped(input.sdk, input.sync, input.workspaceID) - - while (true) { - const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - - input.route.navigate({ - type: "session", - sessionID: result.data.id, - }) - input.dialog.clear() - return - } + const dir = input.sync.path.directory || input.sdk.directory + const url = new URL("/experimental/workspace/adapter", input.sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await input.sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (res) return res + input.toast.show({ + message: "Failed to load workspace adapters", + variant: "error", + }) } -export async function restoreWorkspaceSession(input: { +export async function openWorkspaceSelect(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { + input.dialog.clear() + const adapters = await loadWorkspaceAdapters(input) + if (!adapters) return + input.dialog.replace(() => ) +} + +export async function warpWorkspaceSession(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType project: ReturnType toast: ReturnType - workspaceID: string + workspaceID: string | null sessionID: string done?: () => void -}) { +}): Promise { const result = await input.sdk.client.experimental.workspace - .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) + .warp({ + id: input.workspaceID, + sessionID: input.sessionID, + }) .catch(() => undefined) if (!result?.data) { input.toast.show({ - message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, + message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) - return + return false } input.project.workspace.set(input.workspaceID) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]) + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) - input.toast.show({ - message: "Session restored into the new workspace", - variant: "success", - }) input.done?.() - if (input.done) return + if (input.done) return true input.dialog.clear() + return true } -export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { +export function DialogWorkspaceSelect(props: { + adapters?: Adapter[] + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const dialog = useDialog() - const sync = useSync() const project = useProject() + const sync = useSync() const sdk = useSDK() const toast = useToast() - const [creating, setCreating] = createSignal() - const [adapters, setAdapters] = createSignal() + const [adapters, setAdapters] = createSignal(props.adapters) onMount(() => { dialog.setSize("medium") void (async () => { - const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adapter", sdk.url) - if (dir) url.searchParams.set("directory", dir) - const res = await sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (!res) { - toast.show({ - message: "Failed to load workspace adapters", - variant: "error", - }) - return - } + if (adapters()) return + const res = await loadWorkspaceAdapters({ sdk, sync, toast }) + if (!res) return setAdapters(res) })() }) - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } + const options = createMemo[]>(() => { const list = adapters() - if (!list) { - return [ - { - title: "Loading workspaces...", - value: "loading" as const, - description: "Fetching available workspace adapters", + if (!list) return [] + const recent = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .flatMap((session) => (session.workspaceID ? [session.workspaceID] : [])) + .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index) + .slice(0, 3) + .flatMap((workspaceID) => { + const workspace = project.workspace.get(workspaceID) + return workspace ? [workspace] : [] + }) + return [ + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "New workspace", + })), + { + title: "None", + value: { type: "none" as const }, + description: "Use the local project", + category: "Choose workspace", + }, + ...recent.map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { + type: "existing" as const, + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, }, - ] - } - return list.map((item) => ({ - title: item.name, - value: item.type, - description: item.description, - })) + category: "Choose workspace", + })), + { + title: "View all workspaces", + value: { type: "existing-list" as const }, + description: "Choose from all workspaces", + category: "Choose workspace", + }, + ] }) - const create = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => { - toast.show({ - message: "Creating workspace failed", - variant: "error", - }) - return undefined - }) - - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, - variant: "error", - }) - return - } - - await project.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - + if (!adapters()) return null return ( - + title="Warp" skipFilter={true} + renderFilter={false} options={options()} onSelect={(option) => { - if (option.value === "creating" || option.value === "loading") return - void create(option.value) + if (!option.value) return + if (option.value.type === "none") { + void props.onSelect(option.value) + return + } + if (option.value.type === "new") { + void props.onSelect(option.value) + return + } + if (option.value.type === "existing") { + void props.onSelect(option.value) + return + } + + dialog.replace(() => ) + }} + /> + ) +} + +function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise | void }) { + const project = useProject() + + const options = createMemo[]>(() => + project.workspace + .list() + .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { workspace }, + })), + ) + + return ( + + title="Existing Workspace" + options={options()} + onSelect={(option) => { + void props.onSelect({ + type: "existing", + workspaceID: option.value.workspace.id, + workspaceType: option.value.workspace.type, + workspaceName: option.value.workspace.name, + }) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a6ba797f3..74332c77b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" @@ -41,9 +42,11 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" -import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" +import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" +import { Flag } from "@opencode-ai/core/flag/flag" +import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" export type PromptProps = { sessionID?: string @@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) { const [editorContextHover, setEditorContextHover] = createSignal(false) let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() + const [workspaceSelection, setWorkspaceSelection] = createSignal() + const [workspaceCreating, setWorkspaceCreating] = createSignal(false) + const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) + const [warpNotice, setWarpNotice] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + function selectWorkspace(selection: WorkspaceSelection | undefined) { + setWorkspaceSelection(selection) + } + + function setCreatingWorkspace(creating: boolean) { + setWorkspaceCreating(creating) + } + + function showWarpNotice(name: string) { + setWarpNotice(`Warped to ${name}`) + setTimeout(() => setWarpNotice(undefined), 4000) + } + + async function createWorkspace(selection: Extract) { + setCreatingWorkspace(true) + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + if (result == undefined || result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + message: "Creating workspace failed", + variant: "error", + }) + return + } + + await project.workspace.sync() + const workspace = result.data + selectWorkspace({ + type: "existing", + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, + }) + setCreatingWorkspace(false) + return workspace + } + + async function warpSession(selection: WorkspaceSelection) { + if (!props.sessionID) { + selectWorkspace(selection) + dialog.clear() + if (selection.type === "new") void createWorkspace(selection) + return + } + selectWorkspace(selection) + dialog.clear() + + const workspace = + selection.type === "none" + ? { id: null, name: "local project" } + : selection.type === "existing" + ? { id: selection.workspaceID, name: selection.workspaceName } + : await createWorkspace(selection) + if (!workspace) return + + const warped = await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID: workspace.id, + sessionID: props.sessionID, + }) + if (warped) showWarpNotice(workspace.name) + } + + createEffect(() => { + if (!workspaceCreating()) { + setWorkspaceCreatingDots(3) + return + } + const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) + function promptModelWarning() { toast.show({ variant: "warning", @@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { + if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) @@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "Warp", + description: "Change the workspace for the session", + value: "workspace.set", + category: "Session", + enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slash: { + name: "warp", + }, + onSelect: (dialog) => { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + }, + }, ] }) @@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) { ]) async function submit() { + setWarpNotice(undefined) + // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. @@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false + if (workspaceCreating()) return false if (autocomplete?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() @@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID: nextWorkspaceID, - sessionID: props.sessionID!, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false }} /> )) @@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { + const workspace = workspaceSelection() + const workspaceID = iife(() => { + if (!workspace) return undefined + if (workspace.type === "none") return undefined + if (workspace.type === "existing") return workspace.workspaceID + return undefined + }) + const res = await sdk.client.session.create({ workspace: props.workspaceID, agent: agent.name, @@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) + const workspaceLabel = createMemo< + | { type: "new"; workspaceType: string } + | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } + | undefined + >(() => { + const selected = workspaceSelection() + if (!selected) return + if (selected.type === "none") return + if (props.sessionID && !workspaceCreating()) return + if (selected.type === "new") { + return { + type: "new", + workspaceType: selected.workspaceType, + } + } + return { + type: "existing", + workspaceType: selected.workspaceType, + workspaceName: selected.workspaceName, + status: selected.type === "existing" ? "connected" : undefined, + } + }) + const spinnerDef = createMemo(() => { const agent = local.agent.current() const color = agent ? local.agent.color(agent.name) : theme.border @@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) { }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={theme.text} + cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} /> @@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) { /> - }> - - - - [⋯]}> - - - - - {(() => { - const retry = createMemo(() => { - const s = status() - if (s.type !== "retry") return - return s - }) - const message = createMemo(() => { - const r = retry() - if (!r) return - if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) - return "gemini is way too hot right now" - if (r.message.length > 80) return r.message.slice(0, 80) + "..." - return r.message - }) - const isTruncated = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.length > 120 - }) - const [seconds, setSeconds] = createSignal(0) - onMount(() => { - const timer = setInterval(() => { - const next = retry()?.next - if (next) setSeconds(Math.round((next - Date.now()) / 1000)) - }, 1000) - - onCleanup(() => { - clearInterval(timer) + + + + + + [⋯]}> + + + + + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) + const message = createMemo(() => { + const r = retry() + if (!r) return + if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) + return "gemini is way too hot right now" + if (r.message.length > 80) return r.message.slice(0, 80) + "..." + return r.message + }) + const isTruncated = createMemo(() => { + const r = retry() + if (!r) return false + return r.message.length > 120 + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + const handleMessageClick = () => { + const r = retry() + if (!r) return + if (isTruncated()) { + void DialogAlert.show(dialog, "Retry Error", r.message) + } } - } - const retryText = () => { - const r = retry() - if (!r) return "" - const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" - const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const duration = formatDuration(seconds()) + const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } - return ( - - - {retryText()} - - - ) - })()} + return ( + + + {retryText()} + + + ) + })()} + + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - - - + + + {(notice) => ( + + {notice()} + + )} + + + {(workspace) => ( + + + + + + {(() => { + const item = workspace() + if (item.type === "new") { + if (workspaceCreating()) + return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}` + return ( + <> + Workspace (new {item.workspaceType}) + + ) + } + return ( + <> + Workspace {item.workspaceName} + + ) + })()} + + + )} + + {props.hint ?? } + diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx new file mode 100644 index 000000000..efdbf7158 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx @@ -0,0 +1,19 @@ +import { useTheme } from "@tui/context/theme" + +export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + +export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { + const { theme } = useTheme() + const color = () => { + if (props.status === "connected") return theme.success + if (props.status === "error") return theme.error + return theme.textMuted + } + + return ( + <> + {props.icon ? : undefined} + {props.name} ({props.type}) + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 7adc4c1db..0f9214092 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" +import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const project = useProject() @@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) - const workspaceStatus = () => { + const workspace = () => { const workspaceID = session()?.workspaceID - if (!workspaceID) return "error" - return project.workspace.status(workspaceID) ?? "error" - } - const workspaceLabel = () => { - const workspaceID = session()?.workspaceID - if (!workspaceID) return "unknown" - const info = project.workspace.get(workspaceID) - if (!info) return "unknown" - return `${info.type}: ${info.name}` + if (!workspaceID) return + return project.workspace.get(workspaceID) } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {" "} - {workspaceLabel()} + } + > + {(item) => ( + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 4d68c4430..ef7d4bd3b 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,6 +23,7 @@ export interface DialogSelectProps { onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean + renderFilter?: boolean keybind?: { keybind?: Keybind.Info title: string @@ -81,7 +82,7 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable const filtered = createMemo(() => { - if (props.skipFilter) return props.options.filter((x) => x.disabled !== true) + if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() const options = pipe( props.options, @@ -250,30 +251,32 @@ export function DialogSelect(props: DialogSelectProps) { esc - - { - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} - ref={(r) => { - input = r - input.traits = { status: "FILTER" } - setTimeout(() => { - if (!input) return - if (input.isDestroyed) return - input.focus() - }, 1) - }} - placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} - /> - + + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + input.traits = { status: "FILTER" } + setTimeout(() => { + if (!input) return + if (input.isDestroyed) return + input.focus() + }, 1) + }} + placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} + /> + + 0} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 485cb2e92..fe651fe3e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,10 +1,11 @@ -import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" +import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -20,6 +21,7 @@ import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" @@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({ }) export type ConnectionStatus = Schema.Schema.Type -const Restore = Schema.Struct({ - workspaceID: WorkspaceID, - sessionID: SessionID, - total: NonNegativeInt, - step: NonNegativeInt, -}) - export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -58,7 +53,6 @@ export const Event = { message: Schema.String, }), ), - Restore: BusEvent.define("workspace.restore", Restore), Status: BusEvent.define("workspace.status", ConnectionStatus), } @@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectID, - extra: Info.fields.extra, + extra: Schema.optional(Info.fields.extra), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const SessionRestoreInput = Schema.Struct({ - workspaceID: WorkspaceID, +export const SessionWarpInput = Schema.Struct({ + workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) -export type SessionRestoreInput = Schema.Schema.Type +export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { message: Schema.String, @@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( - "WorkspaceSessionRestoreHttpError", +export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionWarpHttpError", { message: Schema.String, workspaceID: WorkspaceID, @@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass( }) {} type CreateError = Auth.AuthError -type SessionRestoreError = +type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError - | SessionRestoreHttpError + | SessionWarpHttpError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError> + readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect @@ -169,6 +163,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const auth = yield* Auth.Service const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service const connections = new Map() @@ -461,7 +456,7 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null }), + adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), ) const info: Info = { @@ -518,29 +513,93 @@ export const layer = Layer.effect( return info }) - const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { return yield* Effect.gen(function* () { - log.info("session restore requested", { + log.info("session warp requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, }) - const space = yield* get(input.workspaceID) + const current = yield* db((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) { + const adapter = getAdapter(previous.projectID, previous.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + + if (target.type === "remote") { + yield* syncHistory(previous, target.url, target.headers).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("session warp final source sync failed", { + workspaceID: previous.id, + sessionID: input.sessionID, + error: errorData(error), + }) + }), + ), + ) + } else { + yield* prompt.cancel(input.sessionID) + } + + // "claim" this session so any future events coming from + // the old workspace are ignored + SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + } + } + + if (input.workspaceID === null) { + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }), + ) + + log.info("session warp complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + target: "local", + }) + return + } + + const workspaceID = input.workspaceID + const space = yield* get(workspaceID) if (!space) return yield* new WorkspaceNotFoundError({ - message: `Workspace not found: ${input.workspaceID}`, - workspaceID: input.workspaceID, + message: `Workspace not found: ${workspaceID}`, + workspaceID, }) const adapter = getAdapter(space.projectID, space.type) const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { + if (target.type === "local") { + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + log.info("session warp complete", { workspaceID: input.workspaceID, - }, - }) + sessionID: input.sessionID, + target: target.directory, + }) + return + } const rows = yield* db((db) => db @@ -562,130 +621,95 @@ export const layer = Layer.effect( sessionID: input.sessionID, }) - const size = 10 - // TODO: look into using effect APIs to process this in chunks - const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => - rows.slice(i * size, (i + 1) * size), - ) - const total = sets.length + const batches = Iterable.chunksOf(rows, 10) + const total = Iterable.size(batches) - log.info("session restore prepared", { + log.info("session warp prepared", { workspaceID: input.workspaceID, sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + target: String(route(target.url, "/sync/replay")), events: rows.length, batches: total, first: rows[0]?.seq, last: rows.at(-1)?.seq, }) - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, - }), - ) - - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - }) - - if (target.type === "local") { - yield* sync.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const res = yield* http.execute( - HttpClientRequest.post(url, { - headers: new Headers(target.headers), - body: HttpBody.jsonUnsafe({ - directory: space.directory ?? "", - events, + yield* Effect.forEach( + batches, + (events, i) => + Effect.gen(function* () { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), }), - }), - ) + ) - if (res.status < 200 || res.status >= 300) { - const body = yield* res.text - log.error("session restore batch failed", { + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + + log.info("session warp batch posted", { workspaceID: input.workspaceID, sessionID: input.sessionID, step: i + 1, total, - status: res.status, - body, + status: response.status, }) - return yield* new SessionRestoreHttpError({ - message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: res.status, - body, - }) - } - - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) - } - - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, }), - ) + { discard: true }, + ) + + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/steal"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp steal failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) } - log.info("session restore complete", { + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, batches: total, }) - - return { total } }).pipe( Effect.tapError((err) => Effect.sync(() => - log.error("session restore failed", { + log.error("session warp failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, error: errorData(err), @@ -814,7 +838,7 @@ export const layer = Layer.effect( return Service.of({ create, - sessionRestore, + sessionWarp, list, get, remove, @@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 21a7810ce..788aef317 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { errorData } from "@/util/error" - -const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() @@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() => }, ) .post( - "/:id/session-restore", + "/warp", describeRoute({ - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - operationId: "experimental.workspace.sessionRestore", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", + operationId: "experimental.workspace.warp", responses: { - 200: { - description: "Session replay started", - content: { - "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), - }, - }, + 204: { + description: "Session warped", }, ...errors(400), }, }), - validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), - validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), + validator( + "json", + z.object({ + id: zodObject(Workspace.Info).shape.id.nullable(), + sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + }), + ), async (c) => { - const { id } = c.req.valid("param") - const body = c.req.valid("json") as Omit - log.info("session restore route requested", { - workspaceID: id, - sessionID: body.sessionID, - directory: Instance.directory, - }) - try { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.sessionRestore({ - workspaceID: id, - ...body, - }), - ), - ) - log.info("session restore route complete", { - workspaceID: id, - sessionID: body.sessionID, - total: result.total, - }) - return c.json(result) - } catch (err) { - log.error("session restore route failed", { - workspaceID: id, - sessionID: body.sessionID, - error: errorData(err), - }) - throw err - } + const body = c.req.valid("json") + await AppRuntime.runPromise( + Workspace.Service.use((workspace) => + workspace.sessionWarp({ + workspaceID: body.id, + sessionID: body.sessionID, + }), + ), + ) + return c.body(null, 204) }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 58d30b4c7..442e65655 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@/util/schema" +import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({ export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }) +export const SessionPayload = Schema.Struct({ + sessionID: SessionID, +}) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: Schema.String, @@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({ export const SyncPaths = { start: `${root}/start`, replay: `${root}/replay`, + steal: `${root}/steal`, history: `${root}/history`, } as const @@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync") description: "Validate and replay a complete sync event history.", }), ), + HttpApiEndpoint.post("steal", SyncPaths.steal, { + payload: SessionPayload, + success: described(SessionPayload, "Session stolen into workspace"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.steal", + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + }), + ), HttpApiEndpoint.post("history", SyncPaths.history, { payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 08e9e044b..f197ab976 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,21 +1,17 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" -import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct({ - ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), - extra: Schema.optional(Workspace.CreateInput.fields.extra), -}) -export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) -export const SessionRestoreResponse = Schema.Struct({ - total: NonNegativeInt, +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const WarpPayload = Schema.Struct({ + id: Schema.NullOr(Workspace.Info.fields.id), + sessionID: Workspace.SessionWarpInput.fields.sessionID, }) export const WorkspacePaths = { @@ -23,7 +19,7 @@ export const WorkspacePaths = { list: root, status: `${root}/status`, remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, + warp: `${root}/warp`, } as const export const WorkspaceApi = HttpApi.make("workspace") @@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Remove an existing workspace.", }), ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: described(SessionRestoreResponse, "Session replay started"), + HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + payload: WarpPayload, + success: described(HttpApiSchema.NoContent, "Session warped"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", + identifier: "experimental.workspace.warp", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", }), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index f4a2f315c..152d22f98 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,5 +1,6 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" import { EventTable } from "@/sync/event.sql" @@ -12,7 +13,7 @@ import { or } from "drizzle-orm" import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { HistoryPayload, ReplayPayload } from "../groups/sync" +import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "server.sync" }) @@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl return { sessionID: source } }) + const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) { + const workspaceID = yield* InstanceState.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + yield* sync.run(Session.Event.Updated, { + sessionID: ctx.payload.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: ctx.payload.sessionID, + workspaceID, + }) + + return { sessionID: ctx.payload.sessionID } + }) + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return Database.use((db) => @@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl ) }) - return handlers.handle("start", start).handle("replay", replay).handle("history", history) + return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 570f355e5..b415943a6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, SessionRestorePayload } from "../groups/workspace" +import { CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace.remove(ctx.params.id) }) - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - return yield* workspace - .sessionRestore({ - workspaceID: ctx.params.id, + const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + yield* workspace + .sessionWarp({ + workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) @@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("create", create) .handle("status", status) .handle("remove", remove) - .handle("sessionRestore", sessionRestore) + .handle("warp", warp) }), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 89b5641e5..71662dea9 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b7bf413d4..9894d8c8e 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" +import { Session } from "@/session/session" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { SessionID } from "@/session/schema" const ReplayEvent = z.object({ id: z.string(), @@ -24,6 +27,9 @@ const ReplayEvent = z.object({ type: z.string(), data: z.record(z.string(), z.unknown()), }) +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) const log = Log.create({ service: "server.sync" }) @@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() => }) }, ) + .post( + "/steal", + describeRoute({ + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + operationId: "sync.steal", + responses: { + 200: { + description: "Session stolen into workspace", + content: { + "application/json": { + schema: resolver(SessionPayload), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + const workspaceID = WorkspaceContext.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + SyncEvent.run(Session.Event.Updated, { + sessionID: body.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: body.sessionID, + workspaceID, + }) + + return c.json({ + sessionID: body.sessionID, + }) + }, + ) .post( "/history", describeRoute({ diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/opencode/src/sync/event.sql.ts index b51b5a5df..547a80f0f 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/opencode/src/sync/event.sql.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), seq: integer().notNull(), + owner_id: text(), }) export const EventTable = sqliteTable("event", { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 2654767e9..62b30ccf9 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -59,8 +59,11 @@ export interface Interface { data: Event["data"], options?: { publish?: boolean }, ) => Effect.Effect - readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect - readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect + readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { publish: boolean; ownerID?: string }, + ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect } @@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)( const row = Database.use((db) => db - .select({ seq: EventSequenceTable.seq }) + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) .get(), @@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)( const latest = row?.seq ?? -1 if (event.seq <= latest) return + if (row?.ownerID && row.ownerID !== options?.ownerID) { + return + } + const expected = latest + 1 if (event.seq !== expected) { throw new Error( @@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context }) + process(def, event, { publish, context, ownerID: options?.ownerID }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -263,7 +270,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext }, + options: { publish: boolean; context?: PublishContext; ownerID?: string }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -274,8 +281,6 @@ function process( throw new Error(`Projector not found for event: ${def.type}`) } - // idempotent: need to ignore any events already logged - Database.transaction((tx) => { projector(tx, event.data, event) @@ -284,6 +289,7 @@ function process( .values({ aggregate_id: event.aggregateID, seq: event.seq, + owner_id: options?.ownerID, }) .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, @@ -332,11 +338,11 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean }) { +export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replay(event, options)) } -export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { +export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replayAll(events, options)) } @@ -348,6 +354,16 @@ export function remove(aggregateID: string) { return runtime.runSync((sync) => sync.remove(aggregateID)) } +export function claim(aggregateID: string, ownerID: string) { + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ) +} + export function payloads() { return registry .entries() diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 10a05e3b1..84f567006 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { asc, eq } from "drizzle-orm" +import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" @@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" -import { SessionID, MessageID, PartID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" -import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -111,8 +110,8 @@ async function withInstance(fn: (dir: string) => T | Promise) { const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) +const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) const listWorkspaces = (project: Parameters[0]) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) @@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) { )?.seq } -function eventRows(sessionID: SessionID) { +function sessionSequenceOwner(sessionID: SessionID) { return Database.use((db) => db - .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), + )?.ownerID } function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -function replaceSessionEvents(sessionID: SessionID, count: number) { - Database.use((db) => { - db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run() - if (count === 0) return - - db.insert(EventSequenceTable) - .values({ aggregate_id: sessionID, seq: count - 1 }) - .run() - db.insert(EventTable) - .values( - Array.from({ length: count }, (_, i) => ({ - id: `evt_${unique(`manual-${i}`)}`, - aggregate_id: sessionID, - seq: i, - type: sessionUpdatedType(), - data: { sessionID, info: { title: `manual ${i}` } }, - })), - ) - .run() - }) -} - describe("workspace-old schemas and exports", () => { test("keeps the historical event type names", () => { expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore") expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") }) @@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => { expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) - - test("validates session restore input", () => { - const input = { - workspaceID: WorkspaceID.ascending("wrk_schema_restore"), - sessionID: SessionID.descending("ses_schema_restore"), - } - - expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow() - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow() - }) }) describe("workspace-old CRUD", () => { @@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => { expect(await getWorkspace(info.id)).toBeUndefined() }) }) + + test("sessionWarp moves a session into a local workspace and claims ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-prev-local") + const targetType = unique("warp-target-local") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBe(target.id) + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }) + }) + + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-detach-local") + const previous = workspaceInfo(Instance.project.id, previousType) + insertWorkspace(previous) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id) + }) + }) + + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { + const calls: FetchCall[] = [] + let historySessionID: SessionID | undefined + let historyNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/warp-source/sync/history") { + return yield* HttpServerResponse.json([ + { + id: `evt_${unique("warp-source-history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from source history" } }, + }, + ]) + } + if (call.url.pathname === "/warp-target/sync/replay") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/sync/steal") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const previousType = unique("warp-remote-source") + const targetType = unique("warp-remote-target") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" }) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) + registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + const session = yield* sessionSvc.create({}) + attachSessionToWorkspace(session.id, previous.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + + expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ + "POST /warp-source/sync/history", + "POST /warp-target/sync/replay", + "POST /warp-target/sync/steal", + ]) + expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) + expect(calls[1].json).toMatchObject({ + directory: "remote-target-dir", + events: [ + { + aggregateID: session.id, + seq: 0, + type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version), + }, + { + aggregateID: session.id, + seq: historyNextSeq, + type: sessionUpdatedType(), + }, + ], + }) + expect(calls[2].json).toEqual({ sessionID: session.id }) + expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }), + { git: true }, + ) + }) + }) }) describe("workspace-old sync state", () => { @@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => { }) }, 7000) }) - -describe("workspace-old sessionRestore", () => { - test("throws when the workspace is missing", async () => { - await withInstance(async () => { - await expect( - restoreWorkspaceSession({ - workspaceID: WorkspaceID.ascending("wrk_restore_missing"), - sessionID: SessionID.descending("ses_restore_missing_workspace"), - }), - ).rejects.toThrow("Workspace not found: wrk_restore_missing") - }) - }) - - test("throws when switching a missing session fails", async () => { - await withInstance(async (dir) => { - const type = unique("restore-missing-session") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - - await expect( - restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), - ).rejects.toThrow("NotFoundError") - await removeWorkspace(info.id) - }) - }) - - it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - const call = { - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - } - if (call.url.pathname === "/restore/sync/replay") { - replay.push(call) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - } - return HttpServerResponse.text("unexpected", { status: 500 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter( - Instance.project.id, - type, - remoteAdapter(`${url}/restore/?ignored=1#hash`, { - directory: dir, - headers: { authorization: "Bearer restore" }, - }).adapter, - ) - const session = yield* sessionSvc.create({ title: "restore remote" }) - replaceSessionEvents(session.id, 24) - - const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }) - - expect(result).toEqual({ total: 3 }) - expect(replay).toHaveLength(3) - expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([ - "/restore/sync/replay", - "/restore/sync/replay", - "/restore/sync/replay", - ]) - expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true) - expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true) - expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5]) - expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir]) - expect( - replay.flatMap((call) => - (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq), - ), - ).toEqual(Array.from({ length: 25 }, (_, i) => i)) - expect( - (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1), - ).toMatchObject({ - seq: 24, - type: sessionUpdatedType(), - data: { sessionID: session.id, info: { workspaceID: info.id } }, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("remote restore sends an empty directory string when the workspace directory is null", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - () => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-null-dir") - const info = workspaceInfo(Instance.project.id, type, { directory: null }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) - const session = yield* sessionSvc.create({ title: "null dir" }) - replaceSessionEvents(session.id, 0) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - expect((replay[0].json as { directory: string }).directory).toBe("") - expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) - - it.live("remote restore failures include status and body and do not emit completed batch progress", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.text("replay failed", { status: 503 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote-fail") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "restore fail" }) - replaceSessionEvents(session.id, 11) - - const error = yield* Effect.flip( - workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }), - ) - expect((error as Error).message).toContain( - `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`, - ) - - expect(replay).toHaveLength(1) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("local restore replays batches and emits progress", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-local") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - const session = yield* sessionSvc.create({ title: "restore local" }) - replaceSessionEvents(session.id, 20) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 3, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ), - ) - - it.live("session restore includes real message and part events in sequence order", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-real-events") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "real events" }) - for (let i = 0; i < 3; i++) { - const msg = yield* sessionSvc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* sessionSvc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: msg.id, - type: "text", - text: `message ${i}`, - }) - } - const before = eventRows(session.id) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - - const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events - expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1]) - expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type)) - expect(posted.at(-1)?.type).toBe(sessionUpdatedType()) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 193c2971a..21bf4120c 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -168,22 +168,19 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), + body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) - const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { + const warped = yield* request(WorkspacePaths.warp, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({ - total: expect.any(Number), + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), }) + expect(warped.status).toBe(204) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) @@ -212,7 +209,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -257,7 +253,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -272,7 +267,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + body: JSON.stringify({ type: "local-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -327,7 +322,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -394,7 +389,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-session-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const session = yield* Session.Service.use((svc) => svc.create()).pipe( diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 234c5246e..0986b3904 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" -import { EventTable } from "../../src/sync/event.sql" +import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" @@ -252,5 +252,76 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claims unowned event sequence on replay with ownerID", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "owned" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) + + it.live( + "ignores replay from a different owner after sequence is claimed", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + yield* SyncEvent.use.replay( + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "ignored" }, + }, + { publish: false, ownerID: "owner-2" }, + ) + + const events = Database.use((db) => db.select().from(EventTable).all()) + const sequence = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(events).toHaveLength(1) + expect(events[0].id).toBe("evt_1") + expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e94132c2b..ab191b056 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4,58 +4,84 @@ import { client } from "./client.gen.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import type { AgentPartInput, + AppAgentsErrors, AppAgentsResponses, AppLogErrors, AppLogResponses, + AppSkillsErrors, AppSkillsResponses, Auth as Auth3, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + CommandListErrors, CommandListResponses, Config as Config3, + ConfigGetErrors, ConfigGetResponses, + ConfigProvidersErrors, ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, + EventSubscribeErrors, EventSubscribeResponses, EventTuiCommandExecute2, EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, + ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, + ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, + ExperimentalResourceListErrors, ExperimentalResourceListResponses, + ExperimentalSessionListErrors, ExperimentalSessionListResponses, + ExperimentalWorkspaceAdapterListErrors, ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceListErrors, ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ExperimentalWorkspaceSessionRestoreResponses, + ExperimentalWorkspaceStatusErrors, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceWarpErrors, + ExperimentalWorkspaceWarpResponses, + FileListErrors, FileListResponses, FilePartInput, FilePartSource, + FileReadErrors, FileReadResponses, + FileStatusErrors, FileStatusResponses, + FindFilesErrors, FindFilesResponses, + FindSymbolsErrors, FindSymbolsResponses, + FindTextErrors, FindTextResponses, + FormatterStatusErrors, FormatterStatusResponses, + GlobalConfigGetErrors, GlobalConfigGetResponses, GlobalConfigUpdateErrors, GlobalConfigUpdateResponses, + GlobalDisposeErrors, GlobalDisposeResponses, + GlobalEventErrors, GlobalEventResponses, + GlobalHealthErrors, GlobalHealthResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, + InstanceDisposeErrors, InstanceDisposeResponses, + LspStatusErrors, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -67,10 +93,13 @@ import type { McpAuthRemoveResponses, McpAuthStartErrors, McpAuthStartResponses, + McpConnectErrors, McpConnectResponses, + McpDisconnectErrors, McpDisconnectResponses, McpLocalConfig, McpRemoteConfig, + McpStatusErrors, McpStatusResponses, OutputFormat, Part as Part2, @@ -78,20 +107,27 @@ import type { PartDeleteResponses, PartUpdateErrors, PartUpdateResponses, + PathGetErrors, PathGetResponses, + PermissionListErrors, PermissionListResponses, PermissionReplyErrors, PermissionReplyResponses, PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, + ProjectCurrentErrors, ProjectCurrentResponses, + ProjectInitGitErrors, ProjectInitGitResponses, + ProjectListErrors, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, Prompt, + ProviderAuthErrors, ProviderAuthResponses, + ProviderListErrors, ProviderListResponses, ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeResponses, @@ -105,13 +141,16 @@ import type { PtyCreateResponses, PtyGetErrors, PtyGetResponses, + PtyListErrors, PtyListResponses, PtyRemoveErrors, PtyRemoveResponses, + PtyShellsErrors, PtyShellsResponses, PtyUpdateErrors, PtyUpdateResponses, QuestionAnswer, + QuestionListErrors, QuestionListResponses, QuestionRejectErrors, QuestionRejectResponses, @@ -130,12 +169,15 @@ import type { SessionDeleteMessageResponses, SessionDeleteResponses, SessionDelivery, + SessionDiffErrors, SessionDiffResponses, + SessionForkErrors, SessionForkResponses, SessionGetErrors, SessionGetResponses, SessionInitErrors, SessionInitResponses, + SessionListErrors, SessionListResponses, SessionMessageErrors, SessionMessageResponses, @@ -168,7 +210,10 @@ import type { SyncHistoryListResponses, SyncReplayErrors, SyncReplayResponses, + SyncStartErrors, SyncStartResponses, + SyncStealErrors, + SyncStealResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -176,34 +221,50 @@ import type { ToolListResponses, TuiAppendPromptErrors, TuiAppendPromptResponses, + TuiClearPromptErrors, TuiClearPromptResponses, + TuiControlNextErrors, TuiControlNextResponses, + TuiControlResponseErrors, TuiControlResponseResponses, TuiExecuteCommandErrors, TuiExecuteCommandResponses, + TuiOpenHelpErrors, TuiOpenHelpResponses, + TuiOpenModelsErrors, TuiOpenModelsResponses, + TuiOpenSessionsErrors, TuiOpenSessionsResponses, + TuiOpenThemesErrors, TuiOpenThemesResponses, TuiPublishErrors, TuiPublishResponses, TuiSelectSessionErrors, TuiSelectSessionResponses, + TuiShowToastErrors, TuiShowToastResponses, + TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V2SessionCompactErrors, V2SessionCompactResponses, + V2SessionContextErrors, V2SessionContextResponses, V2SessionListErrors, V2SessionListResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, + V2SessionPromptErrors, V2SessionPromptResponses, + V2SessionWaitErrors, V2SessionWaitResponses, + VcsDiffErrors, VcsDiffResponses, + VcsGetErrors, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, + WorktreeListErrors, WorktreeListResponses, WorktreeRemoveErrors, WorktreeRemoveInput, @@ -381,7 +442,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/agent", ...options, ...params, @@ -411,7 +472,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/skill", ...options, ...params, @@ -426,7 +487,7 @@ export class Config extends HeyApiClient { * Retrieve the current global OpenCode configuration settings and preferences. */ public get(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/config", ...options, }) @@ -464,7 +525,7 @@ export class Global extends HeyApiClient { * Get health information about the OpenCode server. */ public health(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/health", ...options, }) @@ -476,7 +537,7 @@ export class Global extends HeyApiClient { * Subscribe to global events from the OpenCode system using server-sent events. */ public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/global/event", ...options, }) @@ -488,7 +549,7 @@ export class Global extends HeyApiClient { * Clean up and dispose all OpenCode instances, releasing all resources. */ public dispose(options?: Options) { - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/global/dispose", ...options, }) @@ -548,7 +609,7 @@ export class Event extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/event", ...options, ...params, @@ -580,7 +641,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config", ...options, ...params, @@ -647,7 +708,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config/providers", ...options, ...params, @@ -679,7 +740,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleGetResponses, + ExperimentalConsoleGetErrors, + ThrowOnError + >({ url: "/experimental/console", ...options, ...params, @@ -709,7 +774,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleListOrgsResponses, + ExperimentalConsoleListOrgsErrors, + ThrowOnError + >({ url: "/experimental/console/orgs", ...options, ...params, @@ -792,7 +861,11 @@ export class Session extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalSessionListResponses, + ExperimentalSessionListErrors, + ThrowOnError + >({ url: "/experimental/session", ...options, ...params, @@ -824,7 +897,11 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalResourceListResponses, + ExperimentalResourceListErrors, + ThrowOnError + >({ url: "/experimental/resource", ...options, ...params, @@ -856,7 +933,11 @@ export class Adapter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalWorkspaceAdapterListResponses, + ExperimentalWorkspaceAdapterListErrors, + ThrowOnError + >({ url: "/experimental/workspace/adapter", ...options, ...params, @@ -888,7 +969,11 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalWorkspaceListResponses, + ExperimentalWorkspaceListErrors, + ThrowOnError + >({ url: "/experimental/workspace", ...options, ...params, @@ -965,7 +1050,11 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceStatusErrors, + ThrowOnError + >({ url: "/experimental/workspace/status", ...options, ...params, @@ -1009,15 +1098,15 @@ export class Workspace extends HeyApiClient { } /** - * Restore session into workspace + * Warp session into workspace * - * Replay a session's sync events into the target workspace in batches. + * Move a session's sync history into the target workspace, or detach it to the local project. */ - public sessionRestore( - parameters: { - id: string + public warp( + parameters?: { directory?: string workspace?: string + id?: string | null sessionID?: string }, options?: Options, @@ -1027,20 +1116,20 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "id" }, { in: "body", key: "sessionID" }, ], }, ], ) return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, + ExperimentalWorkspaceWarpResponses, + ExperimentalWorkspaceWarpErrors, ThrowOnError >({ - url: "/experimental/workspace/{id}/session-restore", + url: "/experimental/workspace/warp", ...options, ...params, headers: { @@ -1206,7 +1295,7 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/experimental/worktree", ...options, ...params, @@ -1314,7 +1403,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find", ...options, ...params, @@ -1352,7 +1441,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/file", ...options, ...params, @@ -1384,7 +1473,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/symbol", ...options, ...params, @@ -1418,7 +1507,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file", ...options, ...params, @@ -1450,7 +1539,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/content", ...options, ...params, @@ -1480,7 +1569,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/status", ...options, ...params, @@ -1512,7 +1601,7 @@ export class Instance extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/instance/dispose", ...options, ...params, @@ -1544,7 +1633,7 @@ export class Path extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/path", ...options, ...params, @@ -1576,7 +1665,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs", ...options, ...params, @@ -1608,7 +1697,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs/diff", ...options, ...params, @@ -1640,7 +1729,7 @@ export class Command extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/command", ...options, ...params, @@ -1672,7 +1761,7 @@ export class Lsp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/lsp", ...options, ...params, @@ -1704,7 +1793,7 @@ export class Formatter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/formatter", ...options, ...params, @@ -1875,7 +1964,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/mcp", ...options, ...params, @@ -1944,7 +2033,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/connect", ...options, ...params, @@ -1974,7 +2063,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/disconnect", ...options, ...params, @@ -2011,7 +2100,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project", ...options, ...params, @@ -2041,7 +2130,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project/current", ...options, ...params, @@ -2071,7 +2160,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/project/git/init", ...options, ...params, @@ -2155,7 +2244,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty/shells", ...options, ...params, @@ -2185,7 +2274,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty", ...options, ...params, @@ -2436,7 +2525,7 @@ export class Question extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/question", ...options, ...params, @@ -2539,7 +2628,7 @@ export class Permission extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/permission", ...options, ...params, @@ -2749,7 +2838,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider", ...options, ...params, @@ -2779,7 +2868,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider/auth", ...options, ...params, @@ -2828,7 +2917,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session", ...options, ...params, @@ -3116,7 +3205,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session/{sessionID}/diff", ...options, ...params, @@ -3318,7 +3407,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/session/{sessionID}/fork", ...options, ...params, @@ -3894,7 +3983,7 @@ export class Sync extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/sync/start", ...options, ...params, @@ -3956,6 +4045,43 @@ export class Sync extends HeyApiClient { }) } + /** + * Steal session into workspace + * + * Update a session to belong to the current workspace through the sync event system. + */ + public steal( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/steal", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _history?: History get history(): History { return (this._history ??= new History({ client: this.client })) @@ -4022,7 +4148,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/prompt", ...options, ...params, @@ -4059,7 +4185,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/compact", ...options, ...params, @@ -4091,7 +4217,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/wait", ...options, ...params, @@ -4123,7 +4249,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/api/session/{sessionID}/context", ...options, ...params, @@ -4194,7 +4320,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/tui/control/next", ...options, ...params, @@ -4226,7 +4352,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/control/response", ...options, ...params, @@ -4300,7 +4426,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-help", ...options, ...params, @@ -4330,7 +4456,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-sessions", ...options, ...params, @@ -4360,7 +4486,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-themes", ...options, ...params, @@ -4390,7 +4516,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-models", ...options, ...params, @@ -4420,7 +4546,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/submit-prompt", ...options, ...params, @@ -4450,7 +4576,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/clear-prompt", ...options, ...params, @@ -4525,7 +4651,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/show-toast", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86c5a762b..a40b567f8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -35,7 +35,6 @@ export type Event = | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -801,7 +800,6 @@ export type GlobalEvent = { | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -2478,17 +2476,6 @@ export type EventWorkspaceFailed = { } } -export type EventWorkspaceRestore = { - id: string - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - export type EventWorkspaceStatus = { id: string type: "workspace.status" @@ -3358,6 +3345,15 @@ export type GlobalHealthData = { url: "/global/health" } +export type GlobalHealthErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalHealthError = GlobalHealthErrors[keyof GlobalHealthErrors] + export type GlobalHealthResponses = { /** * Health information @@ -3377,6 +3373,15 @@ export type GlobalEventData = { url: "/global/event" } +export type GlobalEventErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalEventError = GlobalEventErrors[keyof GlobalEventErrors] + export type GlobalEventResponses = { /** * Event stream @@ -3393,6 +3398,15 @@ export type GlobalConfigGetData = { url: "/global/config" } +export type GlobalConfigGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalConfigGetError = GlobalConfigGetErrors[keyof GlobalConfigGetErrors] + export type GlobalConfigGetResponses = { /** * Get global config info @@ -3434,6 +3448,15 @@ export type GlobalDisposeData = { url: "/global/dispose" } +export type GlobalDisposeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalDisposeError = GlobalDisposeErrors[keyof GlobalDisposeErrors] + export type GlobalDisposeResponses = { /** * Global disposed @@ -3488,6 +3511,15 @@ export type EventSubscribeData = { url: "/event" } +export type EventSubscribeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type EventSubscribeError = EventSubscribeErrors[keyof EventSubscribeErrors] + export type EventSubscribeResponses = { /** * Event stream @@ -3507,6 +3539,15 @@ export type ConfigGetData = { url: "/config" } +export type ConfigGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigGetError = ConfigGetErrors[keyof ConfigGetErrors] + export type ConfigGetResponses = { /** * Get config info @@ -3554,6 +3595,15 @@ export type ConfigProvidersData = { url: "/config/providers" } +export type ConfigProvidersErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigProvidersError = ConfigProvidersErrors[keyof ConfigProvidersErrors] + export type ConfigProvidersResponses = { /** * List of providers @@ -3578,6 +3628,15 @@ export type ExperimentalConsoleGetData = { url: "/experimental/console" } +export type ExperimentalConsoleGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] + export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata @@ -3597,6 +3656,16 @@ export type ExperimentalConsoleListOrgsData = { url: "/experimental/console/orgs" } +export type ExperimentalConsoleListOrgsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalConsoleListOrgsError = + ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] + export type ExperimentalConsoleListOrgsResponses = { /** * Switchable Console orgs @@ -3735,6 +3804,15 @@ export type WorktreeListData = { url: "/experimental/worktree" } +export type WorktreeListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors] + export type WorktreeListResponses = { /** * List of worktree directories @@ -3816,6 +3894,15 @@ export type ExperimentalSessionListData = { url: "/experimental/session" } +export type ExperimentalSessionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalSessionListError = ExperimentalSessionListErrors[keyof ExperimentalSessionListErrors] + export type ExperimentalSessionListResponses = { /** * List of sessions @@ -3835,6 +3922,15 @@ export type ExperimentalResourceListData = { url: "/experimental/resource" } +export type ExperimentalResourceListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalResourceListError = ExperimentalResourceListErrors[keyof ExperimentalResourceListErrors] + export type ExperimentalResourceListResponses = { /** * MCP resources @@ -3858,6 +3954,15 @@ export type FindTextData = { url: "/find" } +export type FindTextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindTextError = FindTextErrors[keyof FindTextErrors] + export type FindTextResponses = { /** * Matches @@ -3897,6 +4002,15 @@ export type FindFilesData = { url: "/find/file" } +export type FindFilesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindFilesError = FindFilesErrors[keyof FindFilesErrors] + export type FindFilesResponses = { /** * File paths @@ -3917,6 +4031,15 @@ export type FindSymbolsData = { url: "/find/symbol" } +export type FindSymbolsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindSymbolsError = FindSymbolsErrors[keyof FindSymbolsErrors] + export type FindSymbolsResponses = { /** * Symbols @@ -3937,6 +4060,15 @@ export type FileListData = { url: "/file" } +export type FileListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileListError = FileListErrors[keyof FileListErrors] + export type FileListResponses = { /** * Files and directories @@ -3957,6 +4089,15 @@ export type FileReadData = { url: "/file/content" } +export type FileReadErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileReadError = FileReadErrors[keyof FileReadErrors] + export type FileReadResponses = { /** * File content @@ -3976,6 +4117,15 @@ export type FileStatusData = { url: "/file/status" } +export type FileStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileStatusError = FileStatusErrors[keyof FileStatusErrors] + export type FileStatusResponses = { /** * File status @@ -3995,6 +4145,15 @@ export type InstanceDisposeData = { url: "/instance/dispose" } +export type InstanceDisposeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type InstanceDisposeError = InstanceDisposeErrors[keyof InstanceDisposeErrors] + export type InstanceDisposeResponses = { /** * Instance disposed @@ -4014,6 +4173,15 @@ export type PathGetData = { url: "/path" } +export type PathGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PathGetError = PathGetErrors[keyof PathGetErrors] + export type PathGetResponses = { /** * Path @@ -4033,6 +4201,15 @@ export type VcsGetData = { url: "/vcs" } +export type VcsGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsGetError = VcsGetErrors[keyof VcsGetErrors] + export type VcsGetResponses = { /** * VCS info @@ -4053,6 +4230,15 @@ export type VcsDiffData = { url: "/vcs/diff" } +export type VcsDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsDiffError = VcsDiffErrors[keyof VcsDiffErrors] + export type VcsDiffResponses = { /** * VCS diff @@ -4072,6 +4258,15 @@ export type CommandListData = { url: "/command" } +export type CommandListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type CommandListError = CommandListErrors[keyof CommandListErrors] + export type CommandListResponses = { /** * List of commands @@ -4091,6 +4286,15 @@ export type AppAgentsData = { url: "/agent" } +export type AppAgentsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppAgentsError = AppAgentsErrors[keyof AppAgentsErrors] + export type AppAgentsResponses = { /** * List of agents @@ -4110,6 +4314,15 @@ export type AppSkillsData = { url: "/skill" } +export type AppSkillsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppSkillsError = AppSkillsErrors[keyof AppSkillsErrors] + export type AppSkillsResponses = { /** * List of skills @@ -4134,6 +4347,15 @@ export type LspStatusData = { url: "/lsp" } +export type LspStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspStatusError = LspStatusErrors[keyof LspStatusErrors] + export type LspStatusResponses = { /** * LSP server status @@ -4153,6 +4375,15 @@ export type FormatterStatusData = { url: "/formatter" } +export type FormatterStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FormatterStatusError = FormatterStatusErrors[keyof FormatterStatusErrors] + export type FormatterStatusResponses = { /** * Formatter status @@ -4172,6 +4403,15 @@ export type McpStatusData = { url: "/mcp" } +export type McpStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpStatusError = McpStatusErrors[keyof McpStatusErrors] + export type McpStatusResponses = { /** * MCP server status @@ -4229,6 +4469,10 @@ export type McpAuthRemoveData = { } export type McpAuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ @@ -4262,7 +4506,7 @@ export type McpAuthStartData = { export type McpAuthStartErrors = { /** - * McpUnsupportedOAuthError + * McpUnsupportedOAuthError | BadRequest */ 400: McpUnsupportedOAuthError /** @@ -4335,7 +4579,7 @@ export type McpAuthAuthenticateData = { export type McpAuthAuthenticateErrors = { /** - * McpUnsupportedOAuthError + * McpUnsupportedOAuthError | BadRequest */ 400: McpUnsupportedOAuthError /** @@ -4367,6 +4611,15 @@ export type McpConnectData = { url: "/mcp/{name}/connect" } +export type McpConnectErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpConnectError = McpConnectErrors[keyof McpConnectErrors] + export type McpConnectResponses = { /** * MCP server connected successfully @@ -4388,6 +4641,15 @@ export type McpDisconnectData = { url: "/mcp/{name}/disconnect" } +export type McpDisconnectErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpDisconnectError = McpDisconnectErrors[keyof McpDisconnectErrors] + export type McpDisconnectResponses = { /** * MCP server disconnected successfully @@ -4407,6 +4669,15 @@ export type ProjectListData = { url: "/project" } +export type ProjectListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectListError = ProjectListErrors[keyof ProjectListErrors] + export type ProjectListResponses = { /** * List of projects @@ -4426,6 +4697,15 @@ export type ProjectCurrentData = { url: "/project/current" } +export type ProjectCurrentErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectCurrentError = ProjectCurrentErrors[keyof ProjectCurrentErrors] + export type ProjectCurrentResponses = { /** * Current project information @@ -4445,6 +4725,15 @@ export type ProjectInitGitData = { url: "/project/git/init" } +export type ProjectInitGitErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectInitGitError = ProjectInitGitErrors[keyof ProjectInitGitErrors] + export type ProjectInitGitResponses = { /** * Project information after git initialization @@ -4511,6 +4800,15 @@ export type PtyShellsData = { url: "/pty/shells" } +export type PtyShellsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyShellsError = PtyShellsErrors[keyof PtyShellsErrors] + export type PtyShellsResponses = { /** * List of shells @@ -4534,6 +4832,15 @@ export type PtyListData = { url: "/pty" } +export type PtyListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyListError = PtyListErrors[keyof PtyListErrors] + export type PtyListResponses = { /** * List of sessions @@ -4592,6 +4899,10 @@ export type PtyRemoveData = { } export type PtyRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ @@ -4622,6 +4933,10 @@ export type PtyGetData = { } export type PtyGetErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ @@ -4688,6 +5003,10 @@ export type PtyConnectTokenData = { } export type PtyConnectTokenErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Forbidden */ @@ -4722,6 +5041,15 @@ export type QuestionListData = { url: "/question" } +export type QuestionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type QuestionListError = QuestionListErrors[keyof QuestionListErrors] + export type QuestionListResponses = { /** * List of pending questions @@ -4814,6 +5142,15 @@ export type PermissionListData = { url: "/permission" } +export type PermissionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PermissionListError = PermissionListErrors[keyof PermissionListErrors] + export type PermissionListResponses = { /** * List of pending permissions @@ -4870,6 +5207,15 @@ export type ProviderListData = { url: "/provider" } +export type ProviderListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderListError = ProviderListErrors[keyof ProviderListErrors] + export type ProviderListResponses = { /** * List of providers @@ -4895,6 +5241,15 @@ export type ProviderAuthData = { url: "/provider/auth" } +export type ProviderAuthErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderAuthError2 = ProviderAuthErrors[keyof ProviderAuthErrors] + export type ProviderAuthResponses = { /** * Provider auth methods @@ -4996,6 +5351,15 @@ export type SessionListData = { url: "/session" } +export type SessionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionListError = SessionListErrors[keyof SessionListErrors] + export type SessionListResponses = { /** * List of sessions @@ -5263,6 +5627,15 @@ export type SessionDiffData = { url: "/session/{sessionID}/diff" } +export type SessionDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors] + export type SessionDiffResponses = { /** * Successfully retrieved diff @@ -5450,6 +5823,15 @@ export type SessionForkData = { url: "/session/{sessionID}/fork" } +export type SessionForkErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] + export type SessionForkResponses = { /** * 200 @@ -5973,6 +6355,15 @@ export type SyncStartData = { url: "/sync/start" } +export type SyncStartErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStartError = SyncStartErrors[keyof SyncStartErrors] + export type SyncStartResponses = { /** * Workspace sync started @@ -6023,6 +6414,38 @@ export type SyncReplayResponses = { export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] +export type SyncStealData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/steal" +} + +export type SyncStealErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] + +export type SyncStealResponses = { + /** + * Session stolen into workspace + */ + 200: { + sessionID: string + } +} + +export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] + export type SyncHistoryListData = { body?: { [key: string]: number @@ -6104,6 +6527,15 @@ export type V2SessionPromptData = { url: "/api/session/{sessionID}/prompt" } +export type V2SessionPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] + export type V2SessionPromptResponses = { /** * Session.Message @@ -6125,6 +6557,15 @@ export type V2SessionCompactData = { url: "/api/session/{sessionID}/compact" } +export type V2SessionCompactErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] + export type V2SessionCompactResponses = { /** * @@ -6146,6 +6587,15 @@ export type V2SessionWaitData = { url: "/api/session/{sessionID}/wait" } +export type V2SessionWaitErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] + export type V2SessionWaitResponses = { /** * @@ -6167,6 +6617,15 @@ export type V2SessionContextData = { url: "/api/session/{sessionID}/context" } +export type V2SessionContextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] + export type V2SessionContextResponses = { /** * Success @@ -6246,6 +6705,15 @@ export type TuiOpenHelpData = { url: "/tui/open-help" } +export type TuiOpenHelpErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenHelpError = TuiOpenHelpErrors[keyof TuiOpenHelpErrors] + export type TuiOpenHelpResponses = { /** * Help dialog opened successfully @@ -6265,6 +6733,15 @@ export type TuiOpenSessionsData = { url: "/tui/open-sessions" } +export type TuiOpenSessionsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenSessionsError = TuiOpenSessionsErrors[keyof TuiOpenSessionsErrors] + export type TuiOpenSessionsResponses = { /** * Session dialog opened successfully @@ -6284,6 +6761,15 @@ export type TuiOpenThemesData = { url: "/tui/open-themes" } +export type TuiOpenThemesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenThemesError = TuiOpenThemesErrors[keyof TuiOpenThemesErrors] + export type TuiOpenThemesResponses = { /** * Theme dialog opened successfully @@ -6303,6 +6789,15 @@ export type TuiOpenModelsData = { url: "/tui/open-models" } +export type TuiOpenModelsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenModelsError = TuiOpenModelsErrors[keyof TuiOpenModelsErrors] + export type TuiOpenModelsResponses = { /** * Model dialog opened successfully @@ -6322,6 +6817,15 @@ export type TuiSubmitPromptData = { url: "/tui/submit-prompt" } +export type TuiSubmitPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiSubmitPromptError = TuiSubmitPromptErrors[keyof TuiSubmitPromptErrors] + export type TuiSubmitPromptResponses = { /** * Prompt submitted successfully @@ -6341,6 +6845,15 @@ export type TuiClearPromptData = { url: "/tui/clear-prompt" } +export type TuiClearPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiClearPromptError = TuiClearPromptErrors[keyof TuiClearPromptErrors] + export type TuiClearPromptResponses = { /** * Prompt cleared successfully @@ -6395,6 +6908,15 @@ export type TuiShowToastData = { url: "/tui/show-toast" } +export type TuiShowToastErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiShowToastError = TuiShowToastErrors[keyof TuiShowToastErrors] + export type TuiShowToastResponses = { /** * Toast notification shown successfully @@ -6479,6 +7001,15 @@ export type TuiControlNextData = { url: "/tui/control/next" } +export type TuiControlNextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiControlNextError = TuiControlNextErrors[keyof TuiControlNextErrors] + export type TuiControlNextResponses = { /** * Next TUI request @@ -6501,6 +7032,15 @@ export type TuiControlResponseData = { url: "/tui/control/response" } +export type TuiControlResponseErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiControlResponseError = TuiControlResponseErrors[keyof TuiControlResponseErrors] + export type TuiControlResponseResponses = { /** * Response submitted successfully @@ -6520,6 +7060,16 @@ export type ExperimentalWorkspaceAdapterListData = { url: "/experimental/workspace/adapter" } +export type ExperimentalWorkspaceAdapterListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceAdapterListError = + ExperimentalWorkspaceAdapterListErrors[keyof ExperimentalWorkspaceAdapterListErrors] + export type ExperimentalWorkspaceAdapterListResponses = { /** * Workspace adapters @@ -6544,6 +7094,15 @@ export type ExperimentalWorkspaceListData = { url: "/experimental/workspace" } +export type ExperimentalWorkspaceListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceListError = ExperimentalWorkspaceListErrors[keyof ExperimentalWorkspaceListErrors] + export type ExperimentalWorkspaceListResponses = { /** * Workspaces @@ -6559,7 +7118,7 @@ export type ExperimentalWorkspaceCreateData = { id?: string type: string branch: string | null - extra?: unknown | null + extra: unknown | null } path?: never query?: { @@ -6599,6 +7158,16 @@ export type ExperimentalWorkspaceStatusData = { url: "/experimental/workspace/status" } +export type ExperimentalWorkspaceStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceStatusError = + ExperimentalWorkspaceStatusErrors[keyof ExperimentalWorkspaceStatusErrors] + export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status @@ -6644,41 +7213,37 @@ export type ExperimentalWorkspaceRemoveResponses = { export type ExperimentalWorkspaceRemoveResponse = ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] -export type ExperimentalWorkspaceSessionRestoreData = { +export type ExperimentalWorkspaceWarpData = { body?: { + id: string | null sessionID: string } - path: { - id: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/{id}/session-restore" + url: "/experimental/workspace/warp" } -export type ExperimentalWorkspaceSessionRestoreErrors = { +export type ExperimentalWorkspaceWarpErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] +export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] -export type ExperimentalWorkspaceSessionRestoreResponses = { +export type ExperimentalWorkspaceWarpResponses = { /** - * Session replay started + * Session warped */ - 200: { - total: number - } + 204: void } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ExperimentalWorkspaceWarpResponse = + ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] export type PtyConnectData = { body?: never @@ -6693,6 +7258,10 @@ export type PtyConnectData = { } export type PtyConnectErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Forbidden */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6ff18b515..1a2f1e947 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -218,6 +218,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get health information about the OpenCode server.", @@ -245,6 +255,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Subscribe to global events from the OpenCode system using server-sent events.", @@ -272,6 +292,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current global OpenCode configuration settings and preferences.", @@ -344,6 +374,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Clean up and dispose all OpenCode instances, releasing all resources.", @@ -470,6 +510,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get events", @@ -514,6 +564,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current OpenCode configuration settings and preferences.", @@ -636,6 +696,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all configured AI providers and their default models.", @@ -680,6 +750,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", @@ -757,6 +837,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the available Console orgs across logged-in accounts, including the current active org.", @@ -993,6 +1083,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List all sandbox worktrees for the current project.", @@ -1292,6 +1392,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", @@ -1340,6 +1450,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get all available MCP resources from connected servers. Optionally filter by name.", @@ -1456,6 +1576,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Search for text patterns across files in the project using ripgrep.", @@ -1540,6 +1670,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Search for files or directories by name or pattern in the project directory.", @@ -1596,6 +1736,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Search for workspace symbols like functions, classes, and variables using LSP.", @@ -1652,6 +1802,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List files and directories in a specified path.", @@ -1704,6 +1864,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Read the content of a specified file.", @@ -1752,6 +1922,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the git status of all files in the project.", @@ -1797,6 +1977,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", @@ -1841,6 +2031,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current working directory and related path information for the OpenCode instance.", @@ -1885,6 +2085,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", @@ -1942,6 +2152,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current git diff for the working tree or against the default branch.", @@ -1990,6 +2210,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available commands in the OpenCode system.", @@ -2038,6 +2268,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available AI agents in the OpenCode system.", @@ -2102,6 +2342,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available skills in the OpenCode system.", @@ -2150,6 +2400,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get LSP server status", @@ -2198,6 +2458,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get formatter status", @@ -2246,6 +2516,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the status of all Model Context Protocol (MCP) servers.", @@ -2393,7 +2673,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError", + "description": "McpUnsupportedOAuthError | BadRequest", "content": { "application/json": { "schema": { @@ -2471,6 +2751,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2622,7 +2912,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError", + "description": "McpUnsupportedOAuthError | BadRequest", "content": { "application/json": { "schema": { @@ -2693,6 +2983,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Connect an MCP server.", @@ -2745,6 +3045,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Disconnect an MCP server.", @@ -2792,6 +3102,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of projects that have been opened with OpenCode.", @@ -2836,6 +3156,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the currently active project that OpenCode is working with.", @@ -2880,6 +3210,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Create a git repository for the current project and return the refreshed project info.", @@ -3053,6 +3393,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of available shells on the system.", @@ -3101,6 +3451,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", @@ -3240,6 +3600,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3393,6 +3763,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3468,6 +3848,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "403": { "description": "Forbidden", "content": { @@ -3535,6 +3925,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get all pending question requests across all sessions.", @@ -3751,6 +4151,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get all pending permission requests across all sessions.", @@ -3912,6 +4322,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available AI providers, including both available and connected ones.", @@ -3963,6 +4383,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve available authentication methods for all AI providers.", @@ -4236,6 +4666,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", @@ -4852,6 +5292,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the file changes (diff) that resulted from a specific user message in the session.", @@ -5342,6 +5792,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Create a new session by forking an existing session at a specific message point.", @@ -6668,6 +7128,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Start sync loops for workspaces in the current project that have active sessions.", @@ -6785,6 +7255,84 @@ ] } }, + "/sync/steal": { + "post": { + "tags": ["sync"], + "operationId": "sync.steal", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session stolen into workspace", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false, + "description": "Session stolen into workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update a session to belong to the current workspace through the sync event system.", + "summary": "Steal session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})" + } + ] + } + }, "/sync/history": { "post": { "tags": ["sync"], @@ -6971,6 +7519,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Create a v2 session message and queue it for the agent loop.", @@ -7036,6 +7594,16 @@ "responses": { "204": { "description": "" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Compact a v2 session conversation.", @@ -7082,6 +7650,16 @@ "responses": { "204": { "description": "" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Wait for a v2 session agent loop to become idle.", @@ -7138,6 +7716,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", @@ -7317,6 +7905,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the help dialog in the TUI to display user assistance information.", @@ -7362,6 +7960,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the session dialog.", @@ -7407,6 +8015,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the theme dialog.", @@ -7452,6 +8070,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the model dialog.", @@ -7497,6 +8125,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Submit the prompt.", @@ -7542,6 +8180,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Clear the prompt.", @@ -7658,6 +8306,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Show a toast notification in the TUI.", @@ -7897,6 +8555,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the next TUI request from the queue for processing.", @@ -7942,6 +8610,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Submit a response to the TUI request queue to complete a pending request.", @@ -8010,6 +8688,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List all available workspace adapters for the current project.", @@ -8058,6 +8746,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List all workspaces.", @@ -8145,7 +8843,7 @@ ] } }, - "required": ["type", "branch"], + "required": ["type", "branch", "extra"], "additionalProperties": false } } @@ -8206,6 +8904,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get connection status for workspaces in the current project.", @@ -8281,10 +8989,10 @@ ] } }, - "/experimental/workspace/{id}/session-restore": { + "/experimental/workspace/warp": { "post": { "tags": ["workspace"], - "operationId": "experimental.workspace.sessionRestore", + "operationId": "experimental.workspace.warp", "parameters": [ { "name": "directory", @@ -8301,36 +9009,11 @@ "schema": { "type": "string" } - }, - { - "name": "id", - "in": "path", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true } ], "responses": { - "200": { - "description": "Session replay started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["total"], - "additionalProperties": false, - "description": "Session replay started" - } - } - } + "204": { + "description": "Session warped" }, "400": { "description": "Bad request", @@ -8343,19 +9026,22 @@ } } }, - "description": "Replay a session's sync events into the target workspace in batches.", - "summary": "Restore session into workspace", + "description": "Move a session's sync history into the target workspace, or detach it to the local project.", + "summary": "Warp session into workspace", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { + "id": { + "type": "string" + }, "sessionID": { "type": "string" } }, - "required": ["sessionID"], + "required": ["id", "sessionID"], "additionalProperties": false } } @@ -8364,7 +9050,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})" } ] } @@ -8412,6 +9098,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "403": { "description": "Forbidden", "content": { @@ -8538,9 +9234,6 @@ { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -10737,9 +11430,6 @@ { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -15793,41 +16483,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventWorkspaceRestore": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.restore"] - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string" - }, - "sessionID": { - "type": "string" - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "step": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventWorkspaceStatus": { "type": "object", "properties": { From f33b17e8ac157237fdf3c4d3ff06ced126fb4752 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 01:29:49 +0000 Subject: [PATCH 165/174] chore: generate --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 207 +++----- packages/sdk/js/src/v2/gen/types.gen.ts | 562 +-------------------- packages/sdk/openapi.json | 646 +----------------------- 3 files changed, 67 insertions(+), 1348 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ab191b056..ffc0970c0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4,84 +4,58 @@ import { client } from "./client.gen.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import type { AgentPartInput, - AppAgentsErrors, AppAgentsResponses, AppLogErrors, AppLogResponses, - AppSkillsErrors, AppSkillsResponses, Auth as Auth3, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, AuthSetResponses, - CommandListErrors, CommandListResponses, Config as Config3, - ConfigGetErrors, ConfigGetResponses, - ConfigProvidersErrors, ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, - EventSubscribeErrors, EventSubscribeResponses, EventTuiCommandExecute2, EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, - ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, - ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, - ExperimentalResourceListErrors, ExperimentalResourceListResponses, - ExperimentalSessionListErrors, ExperimentalSessionListResponses, - ExperimentalWorkspaceAdapterListErrors, ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceListErrors, ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceStatusErrors, ExperimentalWorkspaceStatusResponses, ExperimentalWorkspaceWarpErrors, ExperimentalWorkspaceWarpResponses, - FileListErrors, FileListResponses, FilePartInput, FilePartSource, - FileReadErrors, FileReadResponses, - FileStatusErrors, FileStatusResponses, - FindFilesErrors, FindFilesResponses, - FindSymbolsErrors, FindSymbolsResponses, - FindTextErrors, FindTextResponses, - FormatterStatusErrors, FormatterStatusResponses, - GlobalConfigGetErrors, GlobalConfigGetResponses, GlobalConfigUpdateErrors, GlobalConfigUpdateResponses, - GlobalDisposeErrors, GlobalDisposeResponses, - GlobalEventErrors, GlobalEventResponses, - GlobalHealthErrors, GlobalHealthResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, - InstanceDisposeErrors, InstanceDisposeResponses, - LspStatusErrors, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -93,13 +67,10 @@ import type { McpAuthRemoveResponses, McpAuthStartErrors, McpAuthStartResponses, - McpConnectErrors, McpConnectResponses, - McpDisconnectErrors, McpDisconnectResponses, McpLocalConfig, McpRemoteConfig, - McpStatusErrors, McpStatusResponses, OutputFormat, Part as Part2, @@ -107,27 +78,20 @@ import type { PartDeleteResponses, PartUpdateErrors, PartUpdateResponses, - PathGetErrors, PathGetResponses, - PermissionListErrors, PermissionListResponses, PermissionReplyErrors, PermissionReplyResponses, PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, - ProjectCurrentErrors, ProjectCurrentResponses, - ProjectInitGitErrors, ProjectInitGitResponses, - ProjectListErrors, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, Prompt, - ProviderAuthErrors, ProviderAuthResponses, - ProviderListErrors, ProviderListResponses, ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeResponses, @@ -141,16 +105,13 @@ import type { PtyCreateResponses, PtyGetErrors, PtyGetResponses, - PtyListErrors, PtyListResponses, PtyRemoveErrors, PtyRemoveResponses, - PtyShellsErrors, PtyShellsResponses, PtyUpdateErrors, PtyUpdateResponses, QuestionAnswer, - QuestionListErrors, QuestionListResponses, QuestionRejectErrors, QuestionRejectResponses, @@ -169,15 +130,12 @@ import type { SessionDeleteMessageResponses, SessionDeleteResponses, SessionDelivery, - SessionDiffErrors, SessionDiffResponses, - SessionForkErrors, SessionForkResponses, SessionGetErrors, SessionGetResponses, SessionInitErrors, SessionInitResponses, - SessionListErrors, SessionListResponses, SessionMessageErrors, SessionMessageResponses, @@ -210,7 +168,6 @@ import type { SyncHistoryListResponses, SyncReplayErrors, SyncReplayResponses, - SyncStartErrors, SyncStartResponses, SyncStealErrors, SyncStealResponses, @@ -221,50 +178,34 @@ import type { ToolListResponses, TuiAppendPromptErrors, TuiAppendPromptResponses, - TuiClearPromptErrors, TuiClearPromptResponses, - TuiControlNextErrors, TuiControlNextResponses, - TuiControlResponseErrors, TuiControlResponseResponses, TuiExecuteCommandErrors, TuiExecuteCommandResponses, - TuiOpenHelpErrors, TuiOpenHelpResponses, - TuiOpenModelsErrors, TuiOpenModelsResponses, - TuiOpenSessionsErrors, TuiOpenSessionsResponses, - TuiOpenThemesErrors, TuiOpenThemesResponses, TuiPublishErrors, TuiPublishResponses, TuiSelectSessionErrors, TuiSelectSessionResponses, - TuiShowToastErrors, TuiShowToastResponses, - TuiSubmitPromptErrors, TuiSubmitPromptResponses, - V2SessionCompactErrors, V2SessionCompactResponses, - V2SessionContextErrors, V2SessionContextResponses, V2SessionListErrors, V2SessionListResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, - V2SessionPromptErrors, V2SessionPromptResponses, - V2SessionWaitErrors, V2SessionWaitResponses, - VcsDiffErrors, VcsDiffResponses, - VcsGetErrors, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, - WorktreeListErrors, WorktreeListResponses, WorktreeRemoveErrors, WorktreeRemoveInput, @@ -442,7 +383,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/agent", ...options, ...params, @@ -472,7 +413,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/skill", ...options, ...params, @@ -487,7 +428,7 @@ export class Config extends HeyApiClient { * Retrieve the current global OpenCode configuration settings and preferences. */ public get(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/config", ...options, }) @@ -525,7 +466,7 @@ export class Global extends HeyApiClient { * Get health information about the OpenCode server. */ public health(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/health", ...options, }) @@ -537,7 +478,7 @@ export class Global extends HeyApiClient { * Subscribe to global events from the OpenCode system using server-sent events. */ public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/global/event", ...options, }) @@ -549,7 +490,7 @@ export class Global extends HeyApiClient { * Clean up and dispose all OpenCode instances, releasing all resources. */ public dispose(options?: Options) { - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/global/dispose", ...options, }) @@ -609,7 +550,7 @@ export class Event extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/event", ...options, ...params, @@ -641,7 +582,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config", ...options, ...params, @@ -708,7 +649,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config/providers", ...options, ...params, @@ -740,11 +681,7 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalConsoleGetResponses, - ExperimentalConsoleGetErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/console", ...options, ...params, @@ -774,11 +711,7 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalConsoleListOrgsResponses, - ExperimentalConsoleListOrgsErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/console/orgs", ...options, ...params, @@ -861,11 +794,7 @@ export class Session extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalSessionListResponses, - ExperimentalSessionListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/session", ...options, ...params, @@ -897,11 +826,7 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalResourceListResponses, - ExperimentalResourceListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/resource", ...options, ...params, @@ -933,11 +858,7 @@ export class Adapter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalWorkspaceAdapterListResponses, - ExperimentalWorkspaceAdapterListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/workspace/adapter", ...options, ...params, @@ -969,11 +890,7 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalWorkspaceListResponses, - ExperimentalWorkspaceListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/workspace", ...options, ...params, @@ -1050,11 +967,7 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalWorkspaceStatusResponses, - ExperimentalWorkspaceStatusErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/workspace/status", ...options, ...params, @@ -1106,7 +1019,7 @@ export class Workspace extends HeyApiClient { parameters?: { directory?: string workspace?: string - id?: string | null + id?: string sessionID?: string }, options?: Options, @@ -1295,7 +1208,7 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/experimental/worktree", ...options, ...params, @@ -1403,7 +1316,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find", ...options, ...params, @@ -1441,7 +1354,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/file", ...options, ...params, @@ -1473,7 +1386,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/symbol", ...options, ...params, @@ -1507,7 +1420,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file", ...options, ...params, @@ -1539,7 +1452,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/content", ...options, ...params, @@ -1569,7 +1482,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/status", ...options, ...params, @@ -1601,7 +1514,7 @@ export class Instance extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/instance/dispose", ...options, ...params, @@ -1633,7 +1546,7 @@ export class Path extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/path", ...options, ...params, @@ -1665,7 +1578,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs", ...options, ...params, @@ -1697,7 +1610,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs/diff", ...options, ...params, @@ -1729,7 +1642,7 @@ export class Command extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/command", ...options, ...params, @@ -1761,7 +1674,7 @@ export class Lsp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/lsp", ...options, ...params, @@ -1793,7 +1706,7 @@ export class Formatter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/formatter", ...options, ...params, @@ -1964,7 +1877,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/mcp", ...options, ...params, @@ -2033,7 +1946,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/connect", ...options, ...params, @@ -2063,7 +1976,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/disconnect", ...options, ...params, @@ -2100,7 +2013,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project", ...options, ...params, @@ -2130,7 +2043,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project/current", ...options, ...params, @@ -2160,7 +2073,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/project/git/init", ...options, ...params, @@ -2244,7 +2157,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty/shells", ...options, ...params, @@ -2274,7 +2187,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty", ...options, ...params, @@ -2525,7 +2438,7 @@ export class Question extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/question", ...options, ...params, @@ -2628,7 +2541,7 @@ export class Permission extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/permission", ...options, ...params, @@ -2838,7 +2751,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider", ...options, ...params, @@ -2868,7 +2781,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider/auth", ...options, ...params, @@ -2917,7 +2830,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session", ...options, ...params, @@ -3205,7 +3118,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session/{sessionID}/diff", ...options, ...params, @@ -3407,7 +3320,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/session/{sessionID}/fork", ...options, ...params, @@ -3983,7 +3896,7 @@ export class Sync extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/sync/start", ...options, ...params, @@ -4148,7 +4061,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/prompt", ...options, ...params, @@ -4185,7 +4098,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/compact", ...options, ...params, @@ -4217,7 +4130,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/wait", ...options, ...params, @@ -4249,7 +4162,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/api/session/{sessionID}/context", ...options, ...params, @@ -4320,7 +4233,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/tui/control/next", ...options, ...params, @@ -4352,7 +4265,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/control/response", ...options, ...params, @@ -4426,7 +4339,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-help", ...options, ...params, @@ -4456,7 +4369,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-sessions", ...options, ...params, @@ -4486,7 +4399,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-themes", ...options, ...params, @@ -4516,7 +4429,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-models", ...options, ...params, @@ -4546,7 +4459,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/submit-prompt", ...options, ...params, @@ -4576,7 +4489,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/clear-prompt", ...options, ...params, @@ -4651,7 +4564,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/show-toast", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a40b567f8..c0255754d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3345,15 +3345,6 @@ export type GlobalHealthData = { url: "/global/health" } -export type GlobalHealthErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalHealthError = GlobalHealthErrors[keyof GlobalHealthErrors] - export type GlobalHealthResponses = { /** * Health information @@ -3373,15 +3364,6 @@ export type GlobalEventData = { url: "/global/event" } -export type GlobalEventErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalEventError = GlobalEventErrors[keyof GlobalEventErrors] - export type GlobalEventResponses = { /** * Event stream @@ -3398,15 +3380,6 @@ export type GlobalConfigGetData = { url: "/global/config" } -export type GlobalConfigGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalConfigGetError = GlobalConfigGetErrors[keyof GlobalConfigGetErrors] - export type GlobalConfigGetResponses = { /** * Get global config info @@ -3448,15 +3421,6 @@ export type GlobalDisposeData = { url: "/global/dispose" } -export type GlobalDisposeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalDisposeError = GlobalDisposeErrors[keyof GlobalDisposeErrors] - export type GlobalDisposeResponses = { /** * Global disposed @@ -3511,15 +3475,6 @@ export type EventSubscribeData = { url: "/event" } -export type EventSubscribeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type EventSubscribeError = EventSubscribeErrors[keyof EventSubscribeErrors] - export type EventSubscribeResponses = { /** * Event stream @@ -3539,15 +3494,6 @@ export type ConfigGetData = { url: "/config" } -export type ConfigGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ConfigGetError = ConfigGetErrors[keyof ConfigGetErrors] - export type ConfigGetResponses = { /** * Get config info @@ -3595,15 +3541,6 @@ export type ConfigProvidersData = { url: "/config/providers" } -export type ConfigProvidersErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ConfigProvidersError = ConfigProvidersErrors[keyof ConfigProvidersErrors] - export type ConfigProvidersResponses = { /** * List of providers @@ -3628,15 +3565,6 @@ export type ExperimentalConsoleGetData = { url: "/experimental/console" } -export type ExperimentalConsoleGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] - export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata @@ -3656,16 +3584,6 @@ export type ExperimentalConsoleListOrgsData = { url: "/experimental/console/orgs" } -export type ExperimentalConsoleListOrgsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalConsoleListOrgsError = - ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] - export type ExperimentalConsoleListOrgsResponses = { /** * Switchable Console orgs @@ -3804,15 +3722,6 @@ export type WorktreeListData = { url: "/experimental/worktree" } -export type WorktreeListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors] - export type WorktreeListResponses = { /** * List of worktree directories @@ -3894,15 +3803,6 @@ export type ExperimentalSessionListData = { url: "/experimental/session" } -export type ExperimentalSessionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalSessionListError = ExperimentalSessionListErrors[keyof ExperimentalSessionListErrors] - export type ExperimentalSessionListResponses = { /** * List of sessions @@ -3922,15 +3822,6 @@ export type ExperimentalResourceListData = { url: "/experimental/resource" } -export type ExperimentalResourceListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalResourceListError = ExperimentalResourceListErrors[keyof ExperimentalResourceListErrors] - export type ExperimentalResourceListResponses = { /** * MCP resources @@ -3954,15 +3845,6 @@ export type FindTextData = { url: "/find" } -export type FindTextErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FindTextError = FindTextErrors[keyof FindTextErrors] - export type FindTextResponses = { /** * Matches @@ -4002,15 +3884,6 @@ export type FindFilesData = { url: "/find/file" } -export type FindFilesErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FindFilesError = FindFilesErrors[keyof FindFilesErrors] - export type FindFilesResponses = { /** * File paths @@ -4031,15 +3904,6 @@ export type FindSymbolsData = { url: "/find/symbol" } -export type FindSymbolsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FindSymbolsError = FindSymbolsErrors[keyof FindSymbolsErrors] - export type FindSymbolsResponses = { /** * Symbols @@ -4060,15 +3924,6 @@ export type FileListData = { url: "/file" } -export type FileListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FileListError = FileListErrors[keyof FileListErrors] - export type FileListResponses = { /** * Files and directories @@ -4089,15 +3944,6 @@ export type FileReadData = { url: "/file/content" } -export type FileReadErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FileReadError = FileReadErrors[keyof FileReadErrors] - export type FileReadResponses = { /** * File content @@ -4117,15 +3963,6 @@ export type FileStatusData = { url: "/file/status" } -export type FileStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FileStatusError = FileStatusErrors[keyof FileStatusErrors] - export type FileStatusResponses = { /** * File status @@ -4145,15 +3982,6 @@ export type InstanceDisposeData = { url: "/instance/dispose" } -export type InstanceDisposeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type InstanceDisposeError = InstanceDisposeErrors[keyof InstanceDisposeErrors] - export type InstanceDisposeResponses = { /** * Instance disposed @@ -4173,15 +4001,6 @@ export type PathGetData = { url: "/path" } -export type PathGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PathGetError = PathGetErrors[keyof PathGetErrors] - export type PathGetResponses = { /** * Path @@ -4201,15 +4020,6 @@ export type VcsGetData = { url: "/vcs" } -export type VcsGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type VcsGetError = VcsGetErrors[keyof VcsGetErrors] - export type VcsGetResponses = { /** * VCS info @@ -4230,15 +4040,6 @@ export type VcsDiffData = { url: "/vcs/diff" } -export type VcsDiffErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type VcsDiffError = VcsDiffErrors[keyof VcsDiffErrors] - export type VcsDiffResponses = { /** * VCS diff @@ -4258,15 +4059,6 @@ export type CommandListData = { url: "/command" } -export type CommandListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type CommandListError = CommandListErrors[keyof CommandListErrors] - export type CommandListResponses = { /** * List of commands @@ -4286,15 +4078,6 @@ export type AppAgentsData = { url: "/agent" } -export type AppAgentsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppAgentsError = AppAgentsErrors[keyof AppAgentsErrors] - export type AppAgentsResponses = { /** * List of agents @@ -4314,15 +4097,6 @@ export type AppSkillsData = { url: "/skill" } -export type AppSkillsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppSkillsError = AppSkillsErrors[keyof AppSkillsErrors] - export type AppSkillsResponses = { /** * List of skills @@ -4347,15 +4121,6 @@ export type LspStatusData = { url: "/lsp" } -export type LspStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type LspStatusError = LspStatusErrors[keyof LspStatusErrors] - export type LspStatusResponses = { /** * LSP server status @@ -4375,15 +4140,6 @@ export type FormatterStatusData = { url: "/formatter" } -export type FormatterStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FormatterStatusError = FormatterStatusErrors[keyof FormatterStatusErrors] - export type FormatterStatusResponses = { /** * Formatter status @@ -4403,15 +4159,6 @@ export type McpStatusData = { url: "/mcp" } -export type McpStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type McpStatusError = McpStatusErrors[keyof McpStatusErrors] - export type McpStatusResponses = { /** * MCP server status @@ -4469,10 +4216,6 @@ export type McpAuthRemoveData = { } export type McpAuthRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Not found */ @@ -4506,7 +4249,7 @@ export type McpAuthStartData = { export type McpAuthStartErrors = { /** - * McpUnsupportedOAuthError | BadRequest + * McpUnsupportedOAuthError */ 400: McpUnsupportedOAuthError /** @@ -4579,7 +4322,7 @@ export type McpAuthAuthenticateData = { export type McpAuthAuthenticateErrors = { /** - * McpUnsupportedOAuthError | BadRequest + * McpUnsupportedOAuthError */ 400: McpUnsupportedOAuthError /** @@ -4611,15 +4354,6 @@ export type McpConnectData = { url: "/mcp/{name}/connect" } -export type McpConnectErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type McpConnectError = McpConnectErrors[keyof McpConnectErrors] - export type McpConnectResponses = { /** * MCP server connected successfully @@ -4641,15 +4375,6 @@ export type McpDisconnectData = { url: "/mcp/{name}/disconnect" } -export type McpDisconnectErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type McpDisconnectError = McpDisconnectErrors[keyof McpDisconnectErrors] - export type McpDisconnectResponses = { /** * MCP server disconnected successfully @@ -4669,15 +4394,6 @@ export type ProjectListData = { url: "/project" } -export type ProjectListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProjectListError = ProjectListErrors[keyof ProjectListErrors] - export type ProjectListResponses = { /** * List of projects @@ -4697,15 +4413,6 @@ export type ProjectCurrentData = { url: "/project/current" } -export type ProjectCurrentErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProjectCurrentError = ProjectCurrentErrors[keyof ProjectCurrentErrors] - export type ProjectCurrentResponses = { /** * Current project information @@ -4725,15 +4432,6 @@ export type ProjectInitGitData = { url: "/project/git/init" } -export type ProjectInitGitErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProjectInitGitError = ProjectInitGitErrors[keyof ProjectInitGitErrors] - export type ProjectInitGitResponses = { /** * Project information after git initialization @@ -4800,15 +4498,6 @@ export type PtyShellsData = { url: "/pty/shells" } -export type PtyShellsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PtyShellsError = PtyShellsErrors[keyof PtyShellsErrors] - export type PtyShellsResponses = { /** * List of shells @@ -4832,15 +4521,6 @@ export type PtyListData = { url: "/pty" } -export type PtyListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PtyListError = PtyListErrors[keyof PtyListErrors] - export type PtyListResponses = { /** * List of sessions @@ -4899,10 +4579,6 @@ export type PtyRemoveData = { } export type PtyRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Not found */ @@ -4933,10 +4609,6 @@ export type PtyGetData = { } export type PtyGetErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Not found */ @@ -5003,10 +4675,6 @@ export type PtyConnectTokenData = { } export type PtyConnectTokenErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Forbidden */ @@ -5041,15 +4709,6 @@ export type QuestionListData = { url: "/question" } -export type QuestionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type QuestionListError = QuestionListErrors[keyof QuestionListErrors] - export type QuestionListResponses = { /** * List of pending questions @@ -5142,15 +4801,6 @@ export type PermissionListData = { url: "/permission" } -export type PermissionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PermissionListError = PermissionListErrors[keyof PermissionListErrors] - export type PermissionListResponses = { /** * List of pending permissions @@ -5207,15 +4857,6 @@ export type ProviderListData = { url: "/provider" } -export type ProviderListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderListError = ProviderListErrors[keyof ProviderListErrors] - export type ProviderListResponses = { /** * List of providers @@ -5241,15 +4882,6 @@ export type ProviderAuthData = { url: "/provider/auth" } -export type ProviderAuthErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderAuthError2 = ProviderAuthErrors[keyof ProviderAuthErrors] - export type ProviderAuthResponses = { /** * Provider auth methods @@ -5351,15 +4983,6 @@ export type SessionListData = { url: "/session" } -export type SessionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SessionListError = SessionListErrors[keyof SessionListErrors] - export type SessionListResponses = { /** * List of sessions @@ -5627,15 +5250,6 @@ export type SessionDiffData = { url: "/session/{sessionID}/diff" } -export type SessionDiffErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors] - export type SessionDiffResponses = { /** * Successfully retrieved diff @@ -5823,15 +5437,6 @@ export type SessionForkData = { url: "/session/{sessionID}/fork" } -export type SessionForkErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] - export type SessionForkResponses = { /** * 200 @@ -6355,15 +5960,6 @@ export type SyncStartData = { url: "/sync/start" } -export type SyncStartErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SyncStartError = SyncStartErrors[keyof SyncStartErrors] - export type SyncStartResponses = { /** * Workspace sync started @@ -6527,15 +6123,6 @@ export type V2SessionPromptData = { url: "/api/session/{sessionID}/prompt" } -export type V2SessionPromptErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] - export type V2SessionPromptResponses = { /** * Session.Message @@ -6557,15 +6144,6 @@ export type V2SessionCompactData = { url: "/api/session/{sessionID}/compact" } -export type V2SessionCompactErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] - export type V2SessionCompactResponses = { /** * @@ -6587,15 +6165,6 @@ export type V2SessionWaitData = { url: "/api/session/{sessionID}/wait" } -export type V2SessionWaitErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] - export type V2SessionWaitResponses = { /** * @@ -6617,15 +6186,6 @@ export type V2SessionContextData = { url: "/api/session/{sessionID}/context" } -export type V2SessionContextErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] - export type V2SessionContextResponses = { /** * Success @@ -6705,15 +6265,6 @@ export type TuiOpenHelpData = { url: "/tui/open-help" } -export type TuiOpenHelpErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenHelpError = TuiOpenHelpErrors[keyof TuiOpenHelpErrors] - export type TuiOpenHelpResponses = { /** * Help dialog opened successfully @@ -6733,15 +6284,6 @@ export type TuiOpenSessionsData = { url: "/tui/open-sessions" } -export type TuiOpenSessionsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenSessionsError = TuiOpenSessionsErrors[keyof TuiOpenSessionsErrors] - export type TuiOpenSessionsResponses = { /** * Session dialog opened successfully @@ -6761,15 +6303,6 @@ export type TuiOpenThemesData = { url: "/tui/open-themes" } -export type TuiOpenThemesErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenThemesError = TuiOpenThemesErrors[keyof TuiOpenThemesErrors] - export type TuiOpenThemesResponses = { /** * Theme dialog opened successfully @@ -6789,15 +6322,6 @@ export type TuiOpenModelsData = { url: "/tui/open-models" } -export type TuiOpenModelsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenModelsError = TuiOpenModelsErrors[keyof TuiOpenModelsErrors] - export type TuiOpenModelsResponses = { /** * Model dialog opened successfully @@ -6817,15 +6341,6 @@ export type TuiSubmitPromptData = { url: "/tui/submit-prompt" } -export type TuiSubmitPromptErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiSubmitPromptError = TuiSubmitPromptErrors[keyof TuiSubmitPromptErrors] - export type TuiSubmitPromptResponses = { /** * Prompt submitted successfully @@ -6845,15 +6360,6 @@ export type TuiClearPromptData = { url: "/tui/clear-prompt" } -export type TuiClearPromptErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiClearPromptError = TuiClearPromptErrors[keyof TuiClearPromptErrors] - export type TuiClearPromptResponses = { /** * Prompt cleared successfully @@ -6908,15 +6414,6 @@ export type TuiShowToastData = { url: "/tui/show-toast" } -export type TuiShowToastErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiShowToastError = TuiShowToastErrors[keyof TuiShowToastErrors] - export type TuiShowToastResponses = { /** * Toast notification shown successfully @@ -7001,15 +6498,6 @@ export type TuiControlNextData = { url: "/tui/control/next" } -export type TuiControlNextErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiControlNextError = TuiControlNextErrors[keyof TuiControlNextErrors] - export type TuiControlNextResponses = { /** * Next TUI request @@ -7032,15 +6520,6 @@ export type TuiControlResponseData = { url: "/tui/control/response" } -export type TuiControlResponseErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiControlResponseError = TuiControlResponseErrors[keyof TuiControlResponseErrors] - export type TuiControlResponseResponses = { /** * Response submitted successfully @@ -7060,16 +6539,6 @@ export type ExperimentalWorkspaceAdapterListData = { url: "/experimental/workspace/adapter" } -export type ExperimentalWorkspaceAdapterListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceAdapterListError = - ExperimentalWorkspaceAdapterListErrors[keyof ExperimentalWorkspaceAdapterListErrors] - export type ExperimentalWorkspaceAdapterListResponses = { /** * Workspace adapters @@ -7094,15 +6563,6 @@ export type ExperimentalWorkspaceListData = { url: "/experimental/workspace" } -export type ExperimentalWorkspaceListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceListError = ExperimentalWorkspaceListErrors[keyof ExperimentalWorkspaceListErrors] - export type ExperimentalWorkspaceListResponses = { /** * Workspaces @@ -7118,7 +6578,7 @@ export type ExperimentalWorkspaceCreateData = { id?: string type: string branch: string | null - extra: unknown | null + extra?: unknown | null } path?: never query?: { @@ -7158,16 +6618,6 @@ export type ExperimentalWorkspaceStatusData = { url: "/experimental/workspace/status" } -export type ExperimentalWorkspaceStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceStatusError = - ExperimentalWorkspaceStatusErrors[keyof ExperimentalWorkspaceStatusErrors] - export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status @@ -7215,7 +6665,7 @@ export type ExperimentalWorkspaceRemoveResponse = export type ExperimentalWorkspaceWarpData = { body?: { - id: string | null + id: string sessionID: string } path?: never @@ -7258,10 +6708,6 @@ export type PtyConnectData = { } export type PtyConnectErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Forbidden */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1a2f1e947..db8889f1a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -218,16 +218,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get health information about the OpenCode server.", @@ -255,16 +245,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Subscribe to global events from the OpenCode system using server-sent events.", @@ -292,16 +272,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current global OpenCode configuration settings and preferences.", @@ -374,16 +344,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Clean up and dispose all OpenCode instances, releasing all resources.", @@ -510,16 +470,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get events", @@ -564,16 +514,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current OpenCode configuration settings and preferences.", @@ -696,16 +636,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all configured AI providers and their default models.", @@ -750,16 +680,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", @@ -837,16 +757,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the available Console orgs across logged-in accounts, including the current active org.", @@ -1083,16 +993,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List all sandbox worktrees for the current project.", @@ -1392,16 +1292,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", @@ -1450,16 +1340,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get all available MCP resources from connected servers. Optionally filter by name.", @@ -1576,16 +1456,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Search for text patterns across files in the project using ripgrep.", @@ -1670,16 +1540,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Search for files or directories by name or pattern in the project directory.", @@ -1736,16 +1596,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Search for workspace symbols like functions, classes, and variables using LSP.", @@ -1802,16 +1652,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List files and directories in a specified path.", @@ -1864,16 +1704,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Read the content of a specified file.", @@ -1922,16 +1752,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the git status of all files in the project.", @@ -1977,16 +1797,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", @@ -2031,16 +1841,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current working directory and related path information for the OpenCode instance.", @@ -2085,16 +1885,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", @@ -2152,16 +1942,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current git diff for the working tree or against the default branch.", @@ -2210,16 +1990,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available commands in the OpenCode system.", @@ -2268,16 +2038,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available AI agents in the OpenCode system.", @@ -2342,16 +2102,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available skills in the OpenCode system.", @@ -2400,16 +2150,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get LSP server status", @@ -2458,16 +2198,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get formatter status", @@ -2516,16 +2246,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the status of all Model Context Protocol (MCP) servers.", @@ -2673,7 +2393,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError | BadRequest", + "description": "McpUnsupportedOAuthError", "content": { "application/json": { "schema": { @@ -2751,16 +2471,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -2912,7 +2622,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError | BadRequest", + "description": "McpUnsupportedOAuthError", "content": { "application/json": { "schema": { @@ -2983,16 +2693,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Connect an MCP server.", @@ -3045,16 +2745,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Disconnect an MCP server.", @@ -3102,16 +2792,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of projects that have been opened with OpenCode.", @@ -3156,16 +2836,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the currently active project that OpenCode is working with.", @@ -3210,16 +2880,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Create a git repository for the current project and return the refreshed project info.", @@ -3393,16 +3053,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of available shells on the system.", @@ -3451,16 +3101,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", @@ -3600,16 +3240,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -3763,16 +3393,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -3848,16 +3468,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "403": { "description": "Forbidden", "content": { @@ -3925,16 +3535,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get all pending question requests across all sessions.", @@ -4151,16 +3751,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get all pending permission requests across all sessions.", @@ -4322,16 +3912,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available AI providers, including both available and connected ones.", @@ -4383,16 +3963,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve available authentication methods for all AI providers.", @@ -4666,16 +4236,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", @@ -5292,16 +4852,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the file changes (diff) that resulted from a specific user message in the session.", @@ -5792,16 +5342,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Create a new session by forking an existing session at a specific message point.", @@ -7128,16 +6668,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Start sync loops for workspaces in the current project that have active sessions.", @@ -7519,16 +7049,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Create a v2 session message and queue it for the agent loop.", @@ -7594,16 +7114,6 @@ "responses": { "204": { "description": "" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Compact a v2 session conversation.", @@ -7650,16 +7160,6 @@ "responses": { "204": { "description": "" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Wait for a v2 session agent loop to become idle.", @@ -7716,16 +7216,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", @@ -7905,16 +7395,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the help dialog in the TUI to display user assistance information.", @@ -7960,16 +7440,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the session dialog.", @@ -8015,16 +7485,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the theme dialog.", @@ -8070,16 +7530,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the model dialog.", @@ -8125,16 +7575,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Submit the prompt.", @@ -8180,16 +7620,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Clear the prompt.", @@ -8306,16 +7736,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Show a toast notification in the TUI.", @@ -8555,16 +7975,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the next TUI request from the queue for processing.", @@ -8610,16 +8020,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Submit a response to the TUI request queue to complete a pending request.", @@ -8688,16 +8088,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List all available workspace adapters for the current project.", @@ -8746,16 +8136,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List all workspaces.", @@ -8843,7 +8223,7 @@ ] } }, - "required": ["type", "branch", "extra"], + "required": ["type", "branch"], "additionalProperties": false } } @@ -8904,16 +8284,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get connection status for workspaces in the current project.", @@ -9098,16 +8468,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "403": { "description": "Forbidden", "content": { From 2740d398fa26df560eeb0566226004677c84d0c2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 11:37:18 +1000 Subject: [PATCH 166/174] devex: Enable Electron MCP servers with DevTools debug port (#25795) --- packages/desktop-electron/src/main/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index c9f16606a..af7fd4258 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -74,6 +74,7 @@ setupApp() function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") if (!app.requestSingleInstanceLock()) { app.quit() From edd480f56be832bd3daa871b5bbb6c124bc10a4e Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 4 May 2026 22:06:33 -0400 Subject: [PATCH 167/174] fix(tui): fix type error for calling workspace.warp (#25801) --- .../src/cli/cmd/tui/component/dialog-workspace-create.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index e2af0d63e..ad4063757 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -77,7 +77,7 @@ export async function warpWorkspaceSession(input: { }): Promise { const result = await input.sdk.client.experimental.workspace .warp({ - id: input.workspaceID, + id: input.workspaceID ?? undefined, sessionID: input.sessionID, }) .catch(() => undefined) From f6a3615f59e51dec879a7f8d0cce584b05d4c9e2 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 5 May 2026 10:15:00 +0800 Subject: [PATCH 168/174] fix(console): remove Cloudflare cache config from download fetch (#25804) --- .../app/src/routes/download/[channel]/[platform].ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index 4ae8e2465..7a4b5ef65 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -32,13 +32,6 @@ export async function GET({ params: { platform, channel } }: APIEvent) { const resp = await fetch( `https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`, - { - cf: { - // in case gh releases has rate limits - cacheTtl: 60 * 5, - cacheEverything: true, - }, - } as any, ) const downloadName = downloadNames[platform] From 0df2bb0f3b29b8b98d80c0bd3b1d5c8aac21098f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 4 May 2026 22:22:39 -0400 Subject: [PATCH 169/174] docs: restore v2 todo --- specs/v2/todo.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 specs/v2/todo.md diff --git a/specs/v2/todo.md b/specs/v2/todo.md new file mode 100644 index 000000000..3a4b9cf24 --- /dev/null +++ b/specs/v2/todo.md @@ -0,0 +1,59 @@ +# TODO + +ok we need to work towards a launch of v2 so we can get out of this rebuild phase + +## Kill Hono - Kit + +Hono needs to go away so zod can go away. this is almost done + +## New Data Mode - Dax + +This is mostly done. I'm working through modeling subagents, skill invocations +and shell commands. + +## Rework agent loop - Kit? + +I think this needs to be done so we can take advantage of the simpler data +model. It can stop doing all the + +## Rework compaction - Aiden? + +The new agent loop needs to trigger compaction properly + +## Plugin API design - ??? + +We need to figure out how we want server plugins to work and what hooks are useful. + +Some ideas: + +- plugins get immer drafts so bad mutations can be thrown away +- plugins get global "opencode" instance like in that post i showed +- opencode instance has stuff like `opencode.session.prompt()` or + `opencode.tool.register({...})` + +## Rework Config - ??? + +We should do another pass on config to clean up any mistakes we made with it and +simplify as much as possible. Old configs should get auto-converted to new + +## Auth - ??? + +I have a basic auth system that can track any kind of auth, not just providers + +## Model Database - ??? + +I have a basic model service that allows for models to be registered dynamically + +## Provider - ??? + +Providers should register as plugins and autoload based on whatever logic they +want / config. They should register models into model database + +## Event - Kit/James + +I have this v2/event.ts but it needs to be self contained instead of using the +old bus system + +## Everything is hotreloadable - ??? + +Instead of needing to tear down things when something changes every service should emit granular events so services can react to them and reconfigure themselves. Allows frontend to receive these too, eg model.added. also prevents startup from blocking From 39c88f9afb2281ae3df290f4d88acaf2f8e8398b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 4 May 2026 22:35:21 -0400 Subject: [PATCH 170/174] Improve v2 session message rendering (#25634) --- packages/core/src/global.ts | 2 + .../src/cli/cmd/tui/context/sync-v2.tsx | 16 +- .../tui/feature-plugins/system/session-v2.tsx | 193 +++++++++----- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/session/processor.ts | 9 +- .../opencode/src/session/projectors-next.ts | 6 +- packages/opencode/src/session/prompt.ts | 9 +- packages/opencode/src/v2/auth.ts | 246 ++++++++++++++++++ packages/opencode/src/v2/model.ts | 192 ++++++++++++++ packages/opencode/src/v2/session-event.ts | 23 +- .../src/v2/session-message-updater.ts | 6 +- packages/opencode/src/v2/session-message.ts | 12 +- packages/opencode/src/v2/session.ts | 76 ++++-- .../test/server/httpapi-session.test.ts | 7 +- .../test/v2/session-message-updater.test.ts | 19 +- specs/v2/session-concepts-gap.md | 131 ---------- specs/v2/todo.md | 4 +- 17 files changed, 677 insertions(+), 275 deletions(-) create mode 100644 packages/opencode/src/v2/auth.ts create mode 100644 packages/opencode/src/v2/model.ts delete mode 100644 specs/v2/session-concepts-gap.md diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 1acc3f47f..6560d308c 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -71,6 +71,8 @@ export const layer = Layer.effect( Effect.sync(() => Service.of(make())), ) +export const defaultLayer = layer + export const layerWith = (input: Partial) => Layer.effect( Service, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index 9801f0a2f..d9d23999d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" function activeAssistant(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) if (index < 0) return const assistant = messages[index] return assistant?.type === "assistant" ? assistant : undefined } function activeCompaction(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "compaction") + const index = messages.findIndex((message) => message.type === "compaction") if (index < 0) return const compaction = messages[index] return compaction?.type === "compaction" ? compaction : undefined } function activeShell(messages: SessionMessage[], callID: string) { - const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) if (index < 0) return const shell = messages[index] return shell?.type === "shell" ? shell : undefined @@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( switch (event.type) { case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "user", text: event.properties.prompt.text, @@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( } case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "synthetic", sessionID: event.properties.sessionID, @@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "shell", callID: event.properties.callID, @@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( update(event.properties.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.push({ + draft.unshift({ id: event.id, type: "assistant", agent: event.properties.agent, @@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "compaction", reason: event.properties.reason, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 7270a9c3b..2e5cea980 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { SyntaxStyle } from "@opentui/core" +import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const renderedMessages = createMemo(() => messages().toReversed()) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + const lastUserCreated = (index: number) => + renderedMessages() + .slice(0, index) + .findLast((message) => message.type === "user")?.time.created createEffect(() => { void sync.session.message.sync(props.sessionID) @@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { last={lastAssistant()?.id === message.id} syntax={syntax()} subtleSyntax={subtleSyntax()} + start={lastUserCreated(index())} /> - + <> @@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) { - - - } - > - {props.message.text} - - - - - {(file) => ( - - {file.mime} - {file.name ?? file.uri} - - )} - - - {(agent) => ( - - agent - {agent.name} - - )} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - ) -} - -function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { - const { theme } = useTheme() - return ( - - Synthetic {props.message.text} + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + ) } @@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) { } function CompactionMessage(props: { message: SessionMessageCompaction }) { - const { theme } = useTheme() + const { theme, syntax } = useTheme() return ( - {props.message.summary} + {(summary) => ( + + + + )} ) @@ -294,12 +284,13 @@ function AssistantMessage(props: { last: boolean syntax: SyntaxStyle subtleSyntax: SyntaxStyle + start?: number }) { const { theme } = useTheme() const local = useLocal() const duration = createMemo(() => { if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created + return props.message.time.completed - (props.start ?? props.message.time.created) }) const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" @@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta const { theme } = useTheme() return ( - + (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const complete = createMemo(() => !!props.complete) const denied = createMemo(() => { const message = error() if (!message) return false return ( message.includes("QuestionRejectedError") || message.includes("rejected permission") || + message.includes("specified a rule") || message.includes("user dismissed") ) }) + const fg = createMemo(() => { + if (error()) return theme.error + if (complete()) return theme.textMuted + return theme.text + }) + const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) return ( - - - - {props.children} - - - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - - - - - {error()} - + error() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (!error()) return + if (renderer.getSelection()?.getSelectedText()) return + setShowError((prev) => !prev) + }} + renderBefore={function () { + const el = this as BoxRenderable + const parent = el.parent + if (!parent) return + const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.id.startsWith("text")) setMargin(1) + }} + > + + + + + + + + {props.icon} + + + + + ~ + + + + + + + + + + {props.children} + + + + + {props.pending} + + + + + + + {error()} + + + ) } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 46c210fa5..6d9a6447a 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,7 @@ const prefixes = { tool: "tool", workspace: "wrk", entry: "ent", + account: "act", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index cf1a7e0ae..f22da9292 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,6 +22,7 @@ import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 @@ -432,9 +433,9 @@ export const layer: Layer.Layer< sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { - id: ctx.model.id, - providerID: ctx.model.providerID, - variant: input.assistantMessage.variant, + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), @@ -655,7 +656,7 @@ export const layer: Layer.Layer< EventV2.run(SessionEvent.Step.Failed.Sync, { sessionID: ctx.sessionID, error: { - type: error.name, + type: "unknown", message: errorMessage(e), }, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 88f73acf1..93298170c 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -132,11 +132,7 @@ export default [ SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { db.update(SessionTable) .set({ - model: { - id: data.id, - providerID: data.providerID, - variant: data.variant, - }, + model: data.model, time_updated: DateTime.toEpochMillis(data.timestamp), }) .where(eq(SessionTable.id, data.sessionID)) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0590fc382..e1fa81abf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,6 +56,7 @@ import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" @@ -978,9 +979,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - id: info.model.modelID, - providerID: info.model.providerID, - variant: info.model.variant, + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + }, }) } diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts new file mode 100644 index 000000000..1cc443974 --- /dev/null +++ b/packages/opencode/src/v2/auth.ts @@ -0,0 +1,246 @@ +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "@opencode-ai/core/util/identifier" +import { NonNegativeInt, withStatics } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { AppFileSystem } from "@opencode-ai/core/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const AccountID = Schema.String.pipe( + Schema.brand("AccountID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type AccountID = typeof AccountID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "AuthV2.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Account extends Schema.Class("AuthV2.Account")({ + id: AccountID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type AuthError = AuthFileWriteError + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const accountID = AccountID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Account({ + id: accountID, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = accountID + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (accountID: AccountID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + active?: boolean + }) => Effect.Effect + readonly update: ( + accountID: AccountID, + updates: Partial>, + ) => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly activate: (accountID: AccountID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const file = path.join(global.data, "auth-v2.json") + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) + } catch {} + } + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } + + if ("version" in raw && raw.version === 2) return raw as Writable + + const migrated = migrate(raw as Record) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe(yield* load()) + + const result: Interface = { + get: Effect.fn("AuthV2.get")(function* (accountID) { + return (yield* SynchronizedRef.get(state)).accounts[accountID] + }), + + all: Effect.fn("AuthV2.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("AuthV2.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("AuthV2.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("AuthV2.add")(function* (input) { + return yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = new Account({ + id: AccountID.make(Identifier.ascending()), + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: + (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) + ? { ...data.active, [input.serviceID]: account.id } + : data.active, + } + + yield* write(next) + return [account, next] as const + }), + ) + }), + + update: Effect.fn("AuthV2.update")(function* (accountID, updates) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const existing = data.accounts[accountID] + if (!existing) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [accountID]: new Account({ + id: accountID, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("AuthV2.remove")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) + delete active[accounts[accountID].serviceID] + delete accounts[accountID] + + const next = { ...data, accounts, active } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + activate: Effect.fn("AuthV2.activate")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = data.accounts[accountID] + if (!account) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as AuthV2 from "./auth" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts new file mode 100644 index 000000000..db66199a5 --- /dev/null +++ b/packages/opencode/src/v2/model.ts @@ -0,0 +1,192 @@ +import { withStatics } from "@/util/schema" +import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" + +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) +export type ID = typeof ID.Type + +export const ProviderID = Schema.String.pipe( + Schema.brand("Model.ProviderID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ProviderID = typeof ProviderID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( + Schema.toTaggedUnion("type"), +) +export type Endpoint = typeof Endpoint.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("Model.Info")({ + id: ID, + providerID: ProviderID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) {} + +export function parse(input: string): { providerID: ProviderID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export interface Interface { + readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> + readonly add: (model: Info) => Effect.Effect + readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (provider: ProviderID) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/v2/Model") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let models = HashMap.empty() + + function key(providerID: ProviderID, modelID: ID) { + return `${providerID}/${modelID}` + } + + const result: Interface = { + get: Effect.fn("V2Model.get")(function* (providerID, modelID) { + return HashMap.get(models, key(providerID, modelID)) + }), + + add: Effect.fn("V2Model.add")(function* (model) { + models = HashMap.set(models, key(model.providerID, model.id), model) + }), + + remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { + models = HashMap.remove(models, key(providerID, modelID)) + }), + + all: Effect.fn("V2Model.all")(function* () { + return pipe( + models, + HashMap.toValues, + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + default: Effect.fn("V2Model.default")(function* () { + const all = yield* result.all() + return Option.fromUndefinedOr(all[0]) + }), + + small: Effect.fn("V2Model.small")(function* (providerID) { + const all = yield* result.all() + const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) + return Option.fromUndefinedOr(match) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer + +export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 47938dcbe..7c768bd55 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } import { ToolOutput } from "./tool-output" -import { ModelID, ProviderID } from "@/provider/schema" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -22,10 +22,13 @@ const Base = { sessionID: SessionID, } -const Error = Schema.Struct({ - type: Schema.String, +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), message: Schema.String, +}).annotate({ + identifier: "Session.Error.Unknown", }) +export type UnknownError = Schema.Schema.Type export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", @@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({ version: 1, schema: { ...Base, - id: ModelID, - providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), + model: Modelv2.Ref, }, }) export type ModelSwitched = Schema.Schema.Type @@ -103,11 +104,7 @@ export namespace Step { schema: { ...Base, agent: Schema.String, - model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - variant: Schema.String.pipe(Schema.optional), - }), + model: Modelv2.Ref, snapshot: Schema.String.pipe(Schema.optional), }, }) @@ -139,7 +136,7 @@ export namespace Step { aggregate: "sessionID", schema: { ...Base, - error: Error, + error: UnknownError, }, }) export type Failed = Schema.Schema.Type @@ -296,7 +293,7 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - error: Error, + error: UnknownError, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index d5d5aac7b..80ecb1011 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -109,11 +109,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve id: event.id, type: "model-switched", metadata: event.metadata, - model: { - id: event.data.id, - providerID: event.data.providerID, - variant: event.data.variant, - }, + model: event.data.model, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 94f6b1cac..024e28c45 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event" import { EventV2 } from "./event" import { ToolOutput } from "./tool-output" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class("Session.Message. export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ ...Base, type: Schema.Literal("model-switched"), - model: Schema.Struct({ - id: SessionEvent.ModelSwitched.fields.data.fields.id, - providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, - variant: SessionEvent.ModelSwitched.fields.data.fields.variant, - }), + model: Modelv2.Ref, }) {} export class User extends Schema.Class("Session.Message.User")({ @@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class("Session.Messag input: Schema.Record(Schema.String, Schema.Unknown), content: ToolOutput.Content.pipe(Schema.Array), structured: ToolOutput.Structured, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: SessionEvent.UnknownError, }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1f4cbcf1e..bb86f039b 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceID } from "@/control-plane/schema" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import * as Database from "@/storage/db" -import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" import { SessionMessage } from "./session-message" import type { Prompt } from "./session-prompt" import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@/util/schema" +import { Modelv2 } from "./model" -export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", }) export type Delivery = Schema.Schema.Type @@ -27,11 +27,7 @@ export class Info extends Schema.Class("Session.Info")({ workspaceID: optionalOmitUndefined(WorkspaceID), path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), - model: Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: optionalOmitUndefined(Schema.String), - }).pipe(optionalOmitUndefined), + model: Modelv2.Ref.pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, @@ -53,7 +49,18 @@ export class Info extends Schema.Class("Session.Info")({ */ }) {} +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: SessionID, +}) {} + export interface Interface { + readonly create: (input?: { + agent?: string + model?: Modelv2.Ref + parentID?: SessionID + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly get: (sessionID: SessionID) => Effect.Effect readonly list: (input: { limit?: number order?: "asc" | "desc" @@ -88,13 +95,15 @@ export interface Interface { }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly subagent: (input: { + id?: EventV2.ID + parentID: SessionID + prompt: Prompt + agent: string + model?: Modelv2.Ref + }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { - sessionID: SessionID - id: ModelID - providerID: ProviderID - variant?: string - }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } @@ -120,9 +129,9 @@ export const layer = Layer.effect( agent: row.agent ?? undefined, model: row.model ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), - variant: row.model.variant, + id: Modelv2.ID.make(row.model.id), + providerID: Modelv2.ProviderID.make(row.model.providerID), + variant: Modelv2.VariantID.make(row.model.variant ?? "default"), } : undefined, time: { @@ -134,6 +143,14 @@ export const layer = Layer.effect( } const result: Interface = { + create: Effect.fn("V2Session.create")(function* (_input) { + return {} as any + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), list: Effect.fn("V2Session.list")(function* (input) { const direction = input.cursor?.direction ?? "next" let order = input.order ?? "desc" @@ -262,11 +279,30 @@ export const layer = Layer.effect( EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), - id: input.id, - providerID: input.providerID, - variant: input.variant, + model: input.model, }) }), + subagent: Effect.fn("V2Session.subagent")(function* (input) { + const parent = yield* result.get(input.parentID) + const session = yield* result.create({ + agent: input.agent, + model: input.model, + parentID: input.parentID, + workspaceID: parent.workspaceID, + }) + yield* result.prompt({ + prompt: input.prompt, + sessionID: session.id, + }) + yield* Effect.gen(function* () { + yield* result.wait(session.id) + const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + const assistant = messages.find((msg) => msg.type === "assistant") + if (!assistant) return + const text = assistant.content.findLast((part) => part.type === "text") + if (!text) return + }).pipe(Effect.forkChild()) + }), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), } diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c9a0b53bb..34cecd80d 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -19,6 +19,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "../../src/v2/session-message" +import { Modelv2 } from "../../src/v2/model" import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" @@ -214,7 +215,11 @@ describe("session HttpApi", () => { id: SessionMessage.ID.create(), type: "assistant", agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, time: { created: DateTime.makeUnsafe(1) }, content: [], }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 128177167..44ac031ed 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "../../src/v2/event" +import { Modelv2 } from "../../src/v2/model" import { SessionEvent } from "../../src/v2/session-event" import { SessionMessageUpdater } from "../../src/v2/session-message-updater" @@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, snapshot: "before", }, } satisfies SessionEvent.Event) @@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) @@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md deleted file mode 100644 index 20d84c8f4..000000000 --- a/specs/v2/session-concepts-gap.md +++ /dev/null @@ -1,131 +0,0 @@ -# Session V2 Concept Gaps - -Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. - -## Message Metadata - -- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. -- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. - -## Output Format - -- Text output format. -- JSON-schema output format. -- Structured-output retry count. -- Structured assistant result payload. -- Structured-output error classification. - -## Errors - -- Aborted error. -- Provider auth error. -- API error with status, retryability, headers, body, and metadata. -- Context-overflow error. -- Output-length error. -- Unknown error. -- V2 mostly reduces assistant errors to strings, except retry errors. - -## Part Identity - -- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. -- V2 assistant content does not preserve stable per-content IDs. -- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. - -## Part Timing And Metadata - -- V1 text, reasoning, and tool states carry timing and provider metadata. -- V2 assistant text and reasoning content only store text. -- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. - -## Snapshots And Patches - -- Snapshot parts. -- Patch parts. -- Step-start snapshot references. -- Step-finish snapshot references. -- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. - -## Step Boundaries - -- V1 stores `step-start` and `step-finish` as first-class parts. -- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. -- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. - -## Compaction - -- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. -- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. -- V1 also has history filtering semantics around completed summary messages and retained tails. - -## Files And Sources - -- V1 file parts have `mime`, `filename`, `url`, and typed source information. -- V1 source variants include file, symbol, and resource sources. -- Symbol sources include LSP range, name, and kind. -- Resource sources include client name and URI. -- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. - -## Agents And Subtasks - -- Agent parts. -- Subtask parts. -- Subtask prompt, description, agent, model, and command. -- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. - -## Text Flags - -- Synthetic text flag. -- Ignored text flag. -- V2 has a separate synthetic entry, but no ignored text concept. - -## Tool Calls - -- V1 pending tool state stores parsed input and raw input text separately. -- V2 pending tool state stores a string input but does not preserve a separate raw field. -- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. -- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. -- V1 error tool state has `time.start` and `time.end`. -- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. -- V1 tracks provider execution and provider call metadata. -- V2 events include provider info, but `SessionEntryStepper` drops it from entries. -- V1 has tool-output compaction and truncation behavior via `time.compacted`. - -## Media Handling - -- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. -- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. -- V2 has attachments but not these model-message conversion semantics. - -## Retries - -- V1 stores retries as independently addressable retry parts. -- V2 stores retries as an assistant aggregate. -- V2 captures some retry information, but not the independent part identity/update model. - -## Processor Control Flow - -- Session status transitions: busy, retry, and idle. -- Retry policy integration. -- Context-overflow-driven compaction. -- Abort and interrupt handling. -- Permission-denied blocking. -- Doom-loop detection. -- Plugin hook for `experimental.text.complete`. -- Background summary generation after steps. -- Cleanup semantics for open text, reasoning, and tool calls. - -## Sync And Bus Events - -- Message updated. -- Message removed. -- Message part updated. -- Message part delta. -- Message part removed. -- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. - -## History Retrieval - -- Cursor encoding and decoding. -- Paged message retrieval. -- Reverse streaming through history. -- Compaction-aware history filtering. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 3a4b9cf24..77c650e55 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -20,7 +20,7 @@ model. It can stop doing all the The new agent loop needs to trigger compaction properly -## Plugin API design - ??? +## Plugin API design - James? We need to figure out how we want server plugins to work and what hooks are useful. @@ -49,7 +49,7 @@ I have a basic model service that allows for models to be registered dynamically Providers should register as plugins and autoload based on whatever logic they want / config. They should register models into model database -## Event - Kit/James +## Event - Kit I have this v2/event.ts but it needs to be self contained instead of using the old bus system From 75d141b574b94e304c5222daecd4aa68bb9df1e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 22:36:06 -0400 Subject: [PATCH 171/174] fix(session): cancel subtask child sessions (#25798) --- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/tool/task.ts | 27 +- packages/opencode/test/session/prompt.test.ts | 37 ++ packages/opencode/test/tool/task.test.ts | 518 ++++++++++-------- 4 files changed, 339 insertions(+), 247 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e1fa81abf..8286ecf8e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import z from "zod" import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -121,9 +120,8 @@ export const layer = Layer.effect( return yield* EffectBridge.make() }) const ops = Effect.fn("SessionPrompt.ops")(function* () { - const run = yield* runner() return { - cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), + cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input), } satisfies TaskPromptOps diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e58ea9b12..22e4e5671 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,10 +6,11 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Schema } from "effect" +import { Effect, Exit, Schema } from "effect" +import { EffectBridge } from "@/effect/bridge" export interface TaskPromptOps { - cancel(sessionID: SessionID): void + cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } @@ -118,16 +119,18 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + const runCancel = yield* EffectBridge.make() const messageID = MessageID.ascending() + const cancel = ops.cancel(nextSession.id) - function cancel() { - ops.cancel(nextSession.id) + function onAbort() { + runCancel.fork(cancel) } return yield* Effect.acquireUseRelease( Effect.sync(() => { - ctx.abort.addEventListener("abort", cancel) + ctx.abort.addEventListener("abort", onAbort) }), () => Effect.gen(function* () { @@ -163,10 +166,16 @@ export const TaskTool = Tool.define( ].join("\n"), } }), - () => - Effect.sync(() => { - ctx.abort.removeEventListener("abort", cancel) - }), + (_, exit) => + Effect.gen(function* () { + if (Exit.hasInterrupts(exit)) yield* cancel + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.abort.removeEventListener("abort", onAbort) + }), + ), + ), ) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a602c0c8d..c5170f346 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -858,6 +858,43 @@ it.live( 30_000, ) +it.live( + "cancel propagates from slash command subtask to child session", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined + expect(typeof sessionID).toBe("string") + if (typeof sessionID !== "string") throw new Error("missing child session id") + const childID = SessionID.make(sessionID) + expect((yield* status.get(childID)).type).toBe("busy") + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + expect((yield* status.get(chat.id)).type).toBe("idle") + expect((yield* status.get(childID)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "cancel with queued callers resolves all cleanly", () => diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index a8d62bb68..f75fcf84b 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,18 +1,17 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Instance } from "../../src/project/instance" import { Session } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -35,6 +34,14 @@ const it = testEffect( ), ) +function defer() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const session = yield* Session.Service const chat = yield* session.create({ title }) @@ -66,7 +73,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { @@ -107,102 +114,270 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa } describe("tool.task", () => { - it.live("description sorts subagents by name and is stable across calls", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const get = Effect.fnUntraced(function* () { - const tools = yield* registry.tools({ ...ref, agent: build }) - return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" - }) - const first = yield* get() - const second = yield* get() + it.instance( + "description sorts subagents by name and is stable across calls", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() - expect(first).toBe(second) + expect(first).toBe(second) - const alpha = first.indexOf("- alpha: Alpha agent") - const explore = first.indexOf("- explore:") - const general = first.indexOf("- general:") - const zebra = first.indexOf("- zebra: Zebra agent") + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) - }), - { - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("description hides denied subagents for the caller", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" + it.instance( + "description hides denied subagents for the caller", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" - expect(description).toContain("- alpha: Alpha agent") - expect(description).not.toContain("- zebra: Zebra agent") - }), - { - config: { - permission: { - task: { - "*": "allow", - zebra: "deny", - }, + expect(description).toContain("- alpha: Alpha agent") + expect(description).not.toContain("- zebra: Zebra agent") + }), + { + config: { + permission: { + task: { + "*": "allow", + zebra: "deny", }, - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + }, + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("execute resumes an existing task session from task_id", () => - provideTmpdirInstance(() => + it.instance("execute resumes an existing task session from task_id", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(child.id) + expect(result.metadata.sessionId).toBe(child.id) + expect(result.output).toContain(`task_id: ${child.id}`) + expect(seen?.sessionID).toBe(child.id) + }), + ) + + it.instance("execute asks by default and skips checks when bypassed", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const exec = (extra?: Record) => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + yield* exec() + yield* exec({ bypassAgentCheck: true }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + ) + + it.instance("execute cancels child session when abort signal fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ready = defer() + const cancelled = defer() + const abort = new AbortController() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.promise(() => { + ready.resolve(input) + return cancelled.promise + }).pipe(Effect.as(reply(input, "cancelled"))), + } + + const fiber = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.forkChild) + + const input = yield* Effect.promise(() => ready.promise) + abort.abort() + expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.instance("execute creates a child when task_id does not exist", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: "ses_missing", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(result.metadata.sessionId).not.toBe("ses_missing") + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + }), + ) + + it.instance( + "execute shapes child permissions for task, todowrite, and primary tools", + () => Effect.gen(function* () { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() - const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", - subagent_type: "general", - task_id: child.id, + subagent_type: "reviewer", }, { sessionID: chat.id, @@ -216,172 +391,45 @@ describe("tool.task", () => { }, ) - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(child.id) - expect(result.metadata.sessionId).toBe(child.id) - expect(result.output).toContain(`task_id: ${child.id}`) - expect(seen?.sessionID).toBe(child.id) - }), - ), - ) - - it.live("execute asks by default and skips checks when bypassed", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - const calls: unknown[] = [] - const promptOps = stubOps() - - const exec = (extra?: Record) => - def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps, ...extra }, - messages: [], - metadata: () => Effect.void, - ask: (input) => - Effect.sync(() => { - calls.push(input) - }), - }, - ) - - yield* exec() - yield* exec({ bypassAgentCheck: true }) - - expect(calls).toHaveLength(1) - expect(calls[0]).toEqual({ - permission: "task", - patterns: ["general"], - always: ["*"], - metadata: { - description: "inspect bug", - subagent_type: "general", + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.parentID).toBe(chat.id) + expect(child.permission).toEqual([ + { + permission: "todowrite", + pattern: "*", + action: "deny", }, + { + permission: "bash", + pattern: "*", + action: "allow", + }, + { + permission: "read", + pattern: "*", + action: "allow", + }, + ]) + expect(seen?.tools).toEqual({ + todowrite: false, + bash: false, + read: false, }) }), - ), - ) - - it.live("execute creates a child when task_id does not exist", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - task_id: "ses_missing", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(result.metadata.sessionId) - expect(result.metadata.sessionId).not.toBe("ses_missing") - expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) - expect(seen?.sessionID).toBe(result.metadata.sessionId) - }), - ), - ) - - it.live("execute shapes child permissions for task, todowrite, and primary tools", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "reviewer", + { + config: { + agent: { + reviewer: { + mode: "subagent", + permission: { + task: "allow", }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const child = yield* sessions.get(result.metadata.sessionId) - expect(child.parentID).toBe(chat.id) - expect(child.permission).toEqual([ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "bash", - pattern: "*", - action: "allow", - }, - { - permission: "read", - pattern: "*", - action: "allow", - }, - ]) - expect(seen?.tools).toEqual({ - todowrite: false, - bash: false, - read: false, - }) - }), - { - config: { - agent: { - reviewer: { - mode: "subagent", - permission: { - task: "allow", - }, - }, - }, - experimental: { - primary_tools: ["bash", "read"], }, }, + experimental: { + primary_tools: ["bash", "read"], + }, }, - ), + }, ) }) From 2d0a757eb2dbeabad64af02a9fb3602d4ccefd5b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 02:37:07 +0000 Subject: [PATCH 172/174] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 61 +++++----- packages/sdk/openapi.json | 146 +++++++++--------------- 2 files changed, 83 insertions(+), 124 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c0255754d..7734ca53e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1875,9 +1875,11 @@ export type SyncEventSessionNextModelSwitched = { data: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -1948,7 +1950,7 @@ export type SyncEventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -1987,10 +1989,7 @@ export type SyncEventSessionNextStepFailed = { data: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2188,10 +2187,7 @@ export type SyncEventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -2616,9 +2612,11 @@ export type EventSessionNextModelSwitched = { properties: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -2693,7 +2691,7 @@ export type EventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -2720,16 +2718,18 @@ export type EventSessionNextStepEnded = { } } +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + export type EventSessionNextStepFailed = { id: string type: "session.next.step.failed" properties: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2900,10 +2900,7 @@ export type EventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -2994,7 +2991,7 @@ export type SessionInfo = { model?: { id: string providerID: string - variant?: string + variant: string } time: { created: number @@ -3030,7 +3027,7 @@ export type SessionMessageModelSwitched = { model: { id: string providerID: string - variant?: string + variant: string } } @@ -3124,10 +3121,7 @@ export type SessionMessageToolStateError = { structured: { [key: string]: unknown } - error: { - type: string - message: string - } + error: SessionErrorUnknown } export type SessionMessageAssistantTool = { @@ -3167,7 +3161,7 @@ export type SessionMessageAssistant = { model: { id: string providerID: string - variant?: string + variant: string } content: Array snapshot?: { @@ -3185,10 +3179,7 @@ export type SessionMessageAssistant = { write: number } } - error?: { - type: string - message: string - } + error?: SessionErrorUnknown } export type SessionMessageCompaction = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index db8889f1a..fea9dd5a9 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13998,17 +13998,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -14231,7 +14238,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -14357,17 +14364,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -14979,17 +14976,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -16267,17 +16254,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -16496,7 +16490,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -16580,6 +16574,20 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "SessionErrorUnknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, "EventSessionNextStepFailed": { "type": "object", "properties": { @@ -16600,17 +16608,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -17113,17 +17111,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -17376,7 +17364,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "time": { @@ -17472,7 +17460,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false } }, @@ -17731,17 +17719,7 @@ "type": "object" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["status", "input", "content", "structured", "error"], @@ -17854,7 +17832,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "content": { @@ -17921,17 +17899,7 @@ "additionalProperties": false }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["id", "time", "type", "agent", "model", "content"], From 07f1c8c0ac3d08e32c46a73c71786717b5472879 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 14:26:35 +1000 Subject: [PATCH 173/174] fix(desktop): stabilize Windows titlebar zoom (#25813) --- packages/app/src/components/titlebar.tsx | 21 ++++++++++++++++--- packages/desktop-electron/src/main/ipc.ts | 9 ++++++-- packages/desktop-electron/src/main/windows.ts | 14 ++++++++++--- .../src/renderer/webview-zoom.ts | 15 +++++++++---- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 409fcbeff..eafea591a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -35,6 +35,9 @@ type TauriApi = { const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() +const titlebarHeight = 40 +const minTitlebarZoom = 0.25 +const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. export function Titlebar() { const layout = useLayout() @@ -51,7 +54,14 @@ export function Titlebar() { const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") const zoom = () => platform.webviewZoom?.() ?? 1 - const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) + const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) + const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) + const minHeight = () => { + if (mac()) return `${titlebarHeight / zoom()}px` + if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px` + return undefined + } + const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const [history, setHistory] = createStore({ stack: [] as string[], @@ -165,12 +175,16 @@ export function Titlebar() { return (
+
- {!tauriApi() &&
} + {!tauriApi() &&
}
+
) } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 8dbca8eea..241361373 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -11,7 +11,7 @@ import type { WslConfig, } from "../preload/types" import { getStore } from "./store" -import { setTitlebar } from "./windows" +import { setTitlebar, updateTitlebar } from "./windows" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -183,7 +183,12 @@ export function registerIpcHandlers(deps: Deps) { }) ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) + ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { + event.sender.setZoomFactor(factor) + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + updateTitlebar(win) + }) ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 337e1ca0b..387e793b0 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -21,6 +21,8 @@ protocol.registerSchemesAsPrivileged([ ]) let backgroundColor: string | undefined +const titlebarThemes = new WeakMap>() +const titlebarHeight = 40 export function setBackgroundColor(color: string) { backgroundColor = color @@ -43,18 +45,23 @@ function tone() { return nativeTheme.shouldUseDarkColors ? "dark" : "light" } -function overlay(theme: Partial = {}) { +function overlay(theme: Partial = {}, zoom = 1) { const mode = theme.mode ?? tone() return { color: "#00000000", symbolColor: mode === "dark" ? "white" : "black", - height: 40, + height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)), } } export function setTitlebar(win: BrowserWindow, theme: Partial = {}) { + titlebarThemes.set(win, theme) + updateTitlebar(win) +} + +export function updateTitlebar(win: BrowserWindow) { if (process.platform !== "win32") return - win.setTitleBarOverlay(overlay(theme)) + win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor())) } export function setDockIcon() { @@ -188,6 +195,7 @@ function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { win.webContents.setZoomFactor(1) + updateTitlebar(win) }) } diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 6e13266f4..967ff54eb 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -12,6 +12,7 @@ const OS_NAME = (() => { })() const [webviewZoom, setWebviewZoom] = createSignal(1) +let requestedZoom = 1 const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 @@ -19,8 +20,14 @@ const MIN_ZOOM_LEVEL = 0.2 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) const applyZoom = (next: number) => { - setWebviewZoom(next) - void window.api.setZoomFactor(next) + requestedZoom = next + void window.api.setZoomFactor(next).then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }).catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) } window.addEventListener("keydown", (event) => { @@ -28,12 +35,12 @@ window.addEventListener("keydown", (event) => { if (event.key === "-") { event.preventDefault() - applyZoom(clamp(webviewZoom() - 0.2)) + applyZoom(clamp(requestedZoom - 0.2)) return } if (event.key === "=" || event.key === "+") { event.preventDefault() - applyZoom(clamp(webviewZoom() + 0.2)) + applyZoom(clamp(requestedZoom + 0.2)) return } if (event.key === "0") { From 6f7d63e9ceaacc5debbfcba18bf8391a90e59e8f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 04:27:38 +0000 Subject: [PATCH 174/174] chore: generate --- packages/app/src/components/titlebar.tsx | 266 +++++++++--------- .../src/renderer/webview-zoom.ts | 17 +- 2 files changed, 143 insertions(+), 140 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index eafea591a..2917b7adb 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -185,151 +185,151 @@ export function Titlebar() { class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center" style={{ zoom: counterZoom() }} > -
- -
-
- -
- - -
- -
-
-
- - - - -
-
-
+
+
+
-
-
- - {!tauriApi() &&
} -
- -
+
+
+ + {!tauriApi() &&
} +
+ +
) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 967ff54eb..cb4b5a448 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -21,13 +21,16 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z const applyZoom = (next: number) => { requestedZoom = next - void window.api.setZoomFactor(next).then(() => { - if (requestedZoom !== next) return - setWebviewZoom(next) - }).catch(() => { - if (requestedZoom !== next) return - requestedZoom = webviewZoom() - }) + void window.api + .setZoomFactor(next) + .then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }) + .catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) } window.addEventListener("keydown", (event) => {