Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53242d581b | ||
|
|
7d85813a0f | ||
|
|
ef96fdfd83 | ||
|
|
241b7b5792 | ||
|
|
53d45b2894 | ||
|
|
9d11b62ff0 | ||
|
|
1c453bd4e1 | ||
|
|
4f455f1869 | ||
|
|
d33517c6b5 | ||
|
|
99c758e343 | ||
|
|
27de1ee982 | ||
|
|
8e0676ebea | ||
|
|
990a705063 | ||
|
|
33a78ead3d | ||
|
|
3739eee8cf | ||
|
|
816f5e526b | ||
|
|
63abe037d8 | ||
|
|
f2733330f7 | ||
|
|
c41dc21292 | ||
|
|
f80bf7a423 | ||
|
|
13ea36f575 | ||
|
|
02b7367b4e | ||
|
|
aa4cf681a9 | ||
|
|
f1c0b2651a | ||
|
|
eab9a4f564 | ||
|
|
012fc184bc | ||
|
|
0ed88cfc21 | ||
|
|
98689fb125 | ||
|
|
62bd7c82d3 | ||
|
|
f98449c9b5 | ||
|
|
468eb68878 | ||
|
|
23b594de6e | ||
|
|
5060577ee1 | ||
|
|
d3ebb1f7c0 | ||
|
|
e4cc4e1682 | ||
|
|
53e89f9d52 | ||
|
|
de247b7aae | ||
|
|
0b8050d453 | ||
|
|
be6a89a3b8 | ||
|
|
c0a8b509c7 | ||
|
|
f80651fa91 | ||
|
|
68af95390d | ||
|
|
321db7a819 | ||
|
|
6d2219e001 | ||
|
|
dd432e3cde | ||
|
|
77db212a0a | ||
|
|
f56263791c | ||
|
|
88363f1ed9 | ||
|
|
c5db39f626 | ||
|
|
b5aed287ca | ||
|
|
53849bd866 | ||
|
|
e33912bfee | ||
|
|
548648a3d9 | ||
|
|
4643e13170 | ||
|
|
042e6a5c86 | ||
|
|
e36d6a0cbe | ||
|
|
cc9c0b15c7 | ||
|
|
f3b0d3d7ac | ||
|
|
764c6bc517 | ||
|
|
d441e931f9 | ||
|
|
ad79ad9ea8 | ||
|
|
d6b23fd8f6 | ||
|
|
5911bd532d | ||
|
|
2385123f03 | ||
|
|
09549661e1 |
@@ -7,6 +7,7 @@ on:
|
||||
- ci
|
||||
- dev
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {},
|
||||
"permission": {},
|
||||
"mcp": {},
|
||||
"mcp": {
|
||||
"opencode": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:43110/mcp"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
|
||||
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
اذا كنت تعمل على مشروع مرتبط بـ OpenCode ويستخدم "opencode" كجزء من اسمه (مثل "opencode-dashboard" او "opencode-mobile")، يرجى اضافة ملاحظة في README توضح انه ليس مبنيا بواسطة فريق OpenCode ولا يرتبط بنا بأي شكل.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### ما الفرق عن Claude Code؟
|
||||
|
||||
هو مشابه جدا لـ Claude Code من حيث القدرات. هذه هي الفروقات الاساسية:
|
||||
|
||||
- 100% مفتوح المصدر
|
||||
- غير مقترن بمزود معين. نوصي بالنماذج التي نوفرها عبر [OpenCode Zen](https://opencode.ai/zen)؛ لكن يمكن استخدام OpenCode مع Claude او OpenAI او Google او حتى نماذج محلية. مع تطور النماذج ستتقلص الفجوات وستنخفض الاسعار، لذا من المهم ان يكون مستقلا عن المزود.
|
||||
- دعم LSP جاهز للاستخدام
|
||||
- تركيز على TUI. تم بناء OpenCode بواسطة مستخدمي neovim ومنشئي [terminal.shop](https://terminal.shop)؛ وسندفع حدود ما هو ممكن داخل الطرفية.
|
||||
- معمارية عميل/خادم. على سبيل المثال، يمكن تشغيل OpenCode على جهازك بينما تقوده عن بعد من تطبيق جوال. هذا يعني ان واجهة TUI هي واحدة فقط من العملاء الممكنين.
|
||||
|
||||
---
|
||||
|
||||
**انضم الى مجتمعنا** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়ে
|
||||
|
||||
আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই।
|
||||
|
||||
### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
|
||||
|
||||
#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা?
|
||||
|
||||
ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো:
|
||||
|
||||
- ১০০% ওপেন সোর্স
|
||||
- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ।
|
||||
- আউট-অফ-দ্য-বক্স LSP সাপোর্ট
|
||||
- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি।
|
||||
- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি।
|
||||
|
||||
---
|
||||
|
||||
**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Se você tem interesse em contribuir com o OpenCode, leia os [contributing docs]
|
||||
|
||||
Se você estiver trabalhando em um projeto relacionado ao OpenCode e estiver usando "opencode" como parte do nome (por exemplo, "opencode-dashboard" ou "opencode-mobile"), adicione uma nota no README para deixar claro que não foi construído pela equipe do OpenCode e não é afiliado a nós de nenhuma forma.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Como isso é diferente do Claude Code?
|
||||
|
||||
É muito parecido com o Claude Code em termos de capacidade. Aqui estão as principais diferenças:
|
||||
|
||||
- 100% open source
|
||||
- Não está acoplado a nenhum provedor. Embora recomendemos os modelos que oferecemos pelo [OpenCode Zen](https://opencode.ai/zen); o OpenCode pode ser usado com Claude, OpenAI, Google ou até modelos locais. À medida que os modelos evoluem, as diferenças diminuem e os preços caem, então ser provider-agnostic é importante.
|
||||
- Suporte a LSP pronto para uso
|
||||
- Foco em TUI. O OpenCode é construído por usuários de neovim e pelos criadores do [terminal.shop](https://terminal.shop); vamos levar ao limite o que é possível no terminal.
|
||||
- Arquitetura cliente/servidor. Isso, por exemplo, permite executar o OpenCode no seu computador enquanto você o controla remotamente por um aplicativo mobile. Isso significa que o frontend TUI é apenas um dos possíveis clientes.
|
||||
|
||||
---
|
||||
|
||||
**Junte-se à nossa comunidade** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIB
|
||||
|
||||
Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Po čemu se razlikuje od Claude Code-a?
|
||||
|
||||
Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
|
||||
|
||||
- 100% open source
|
||||
- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
|
||||
- LSP podrška odmah po instalaciji
|
||||
- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
|
||||
- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
|
||||
|
||||
---
|
||||
|
||||
**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Hvis du vil bidrage til OpenCode, så læs vores [contributing docs](./CONTRIBUT
|
||||
|
||||
Hvis du arbejder på et projekt der er relateret til OpenCode og bruger "opencode" som en del af navnet; f.eks. "opencode-dashboard" eller "opencode-mobile", så tilføj en note i din README, der tydeliggør at projektet ikke er bygget af OpenCode-teamet og ikke er tilknyttet os på nogen måde.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Hvordan adskiller dette sig fra Claude Code?
|
||||
|
||||
Det minder meget om Claude Code i forhold til funktionalitet. Her er de vigtigste forskelle:
|
||||
|
||||
- 100% open source
|
||||
- Ikke låst til en udbyder. Selvom vi anbefaler modellerne via [OpenCode Zen](https://opencode.ai/zen); kan OpenCode bruges med Claude, OpenAI, Google eller endda lokale modeller. Efterhånden som modeller udvikler sig vil forskellene mindskes og priserne falde, så det er vigtigt at være provider-agnostic.
|
||||
- LSP-support out of the box
|
||||
- Fokus på TUI. OpenCode er bygget af neovim-brugere og skaberne af [terminal.shop](https://terminal.shop); vi vil skubbe grænserne for hvad der er muligt i terminalen.
|
||||
- Klient/server-arkitektur. Det kan f.eks. lade OpenCode køre på din computer, mens du styrer den eksternt fra en mobilapp. Det betyder at TUI-frontend'en kun er en af de mulige clients.
|
||||
|
||||
---
|
||||
|
||||
**Bliv en del af vores community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Wenn du zu OpenCode beitragen möchtest, lies bitte unsere [Contributing Docs](.
|
||||
|
||||
Wenn du an einem Projekt arbeitest, das mit OpenCode zusammenhängt und "opencode" als Teil seines Namens verwendet (z.B. "opencode-dashboard" oder "opencode-mobile"), füge bitte einen Hinweis in deine README ein, dass es nicht vom OpenCode-Team gebaut wird und nicht in irgendeiner Weise mit uns verbunden ist.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Worin unterscheidet sich das von Claude Code?
|
||||
|
||||
In Bezug auf die Fähigkeiten ist es Claude Code sehr ähnlich. Hier sind die wichtigsten Unterschiede:
|
||||
|
||||
- 100% open source
|
||||
- Nicht an einen Anbieter gekoppelt. Wir empfehlen die Modelle aus [OpenCode Zen](https://opencode.ai/zen); OpenCode kann aber auch mit Claude, OpenAI, Google oder sogar lokalen Modellen genutzt werden. Mit der Weiterentwicklung der Modelle werden die Unterschiede kleiner und die Preise sinken, deshalb ist Provider-Unabhängigkeit wichtig.
|
||||
- LSP-Unterstützung direkt nach dem Start
|
||||
- Fokus auf TUI. OpenCode wird von Neovim-Nutzern und den Machern von [terminal.shop](https://terminal.shop) gebaut; wir treiben die Grenzen dessen, was im Terminal möglich ist.
|
||||
- Client/Server-Architektur. Das ermöglicht z.B., OpenCode auf deinem Computer laufen zu lassen, während du es von einer mobilen App aus fernsteuerst. Das TUI-Frontend ist nur einer der möglichen Clients.
|
||||
|
||||
---
|
||||
|
||||
**Tritt unserer Community bei** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
+9
-21
@@ -97,20 +97,20 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Agents
|
||||
### Agentes
|
||||
|
||||
OpenCode incluye dos agents integrados que puedes alternar con la tecla `Tab`.
|
||||
OpenCode incluye dos agentes integrados que puedes alternar con la tecla `Tab`.
|
||||
|
||||
- **build** - Por defecto, agent con acceso completo para trabajo de desarrollo
|
||||
- **plan** - Agent de solo lectura para análisis y exploración de código
|
||||
- Niega ediciones de archivos por defecto
|
||||
- **build** - Por defecto, agente con acceso completo para tareas de desarrollo
|
||||
- **plan** - Agente de solo lectura para análisis y exploración de código
|
||||
- Deniega ediciones de archivos por defecto
|
||||
- Pide permiso antes de ejecutar comandos bash
|
||||
- Ideal para explorar codebases desconocidas o planificar cambios
|
||||
|
||||
Además, incluye un subagent **general** para búsquedas complejas y tareas de varios pasos.
|
||||
Además, incluye un subagente **general** para búsquedas complejas y tareas de varios pasos.
|
||||
Se usa internamente y se puede invocar con `@general` en los mensajes.
|
||||
|
||||
Más información sobre [agents](https://opencode.ai/docs/agents).
|
||||
Más información sobre [agentes](https://opencode.ai/docs/agents).
|
||||
|
||||
### Documentación
|
||||
|
||||
@@ -120,21 +120,9 @@ Para más información sobre cómo configurar OpenCode, [**ve a nuestra document
|
||||
|
||||
Si te interesa contribuir a OpenCode, lee nuestras [docs de contribución](./CONTRIBUTING.md) antes de enviar un pull request.
|
||||
|
||||
### Construyendo sobre OpenCode
|
||||
### Proyectos basados en OpenCode
|
||||
|
||||
Si estás trabajando en un proyecto relacionado con OpenCode y usas "opencode" como parte del nombre; por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está construido por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### ¿En qué se diferencia de Claude Code?
|
||||
|
||||
Es muy similar a Claude Code en cuanto a capacidades. Estas son las diferencias clave:
|
||||
|
||||
- 100% open source
|
||||
- No está acoplado a ningún proveedor. Aunque recomendamos los modelos que ofrecemos a través de [OpenCode Zen](https://opencode.ai/zen); OpenCode se puede usar con Claude, OpenAI, Google o incluso modelos locales. A medida que evolucionan los modelos, las brechas se cerrarán y los precios bajarán, por lo que ser agnóstico al proveedor es importante.
|
||||
- Soporte LSP listo para usar
|
||||
- Un enfoque en la TUI. OpenCode está construido por usuarios de neovim y los creadores de [terminal.shop](https://terminal.shop); vamos a empujar los límites de lo que es posible en la terminal.
|
||||
- Arquitectura cliente/servidor. Esto, por ejemplo, permite ejecutar OpenCode en tu computadora mientras lo controlas de forma remota desde una app móvil. Esto significa que el frontend TUI es solo uno de los posibles clientes.
|
||||
Si estás trabajando en un proyecto basado en OpenCode y usas "opencode" como parte del nombre, por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está hecho por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -124,18 +124,6 @@ Si vous souhaitez contribuer à OpenCode, lisez nos [docs de contribution](./CON
|
||||
|
||||
Si vous travaillez sur un projet lié à OpenCode et que vous utilisez "opencode" dans le nom du projet (par exemple, "opencode-dashboard" ou "opencode-mobile"), ajoutez une note dans votre README pour préciser qu'il n'est pas construit par l'équipe OpenCode et qu'il n'est pas affilié à nous.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### En quoi est-ce différent de Claude Code ?
|
||||
|
||||
C'est très similaire à Claude Code en termes de capacités. Voici les principales différences :
|
||||
|
||||
- 100% open source
|
||||
- Pas couplé à un fournisseur. Nous recommandons les modèles proposés via [OpenCode Zen](https://opencode.ai/zen) ; OpenCode peut être utilisé avec Claude, OpenAI, Google ou même des modèles locaux. Au fur et à mesure que les modèles évoluent, les écarts se réduiront et les prix baisseront, donc être agnostique au fournisseur est important.
|
||||
- Support LSP prêt à l'emploi
|
||||
- Un focus sur la TUI. OpenCode est construit par des utilisateurs de neovim et les créateurs de [terminal.shop](https://terminal.shop) ; nous allons repousser les limites de ce qui est possible dans le terminal.
|
||||
- Architecture client/serveur. Cela permet par exemple de faire tourner OpenCode sur votre ordinateur tout en le pilotant à distance depuis une application mobile. Cela signifie que la TUI n'est qu'un des clients possibles.
|
||||
|
||||
---
|
||||
|
||||
**Rejoignez notre communauté** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
|
||||
|
||||
### Συχνές Ερωτήσεις
|
||||
|
||||
#### Πώς διαφέρει αυτό από το Claude Code;
|
||||
|
||||
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
|
||||
|
||||
- 100% ανοιχτού κώδικα
|
||||
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
|
||||
- Out-of-the-box υποστήριξη LSP
|
||||
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
|
||||
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
|
||||
|
||||
---
|
||||
|
||||
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contrib
|
||||
|
||||
Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### In cosa è diverso da Claude Code?
|
||||
|
||||
È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze:
|
||||
|
||||
- 100% open source
|
||||
- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con l’evoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante.
|
||||
- Supporto LSP pronto all’uso
|
||||
- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale.
|
||||
- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite un’app mobile. La frontend TUI è quindi solo uno dei possibili client.
|
||||
|
||||
---
|
||||
|
||||
**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode に貢献したい場合は、Pull Request を送る前に [contributin
|
||||
|
||||
OpenCode に関連するプロジェクトで、名前に "opencode"(例: "opencode-dashboard" や "opencode-mobile")を含める場合は、そのプロジェクトが OpenCode チームによって作られたものではなく、いかなる形でも関係がないことを README に明記してください。
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Claude Code との違いは?
|
||||
|
||||
機能面では Claude Code と非常に似ています。主な違いは次のとおりです。
|
||||
|
||||
- 100% オープンソース
|
||||
- 特定のプロバイダーに依存しません。[OpenCode Zen](https://opencode.ai/zen) で提供しているモデルを推奨しますが、OpenCode は Claude、OpenAI、Google、またはローカルモデルでも利用できます。モデルが進化すると差は縮まり価格も下がるため、provider-agnostic であることが重要です。
|
||||
- そのまま使える LSP サポート
|
||||
- TUI にフォーカス。OpenCode は neovim ユーザーと [terminal.shop](https://terminal.shop) の制作者によって作られており、ターミナルで可能なことの限界を押し広げます。
|
||||
- クライアント/サーバー構成。例えば OpenCode をあなたのPCで動かし、モバイルアプリからリモート操作できます。TUI フロントエンドは複数あるクライアントの1つにすぎません。
|
||||
|
||||
---
|
||||
|
||||
**コミュニティに参加** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode 에 기여하고 싶다면, Pull Request 를 제출하기 전에 [contr
|
||||
|
||||
OpenCode 와 관련된 프로젝트를 진행하면서 이름에 "opencode"(예: "opencode-dashboard" 또는 "opencode-mobile") 를 포함한다면, README 에 해당 프로젝트가 OpenCode 팀이 만든 것이 아니며 어떤 방식으로도 우리와 제휴되어 있지 않다는 점을 명시해 주세요.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Claude Code 와는 무엇이 다른가요?
|
||||
|
||||
기능 면에서는 Claude Code 와 매우 유사합니다. 주요 차이점은 다음과 같습니다.
|
||||
|
||||
- 100% 오픈 소스
|
||||
- 특정 제공자에 묶여 있지 않습니다. [OpenCode Zen](https://opencode.ai/zen) 을 통해 제공하는 모델을 권장하지만, OpenCode 는 Claude, OpenAI, Google 또는 로컬 모델과도 사용할 수 있습니다. 모델이 발전하면서 격차는 줄고 가격은 내려가므로 provider-agnostic 인 것이 중요합니다.
|
||||
- 기본으로 제공되는 LSP 지원
|
||||
- TUI 에 집중. OpenCode 는 neovim 사용자와 [terminal.shop](https://terminal.shop) 제작자가 만들었으며, 터미널에서 가능한 것의 한계를 밀어붙입니다.
|
||||
- 클라이언트/서버 아키텍처. 예를 들어 OpenCode 를 내 컴퓨터에서 실행하면서 모바일 앱으로 원격 조작할 수 있습니다. 즉, TUI 프런트엔드는 가능한 여러 클라이언트 중 하나일 뿐입니다.
|
||||
|
||||
---
|
||||
|
||||
**커뮤니티에 참여하기** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
||||
|
||||
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Hvis du vil bidra til OpenCode, les [contributing docs](./CONTRIBUTING.md) før
|
||||
|
||||
Hvis du jobber med et prosjekt som er relatert til OpenCode og bruker "opencode" som en del av navnet; for eksempel "opencode-dashboard" eller "opencode-mobile", legg inn en merknad i README som presiserer at det ikke er bygget av OpenCode-teamet og ikke er tilknyttet oss på noen måte.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Hvordan er dette forskjellig fra Claude Code?
|
||||
|
||||
Det er veldig likt Claude Code når det gjelder funksjonalitet. Her er de viktigste forskjellene:
|
||||
|
||||
- 100% open source
|
||||
- Ikke knyttet til en bestemt leverandør. Selv om vi anbefaler modellene vi tilbyr gjennom [OpenCode Zen](https://opencode.ai/zen); kan OpenCode brukes med Claude, OpenAI, Google eller til og med lokale modeller. Etter hvert som modellene utvikler seg vil gapene lukkes og prisene gå ned, så det er viktig å være provider-agnostic.
|
||||
- LSP-støtte rett ut av boksen
|
||||
- Fokus på TUI. OpenCode er bygget av neovim-brukere og skaperne av [terminal.shop](https://terminal.shop); vi kommer til å presse grensene for hva som er mulig i terminalen.
|
||||
- Klient/server-arkitektur. Dette kan for eksempel la OpenCode kjøre på maskinen din, mens du styrer den eksternt fra en mobilapp. Det betyr at TUI-frontend'en bare er en av de mulige klientene.
|
||||
|
||||
---
|
||||
|
||||
**Bli med i fellesskapet** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Jeśli chcesz współtworzyć OpenCode, przeczytaj [contributing docs](./CONTRIB
|
||||
|
||||
Jeśli pracujesz nad projektem związanym z OpenCode i używasz "opencode" jako części nazwy (na przykład "opencode-dashboard" lub "opencode-mobile"), dodaj proszę notatkę do swojego README, aby wyjaśnić, że projekt nie jest tworzony przez zespół OpenCode i nie jest z nami w żaden sposób powiązany.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Czym to się różni od Claude Code?
|
||||
|
||||
Jest bardzo podobne do Claude Code pod względem możliwości. Oto kluczowe różnice:
|
||||
|
||||
- 100% open source
|
||||
- Niezależne od dostawcy. Chociaż polecamy modele oferowane przez [OpenCode Zen](https://opencode.ai/zen); OpenCode może być używany z Claude, OpenAI, Google, a nawet z modelami lokalnymi. W miarę jak modele ewoluują, różnice będą się zmniejszać, a ceny spadać, więc ważna jest niezależność od dostawcy.
|
||||
- Wbudowane wsparcie LSP
|
||||
- Skupienie na TUI. OpenCode jest budowany przez użytkowników neovim i twórców [terminal.shop](https://terminal.shop); przesuwamy granice tego, co jest możliwe w terminalu.
|
||||
- Architektura klient/serwer. Pozwala np. uruchomić OpenCode na twoim komputerze, a sterować nim zdalnie z aplikacji mobilnej. To znaczy, że frontend TUI jest tylko jednym z możliwych klientów.
|
||||
|
||||
---
|
||||
|
||||
**Dołącz do naszej społeczności** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
Если вы делаете проект, связанный с OpenCode, и используете "opencode" как часть имени (например, "opencode-dashboard" или "opencode-mobile"), добавьте примечание в README, чтобы уточнить, что проект не создан командой OpenCode и не аффилирован с нами.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Чем это отличается от Claude Code?
|
||||
|
||||
По возможностям это очень похоже на Claude Code. Вот ключевые отличия:
|
||||
|
||||
- 100% open source
|
||||
- Не привязано к одному провайдеру. Мы рекомендуем модели из [OpenCode Zen](https://opencode.ai/zen); но OpenCode можно использовать с Claude, OpenAI, Google или даже локальными моделями. По мере развития моделей разрыв будет сокращаться, а цены падать, поэтому важна независимость от провайдера.
|
||||
- Поддержка LSP из коробки
|
||||
- Фокус на TUI. OpenCode построен пользователями neovim и создателями [terminal.shop](https://terminal.shop); мы будем раздвигать границы того, что возможно в терминале.
|
||||
- Архитектура клиент/сервер. Например, это позволяет запускать OpenCode на вашем компьютере, а управлять им удаленно из мобильного приложения. Это значит, что TUI-фронтенд - лишь один из возможных клиентов.
|
||||
|
||||
---
|
||||
|
||||
**Присоединяйтесь к нашему сообществу** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode รวมเอเจนต์ในตัวสองตัวที
|
||||
|
||||
หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด
|
||||
|
||||
### คำถามที่พบบ่อย
|
||||
|
||||
#### ต่างจาก Claude Code อย่างไร?
|
||||
|
||||
คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก:
|
||||
|
||||
- โอเพนซอร์ส 100%
|
||||
- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ
|
||||
- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ
|
||||
- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล
|
||||
- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น
|
||||
|
||||
---
|
||||
|
||||
**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermede
|
||||
|
||||
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
|
||||
|
||||
### SSS
|
||||
|
||||
#### Bu Claude Code'dan nasıl farklı?
|
||||
|
||||
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
|
||||
|
||||
- %100 açık kaynak
|
||||
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
|
||||
- Kurulum gerektirmeyen hazır LSP desteği
|
||||
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
|
||||
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
|
||||
|
||||
---
|
||||
|
||||
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -125,18 +125,6 @@ OpenCode містить два вбудовані агенти, між яким
|
||||
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
||||
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
||||
|
||||
### FAQ
|
||||
|
||||
#### Чим це відрізняється від Claude Code?
|
||||
|
||||
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
||||
|
||||
- 100% open source
|
||||
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
|
||||
- Підтримка LSP з коробки
|
||||
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
|
||||
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
|
||||
|
||||
---
|
||||
|
||||
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -124,18 +124,6 @@ Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hư
|
||||
|
||||
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
|
||||
|
||||
### Các câu hỏi thường gặp (FAQ)
|
||||
|
||||
#### OpenCode khác biệt thế nào so với Claude Code?
|
||||
|
||||
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
|
||||
|
||||
- 100% mã nguồn mở
|
||||
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
|
||||
- Hỗ trợ LSP ngay từ đầu
|
||||
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
|
||||
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
|
||||
|
||||
---
|
||||
|
||||
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -123,18 +123,6 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
|
||||
|
||||
### 常见问题 (FAQ)
|
||||
|
||||
#### 这和 Claude Code 有什么不同?
|
||||
|
||||
功能上很相似,关键差异:
|
||||
|
||||
- 100% 开源。
|
||||
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
|
||||
- 内置 LSP 支持。
|
||||
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
|
||||
- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -123,18 +123,6 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
|
||||
|
||||
### 常見問題 (FAQ)
|
||||
|
||||
#### 這跟 Claude Code 有什麼不同?
|
||||
|
||||
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
|
||||
|
||||
- 100% 開源。
|
||||
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
||||
- 內建 LSP (語言伺服器協定) 支援。
|
||||
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
|
||||
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Effect Service Dependency Graph — Simulated Routes
|
||||
|
||||
Generated for `createSimulatedRoutes` in `packages/opencode/src/server/routes/instance/httpapi/server.ts`.
|
||||
|
||||
## Notation
|
||||
|
||||
- `→ X` means "yields `X.Service` from its `Effect.gen` body at layer init (a true `RIn` of `.layer`)"
|
||||
- `(lazy)` means "uses `InstanceState.context` or similar at call time, not at layer construction"
|
||||
- `(opt)` means "uses `Effect.serviceOption(X)` — not strictly required"
|
||||
- `(internal)` means "satisfied internally by the `.layer` itself via `Layer.provide(...)`, NOT a residual requirement"
|
||||
|
||||
## Service → Dependencies (current, post-rebase)
|
||||
|
||||
```
|
||||
─── External / Platform ─────────────────────────────────────────
|
||||
NodePath (no app deps) provides Path.Path
|
||||
FetchHttpClient (no app deps) provides HttpClient.HttpClient
|
||||
HttpServer.layerServices (no app deps)
|
||||
ChildProcessSpawner (from SimulationSpawner) (no app deps)
|
||||
|
||||
─── Leaf services (no app deps) ─────────────────────────────────
|
||||
Global (no app deps)
|
||||
Env (no app deps — uses InstanceState.make, no Service yields)
|
||||
Bus (no app deps — uses InstanceState.make, no Service yields)
|
||||
SyncEvent → RuntimeFlags, Bus (NEW — previously listed as leaf/lazy)
|
||||
AccountRepo (no app deps — pure DB closures)
|
||||
PtyTicket (no app deps — Cache only)
|
||||
Truncate → AppFileSystem
|
||||
|
||||
─── Middleware/route layers (no app service deps) ───────────────
|
||||
errorLayer
|
||||
compressionLayer → HttpServerRequest (builtin)
|
||||
corsVaryFix
|
||||
fenceLayer → HttpServerRequest (builtin)
|
||||
simulationShareNextLayer provides ShareNext (Layer.succeed override)
|
||||
|
||||
─── Simulation overrides ────────────────────────────────────────
|
||||
SimulationFileSystem provides AppFileSystem (does NOT provide FileSystem.FileSystem;
|
||||
tier0 also merges FileSystem.layerNoop({}) for that tag)
|
||||
SimulationSpawner provides ChildProcessSpawner (Layer.succeed; no deps)
|
||||
SimulationNetwork provides SimulationNetwork.Service + HttpClient.HttpClient
|
||||
(httpClientLayer composed inside `layer(options)`)
|
||||
SimulationGit → AppFileSystem (overrides Git tag)
|
||||
SimulationProvider → Simulation (overrides Provider tag)
|
||||
Simulation → AppFileSystem, SimulationNetwork
|
||||
|
||||
─── Core services ───────────────────────────────────────────────
|
||||
EffectFlock → Global, AppFileSystem
|
||||
Auth → AppFileSystem
|
||||
McpAuth → AppFileSystem
|
||||
Account → AccountRepo, HttpClient
|
||||
Npm → AppFileSystem, Global, FileSystem.FileSystem, EffectFlock
|
||||
Config → AppFileSystem, Auth, Account, Env, Npm
|
||||
Permission → Bus
|
||||
Plugin → Bus, Config, RuntimeFlags (NEW: RuntimeFlags)
|
||||
Discovery → AppFileSystem, Path, HttpClient
|
||||
Skill → Discovery, Config, Bus, AppFileSystem, Global,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
SystemPrompt → Skill
|
||||
|
||||
─── File / git ──────────────────────────────────────────────────
|
||||
Ripgrep → AppFileSystem, HttpClient, ChildProcessSpawner
|
||||
File → AppFileSystem, Ripgrep, Git, Scope
|
||||
FileWatcher → Config, Git
|
||||
Format → Config, AppProcess, RuntimeFlags (CHANGED: was ChildProcessSpawner; now AppProcess + RuntimeFlags)
|
||||
Snapshot → AppFileSystem, AppProcess, Config (CHANGED: was ChildProcessSpawner)
|
||||
Storage → AppFileSystem, Git
|
||||
Vcs → Git, Bus, Scope
|
||||
Worktree → Scope, AppFileSystem, Path, AppProcess,
|
||||
Git, Project, InstanceStore (CHANGED: AppProcess instead of ChildProcessSpawner)
|
||||
Project → AppFileSystem, Path, ChildProcessSpawner,
|
||||
Bus, RuntimeFlags (NEW: RuntimeFlags)
|
||||
|
||||
─── Provider / LSP / MCP ────────────────────────────────────────
|
||||
ModelsDev → AppFileSystem, HttpClient
|
||||
ProviderAuth → Auth, Plugin
|
||||
LSP → Config, RuntimeFlags (NEW: RuntimeFlags)
|
||||
McpAuth → AppFileSystem
|
||||
MCP → ChildProcessSpawner, McpAuth, Bus, Config
|
||||
|
||||
─── Session graph ───────────────────────────────────────────────
|
||||
Todo → Bus
|
||||
Question → Bus
|
||||
SessionStatus → Bus
|
||||
SessionRunState → BackgroundJob, SessionStatus (NEW: BackgroundJob)
|
||||
Instruction → Config, AppFileSystem, Global, HttpClient,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
|
||||
Session → BackgroundJob, Bus, Storage, SyncEvent,
|
||||
RuntimeFlags (NEW: BackgroundJob, RuntimeFlags)
|
||||
SessionSummary → Session, Snapshot, Storage, Bus
|
||||
|
||||
SessionRevert → Session, Snapshot, Storage, Bus,
|
||||
SessionSummary, SessionRunState, SyncEvent
|
||||
LLM → Auth, Config, Provider, Plugin, RuntimeFlags (CHANGED: Permission satisfied internally;
|
||||
(Permission satisfied internally via .layer) RuntimeFlags is new)
|
||||
|
||||
Agent → Config, Auth, Plugin, Skill, Provider,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
Command → Config, MCP, Skill
|
||||
|
||||
SessionProcessor → Session, Config, Bus, Snapshot, Agent, LLM,
|
||||
Permission, Plugin, SessionSummary,
|
||||
SessionStatus, Image, EventV2Bridge,
|
||||
RuntimeFlags, Scope (NEW: Image, EventV2Bridge, RuntimeFlags)
|
||||
SessionCompaction → Bus, Config, Session, Agent, Plugin,
|
||||
SessionProcessor, Provider, EventV2Bridge,
|
||||
RuntimeFlags (NEW: EventV2Bridge, RuntimeFlags)
|
||||
SessionPrompt → Bus, SessionStatus, Session, Agent, Provider,
|
||||
SessionProcessor, SessionCompaction, Plugin,
|
||||
Command, Config, Permission, AppFileSystem,
|
||||
MCP, LSP, ToolRegistry, Truncate,
|
||||
ChildProcessSpawner, Scope, Instruction,
|
||||
SessionRunState, SessionRevert,
|
||||
SessionSummary, SystemPrompt, LLM,
|
||||
Image, Reference, EventV2Bridge,
|
||||
RuntimeFlags (NEW: Image, Reference, EventV2Bridge, RuntimeFlags)
|
||||
|
||||
ToolRegistry → Config, Plugin, Question, Todo, Agent, Skill,
|
||||
Session, SessionStatus, BackgroundJob,
|
||||
Provider, Git, Reference, LSP, Instruction,
|
||||
AppFileSystem, Bus, HttpClient,
|
||||
ChildProcessSpawner, Ripgrep, Format,
|
||||
Truncate, RuntimeFlags (NEW: BackgroundJob, Reference, RuntimeFlags)
|
||||
|
||||
─── Share / Workspace ───────────────────────────────────────────
|
||||
ShareNext (provided by simulationShareNextLayer in sim)
|
||||
SessionShare → Config, Session, ShareNext, Scope, SyncEvent,
|
||||
RuntimeFlags (NEW: RuntimeFlags)
|
||||
Workspace → Auth, Session, SessionPrompt, HttpClient,
|
||||
SyncEvent, Vcs, AppFileSystem, RuntimeFlags (NEW: AppFileSystem (explicit), RuntimeFlags)
|
||||
|
||||
─── Misc ────────────────────────────────────────────────────────
|
||||
Installation → HttpClient, AppProcess (CHANGED: AppProcess instead of ChildProcessSpawner)
|
||||
Pty → Config, Bus, Plugin
|
||||
|
||||
─── Instance lifecycle ──────────────────────────────────────────
|
||||
InstanceBootstrap → Config, File, FileWatcher, Format, LSP, Plugin,
|
||||
Project, Reference, ShareNext, Snapshot, Vcs (NEW: Reference)
|
||||
InstanceStore → Project, InstanceBootstrap, Scope
|
||||
|
||||
Observability (no app deps; provides Logger + tracer)
|
||||
```
|
||||
|
||||
## NEW dependencies introduced since previous graph
|
||||
|
||||
The rebase pulled in several new cross-cutting services that need to be
|
||||
satisfied somewhere in tier0/tier1 of the simulated chain. They are NOT yet
|
||||
listed in the `Tier*Services` unions or merged into any tier in `server.ts`:
|
||||
|
||||
```
|
||||
RuntimeFlags.Service — yielded by ~17 services (Plugin, Skill, Project,
|
||||
Session, SyncEvent, Format, LSP, Instruction,
|
||||
Agent, LLM, SessionShare, SessionProcessor,
|
||||
SessionCompaction, ToolRegistry, SessionPrompt,
|
||||
Workspace, SessionRunState).
|
||||
Source: `@/effect/runtime-flags`.
|
||||
|
||||
AppProcess.Service — yielded by Installation, Format, Snapshot,
|
||||
Worktree (and prod Git, but sim uses SimulationGit).
|
||||
Source: presumed `@opencode-ai/core/app-process`
|
||||
or similar — needs `AppProcess.defaultLayer`.
|
||||
|
||||
BackgroundJob.Service — yielded by Session, SessionRunState, ToolRegistry.
|
||||
Needs `BackgroundJob.defaultLayer`.
|
||||
|
||||
Image.Service — yielded by SessionProcessor, SessionPrompt.
|
||||
|
||||
EventV2Bridge.Service — yielded by SessionProcessor, SessionCompaction,
|
||||
SessionPrompt. Source: `@/event-v2-bridge` (already
|
||||
imported in server.ts but never added to a tier).
|
||||
|
||||
Reference.Service — yielded by ToolRegistry, SessionPrompt,
|
||||
InstanceBootstrap.
|
||||
```
|
||||
|
||||
## Dependency Tiers (topological order)
|
||||
|
||||
Roughly, build order from leaves to roots:
|
||||
|
||||
```
|
||||
Tier 0 (no deps):
|
||||
Global, Env, NodePath, AccountRepo, PtyTicket, Bus, SyncEvent,
|
||||
AppFileSystem (sim), ChildProcessSpawner (sim), HttpClient (sim),
|
||||
SimulationNetwork, FileSystem.layerNoop, simulationShareNextLayer
|
||||
+ (NEW REQUIRED) RuntimeFlags, AppProcess, BackgroundJob, Image,
|
||||
EventV2Bridge, Reference
|
||||
(SyncEvent now depends on RuntimeFlags + Bus, so it's actually tier 1.)
|
||||
|
||||
Tier 1:
|
||||
Auth, Truncate, EffectFlock, Permission, Todo, Question,
|
||||
SessionStatus, McpAuth, Discovery, SimulationGit, Ripgrep, Account,
|
||||
SyncEvent (needs RuntimeFlags + Bus)
|
||||
|
||||
Tier 2:
|
||||
Npm, ModelsDev, Project, Installation, Storage, Vcs, SessionRunState
|
||||
|
||||
Tier 3:
|
||||
Config, File, Simulation, Session
|
||||
|
||||
Tier 4:
|
||||
Plugin, FileWatcher, Format, Snapshot, LSP, MCP, Skill, Instruction,
|
||||
SimulationProvider (= Provider)
|
||||
|
||||
Tier 5:
|
||||
Pty, ProviderAuth, SessionSummary, Agent, Command, LLM, SystemPrompt
|
||||
|
||||
Tier 6:
|
||||
SessionRevert, SessionProcessor, SessionShare
|
||||
|
||||
Tier 7:
|
||||
SessionCompaction, ToolRegistry
|
||||
|
||||
Tier 8:
|
||||
SessionPrompt
|
||||
|
||||
Tier 9:
|
||||
Workspace, InstanceBootstrap
|
||||
|
||||
Tier 10:
|
||||
InstanceStore
|
||||
|
||||
Tier 11:
|
||||
Worktree
|
||||
```
|
||||
|
||||
## Potential cycles / hazards
|
||||
|
||||
```
|
||||
Worktree → InstanceStore → InstanceBootstrap → Project → (back to Worktree?)
|
||||
- InstanceBootstrap requires Project (yes)
|
||||
- Project does NOT require Worktree directly
|
||||
- Worktree requires InstanceStore at layer init
|
||||
→ Worktree must be built AFTER InstanceStore.
|
||||
|
||||
SimulationProvider provides Provider tag, depends on Simulation.
|
||||
Many downstream services depend on Provider — those resolve to
|
||||
SimulationProvider in this layer chain.
|
||||
|
||||
SimulationGit provides Git tag, used by File, FileWatcher,
|
||||
Storage, Vcs, Worktree, ToolRegistry tools.
|
||||
|
||||
LLM.layer pipes Layer.provide(Permission.defaultLayer) internally.
|
||||
So LLM's residual requirements no longer include Permission, BUT the
|
||||
sim chain still provides Permission.layer separately (correct — used by
|
||||
SessionProcessor, SessionPrompt directly).
|
||||
```
|
||||
|
||||
## Why the simulated chain fails today
|
||||
|
||||
The current `server.ts` `Tier0Services` union lists:
|
||||
```
|
||||
AppFileSystem, FileSystem.FileSystem, ChildProcessSpawner, HttpClient,
|
||||
SimulationNetwork, Path, Global, Env, Bus, AccountRepo, ShareNext,
|
||||
SyncEvent, PtyTicket
|
||||
```
|
||||
|
||||
But the actual `Layer.mergeAll(...)` body at tier0 has residual requirements
|
||||
beyond that union. The TS error says
|
||||
`Layer<..., never, Service | Service>` — those two unresolved `Service`s
|
||||
are members of the NEW dependencies table above.
|
||||
|
||||
The most likely culprits, in order of leakage:
|
||||
|
||||
1. **`SyncEvent.layer`** now yields `RuntimeFlags` and `Bus`. `Bus` is in tier0
|
||||
already, but `RuntimeFlags` is not provided anywhere in `createSimulatedRoutes`.
|
||||
So `SyncEvent` leaks `RuntimeFlags` into tier0's `RIn`.
|
||||
|
||||
2. Downstream tiers also leak `RuntimeFlags`, `AppProcess`, `BackgroundJob`,
|
||||
`Image`, `EventV2Bridge`, `Reference` — these cascade through every higher
|
||||
tier as `Service | Service | ...` in the error type.
|
||||
|
||||
## Fix strategy
|
||||
|
||||
The minimal change to make tier0 type-check:
|
||||
|
||||
1. Add `RuntimeFlags.defaultLayer` to tier0 and `RuntimeFlags.Service` to
|
||||
`Tier0Services`. This satisfies `SyncEvent`'s new dep and unblocks every
|
||||
service that yields `RuntimeFlags`.
|
||||
2. Add `AppProcess.defaultLayer` (or a simulated equivalent) to tier0 and
|
||||
`AppProcess.Service` to `Tier0Services`. Needed by `Installation`, `Format`,
|
||||
`Snapshot`, `Worktree`.
|
||||
3. Add `BackgroundJob.defaultLayer` to tier0 and `BackgroundJob.Service` to
|
||||
`Tier0Services`. Needed by `Session`, `SessionRunState`, `ToolRegistry`.
|
||||
4. Add `Image.defaultLayer` to tier0 (or wherever it fits) and `Image.Service`
|
||||
to `Tier0Services`. Needed by `SessionProcessor`, `SessionPrompt`.
|
||||
5. Add `EventV2Bridge.defaultLayer` to tier0 and `EventV2Bridge.Service` to
|
||||
`Tier0Services`. (Note: `EventV2Bridge` is already imported in `server.ts`
|
||||
for the production routes but is not in any simulated tier.)
|
||||
6. Add `Reference.defaultLayer` to a tier that satisfies its deps (it's a leaf
|
||||
wrt the listed graph above) and `Reference.Service` to the corresponding
|
||||
tier union. Needed by `ToolRegistry`, `SessionPrompt`, `InstanceBootstrap`.
|
||||
|
||||
Production routes (`createProductionRoutes`) already include
|
||||
`RuntimeFlags.defaultLayer` and `EventV2Bridge.defaultLayer` in the flat
|
||||
`Layer.provide([...])` list — they were simply never carried over to the
|
||||
simulated chain after the rebase.
|
||||
|
||||
## See also
|
||||
|
||||
- `dependency-graph.html` — interactive visualization of the same data.
|
||||
@@ -37,16 +37,14 @@
|
||||
node_modules = final.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
opencode = final.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = final.callPackage ./nix/desktop.nix {
|
||||
opencode-desktop = final.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit opencode;
|
||||
opencode-desktop = desktop;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,16 +54,15 @@
|
||||
node_modules = pkgs.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
default = opencode;
|
||||
opencode = pkgs.callPackage ./nix/opencode.nix {
|
||||
inherit node_modules;
|
||||
};
|
||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
opencode-desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
inherit opencode;
|
||||
};
|
||||
in
|
||||
{
|
||||
default = opencode;
|
||||
inherit opencode desktop;
|
||||
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||
node_modules_updater = node_modules.override {
|
||||
hash = pkgs.lib.fakeHash;
|
||||
|
||||
+19
-5
@@ -46,10 +46,24 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
]
|
||||
const failedHttpStatus = calculatedField({
|
||||
name: "is_failed_http_status",
|
||||
expression:
|
||||
product === "go"
|
||||
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
|
||||
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
|
||||
expression: `
|
||||
IF(
|
||||
AND(
|
||||
GTE($status, "400"),
|
||||
NOT(EQUALS($status, "401")),
|
||||
NOT(
|
||||
AND(
|
||||
EQUALS($status, "429"),
|
||||
OR(
|
||||
EQUALS($error.type, "GoUsageLimitError"),
|
||||
EQUALS($error.type, "FreeUsageLimitError")
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
1,
|
||||
0
|
||||
)`,
|
||||
})
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
@@ -65,7 +79,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
filters,
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
}
|
||||
|
||||
+74
-73
@@ -1,100 +1,101 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
rustPlatform,
|
||||
pkg-config,
|
||||
cargo-tauri,
|
||||
bun,
|
||||
nodejs,
|
||||
cargo,
|
||||
rustc,
|
||||
jq,
|
||||
wrapGAppsHook4,
|
||||
electron_41,
|
||||
makeWrapper,
|
||||
dbus,
|
||||
glib,
|
||||
gtk4,
|
||||
libsoup_3,
|
||||
librsvg,
|
||||
libappindicator,
|
||||
glib-networking,
|
||||
openssl,
|
||||
webkitgtk_4_1,
|
||||
gst_all_1,
|
||||
writableTmpDirAsHomeHook,
|
||||
autoPatchelfHook,
|
||||
opencode,
|
||||
}:
|
||||
rustPlatform.buildRustPackage (finalAttrs: {
|
||||
let
|
||||
electron = electron_41;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
inherit (opencode)
|
||||
version
|
||||
src
|
||||
node_modules
|
||||
patches
|
||||
;
|
||||
|
||||
cargoRoot = "packages/desktop/src-tauri";
|
||||
cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock;
|
||||
buildAndTestSubdir = finalAttrs.cargoRoot;
|
||||
inherit (opencode) version src node_modules;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
cargo-tauri.hook
|
||||
bun
|
||||
nodejs # for patchShebangs node_modules
|
||||
cargo
|
||||
rustc
|
||||
jq
|
||||
nodejs
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
dbus
|
||||
glib
|
||||
gtk4
|
||||
libsoup_3
|
||||
librsvg
|
||||
libappindicator
|
||||
glib-networking
|
||||
openssl
|
||||
webkitgtk_4_1
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
strictDeps = true;
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
(lib.getLib stdenv.cc.cc)
|
||||
];
|
||||
|
||||
env = opencode.env // {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
};
|
||||
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# mac builds use a .app bundle which doesnt have this issue
|
||||
postPatch = lib.optionalString stdenv.isLinux ''
|
||||
BASE_PATH=packages/desktop
|
||||
FILES=(src/main/windows.ts)
|
||||
for file in "''${FILES[@]}"; do
|
||||
substituteInPlace $BASE_PATH/$file \
|
||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||
done
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
cp -a ${finalAttrs.node_modules}/{node_modules,packages} .
|
||||
chmod -R u+w node_modules packages
|
||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||
chmod -R u+w $HOME/.electron-dist
|
||||
|
||||
cp -R ${finalAttrs.node_modules}/. .
|
||||
patchShebangs node_modules
|
||||
patchShebangs packages/desktop/node_modules
|
||||
|
||||
mkdir -p packages/desktop/src-tauri/sidecars
|
||||
cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget}
|
||||
patchShebangs packages/*/node_modules
|
||||
'';
|
||||
|
||||
# see publish-tauri job in .github/workflows/publish.yml
|
||||
tauriBuildFlags = [
|
||||
"--config"
|
||||
"tauri.prod.conf.json"
|
||||
"--no-sign" # no code signing or auto updates
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
cd packages/desktop
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
--config electron-builder.config.ts \
|
||||
--config.mac.identity=null \
|
||||
--config.electronDist="$HOME/.electron-dist"
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase =
|
||||
''
|
||||
runHook preInstall
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
mkdir -p $out/Applications
|
||||
mv dist/mac*/*.app $out/Applications
|
||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||
''
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mkdir -p $out/opt/opencode-desktop
|
||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||
--inherit-argv0 \
|
||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||
''
|
||||
+ ''
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
autoPatchelfIgnoreMissingDeps = [
|
||||
"libc.musl-x86_64.so.1"
|
||||
];
|
||||
|
||||
# FIXME: workaround for concerns about case insensitive filesystems
|
||||
# should be removed once binary is renamed or decided otherwise
|
||||
# darwin output is a .app bundle so no conflict
|
||||
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
mv $out/bin/OpenCode $out/bin/opencode-desktop
|
||||
sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "OpenCode Desktop App";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode-desktop";
|
||||
inherit (opencode.meta) platforms;
|
||||
inherit (opencode.meta) homepage license platforms;
|
||||
};
|
||||
})
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
|
||||
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
|
||||
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
|
||||
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
|
||||
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
|
||||
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
|
||||
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
|
||||
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -40,7 +40,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||
env.OPENCODE_DISABLE_MODELS_FETCH = true;
|
||||
env.OPENCODE_VERSION = finalAttrs.version;
|
||||
env.OPENCODE_CHANNEL = "local";
|
||||
env.OPENCODE_CHANNEL = "prod";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
@@ -89,11 +89,12 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
|
||||
passthru = {
|
||||
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
|
||||
env = finalAttrs.env;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "The open source coding agent";
|
||||
homepage = "https://opencode.ai/";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode";
|
||||
inherit (node_modules.meta) platforms;
|
||||
|
||||
+3
-3
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"@opentui/core": "0.2.11",
|
||||
"@opentui/keymap": "0.2.11",
|
||||
"@opentui/solid": "0.2.11",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -161,7 +161,9 @@ export async function POST(input: APIEvent) {
|
||||
})
|
||||
|
||||
if (userEmail) {
|
||||
if (coupon === LiteData.firstMonth100Coupon) {
|
||||
if (coupon === LiteData.firstMonth50Coupon) {
|
||||
await Billing.redeemCoupon(userEmail, "GO1MONTH50")
|
||||
} else if (coupon === LiteData.firstMonth100Coupon) {
|
||||
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
|
||||
} else if (coupon === LiteData.threeMonths100Coupon) {
|
||||
await Billing.redeemCoupon(userEmail, "GO3MONTHS100")
|
||||
|
||||
@@ -10,8 +10,11 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
const dailyLimit = rateLimit ?? limits.dailyRequests
|
||||
const isDefaultModel = !rateLimit
|
||||
const headersExist = Object.entries(limits.checkHeaders).every(
|
||||
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
|
||||
)
|
||||
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
|
||||
const isDefaultModel = headersExist && !rateLimit
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `coupon` MODIFY COLUMN `type` enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100') NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const ret = await $`bun sst secret list --fallback`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||
|
||||
@@ -6,7 +6,7 @@ import os from "os"
|
||||
import { Subscription } from "../src/subscription"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const secrets = await $`bun sst secret list --fallback`.cwd(root).text()
|
||||
|
||||
// read value
|
||||
const lines = secrets.split("\n")
|
||||
@@ -25,4 +25,6 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||
Subscription.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `limits-${Date.now()}.env`))
|
||||
await envFile.write(`ZEN_LIMITS="${newValue.replace(/"/g, '\\"')}"`)
|
||||
await $`bun sst secret load ${envFile.name} --fallback`.cwd(root)
|
||||
|
||||
@@ -156,33 +156,32 @@ export namespace Billing {
|
||||
}
|
||||
|
||||
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||
const coupon = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(CouponTable)
|
||||
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!coupon) throw new Error("Invalid coupon code")
|
||||
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
|
||||
// validate coupon type
|
||||
await (async () => {
|
||||
if (type === "GO1MONTH50") return
|
||||
const coupon = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(CouponTable)
|
||||
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!coupon) throw new Error("Invalid coupon code")
|
||||
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
|
||||
})()
|
||||
|
||||
// handle coupon type
|
||||
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(CouponTable)
|
||||
.set({ timeRedeemed: sql`now()` })
|
||||
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
|
||||
)
|
||||
}
|
||||
|
||||
export const getCoupons = async (email: string) => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
|
||||
.from(CouponTable)
|
||||
.where(and(eq(CouponTable.email, email), isNull(CouponTable.timeRedeemed)))
|
||||
.then((rows) => rows.map((row) => row.type)),
|
||||
.insert(CouponTable)
|
||||
.values({ email, type, timeRedeemed: sql`now()` })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
timeRedeemed: sql`now()`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -290,20 +289,29 @@ export namespace Billing {
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||
|
||||
const coupons = await Billing.getCoupons(email)
|
||||
const coupon = coupons.includes("GO12MONTHS100")
|
||||
? LiteData.twelveMonths100Coupon
|
||||
: coupons.includes("GO6MONTHS100")
|
||||
? LiteData.sixMonths100Coupon
|
||||
: coupons.includes("GO3MONTHS100")
|
||||
? LiteData.threeMonths100Coupon
|
||||
: coupons.includes("GOFREEMONTH")
|
||||
? LiteData.firstMonth100Coupon
|
||||
: LiteData.firstMonth50Coupon
|
||||
const coupons = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
|
||||
.from(CouponTable)
|
||||
.where(eq(CouponTable.email, email)),
|
||||
)
|
||||
|
||||
const coupon = (() => {
|
||||
if (coupons.some((coupon) => coupon.type === "GO12MONTHS100" && !coupon.timeRedeemed))
|
||||
return LiteData.twelveMonths100Coupon
|
||||
if (coupons.some((coupon) => coupon.type === "GO6MONTHS100" && !coupon.timeRedeemed))
|
||||
return LiteData.sixMonths100Coupon
|
||||
if (coupons.some((coupon) => coupon.type === "GO3MONTHS100" && !coupon.timeRedeemed))
|
||||
return LiteData.threeMonths100Coupon
|
||||
if (coupons.some((coupon) => coupon.type === "GOFREEMONTH" && !coupon.timeRedeemed))
|
||||
return LiteData.firstMonth100Coupon
|
||||
if (!coupons.some((coupon) => coupon.type === "GO1MONTH50")) return LiteData.firstMonth50Coupon
|
||||
return undefined
|
||||
})()
|
||||
const createSession = () =>
|
||||
Billing.stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
discounts: [{ coupon }],
|
||||
discounts: coupon ? [{ coupon }] : undefined,
|
||||
...(billing.customerID
|
||||
? {
|
||||
customer: billing.customerID,
|
||||
|
||||
@@ -133,7 +133,14 @@ export const UsageTable = mysqlTable(
|
||||
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
||||
)
|
||||
|
||||
export const CouponType = ["BUILDATHON", "GOFREEMONTH", "GO3MONTHS100", "GO6MONTHS100", "GO12MONTHS100"] as const
|
||||
export const CouponType = [
|
||||
"BUILDATHON",
|
||||
"GO1MONTH50",
|
||||
"GOFREEMONTH",
|
||||
"GO3MONTHS100",
|
||||
"GO6MONTHS100",
|
||||
"GO12MONTHS100",
|
||||
] as const
|
||||
export const CouponTable = mysqlTable(
|
||||
"coupon",
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@ export namespace Subscription {
|
||||
free: z.object({
|
||||
promoTokens: z.number().int(),
|
||||
dailyRequests: z.number().int(),
|
||||
dailyRequestsFallback: z.number().int(),
|
||||
checkHeaders: z.record(z.string(), z.string()),
|
||||
}),
|
||||
lite: z.object({
|
||||
rollingLimit: z.number().int(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -6,6 +6,7 @@ function truthy(key: string) {
|
||||
}
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const OPENCODE_SIMULATION = truthy("OPENCODE_SIMULATION")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
@@ -28,6 +29,8 @@ export const Flag = {
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
OPENCODE_SIMULATION,
|
||||
OPENCODE_SIMULATION_BACKEND: OPENCODE_SIMULATION || truthy("OPENCODE_SIMULATION_BACKEND"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
@@ -38,7 +41,6 @@ export const Flag = {
|
||||
),
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
|
||||
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
|
||||
OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
|
||||
@@ -104,6 +104,7 @@ export const Provider = Schema.Struct({
|
||||
})
|
||||
|
||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||
export const Catalog = Schema.Record(Schema.String, Provider)
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Record<string, Provider>>
|
||||
|
||||
@@ -54,6 +54,29 @@ export interface Options {
|
||||
level?: Level
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly time: string
|
||||
readonly level: Level
|
||||
readonly tags: Record<string, unknown>
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 5_000
|
||||
const memory: Entry[] = []
|
||||
|
||||
function record(entry: Entry) {
|
||||
memory.push(entry)
|
||||
if (memory.length > MAX_ENTRIES) memory.splice(0, memory.length - MAX_ENTRIES)
|
||||
}
|
||||
|
||||
export function entries(): Entry[] {
|
||||
return memory.slice()
|
||||
}
|
||||
|
||||
export function clearEntries() {
|
||||
memory.length = 0
|
||||
}
|
||||
|
||||
let logpath = ""
|
||||
export function file() {
|
||||
return logpath
|
||||
@@ -139,24 +162,39 @@ export function create(tags?: Record<string, any>) {
|
||||
last = next.getTime()
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
|
||||
function capture(level: Level, message: any, extra?: Record<string, any>) {
|
||||
const text = message instanceof Error ? formatError(message) : message === undefined ? "" : String(message)
|
||||
record({
|
||||
time: new Date().toISOString(),
|
||||
level,
|
||||
tags: { ...tags, ...extra },
|
||||
message: text,
|
||||
})
|
||||
}
|
||||
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("DEBUG")) {
|
||||
capture("DEBUG", message, extra)
|
||||
write("DEBUG " + build(message, extra))
|
||||
}
|
||||
},
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("INFO")) {
|
||||
capture("INFO", message, extra)
|
||||
write("INFO " + build(message, extra))
|
||||
}
|
||||
},
|
||||
error(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("ERROR")) {
|
||||
capture("ERROR", message, extra)
|
||||
write("ERROR " + build(message, extra))
|
||||
}
|
||||
},
|
||||
warn(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("WARN")) {
|
||||
capture("WARN", message, extra)
|
||||
write("WARN " + build(message, extra))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.0"
|
||||
version = "1.15.4"
|
||||
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.15.0/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.0/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/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.15.0/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/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.15.0/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Simulation script runner.
|
||||
|
||||
Usage:
|
||||
bun run.ts <script.json> [options]
|
||||
|
||||
Options:
|
||||
--mcp <url> MCP endpoint (default http://127.0.0.1:43110/mcp)
|
||||
--chunk <n> Actions per step batch (default 3)
|
||||
--max-steps <n> Hard cap on step calls (default unlimited)
|
||||
--level <lvl> Stop level: DEBUG|INFO|WARN|ERROR (default ERROR)
|
||||
--message-includes <s> Only stop when message includes substring
|
||||
--service-includes <s> Only stop when tag.service includes substring
|
||||
--reset Reset sim state + restart TUI before load
|
||||
--no-reset Skip reset (default)
|
||||
--keep-going Don't stop on errors; continue to end
|
||||
--quiet Suppress per-batch progress
|
||||
--json Emit JSON summary at the end
|
||||
--check-every <n> Check logs every N batches (default 1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.4",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -37,8 +37,8 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -61,6 +61,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"just-bash": "3.0.1",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
|
||||
@@ -244,6 +244,7 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
|
||||
@@ -1,102 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "child_process"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
function detectPlatformAndArch() {
|
||||
// Map platform names
|
||||
let platform
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
platform = "darwin"
|
||||
break
|
||||
case "linux":
|
||||
platform = "linux"
|
||||
break
|
||||
case "win32":
|
||||
platform = "windows"
|
||||
break
|
||||
default:
|
||||
platform = os.platform()
|
||||
break
|
||||
}
|
||||
|
||||
// Map architecture names
|
||||
let arch
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
arch = "x64"
|
||||
break
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
break
|
||||
case "arm":
|
||||
arch = "arm"
|
||||
break
|
||||
default:
|
||||
arch = os.arch()
|
||||
break
|
||||
}
|
||||
|
||||
return { platform, arch }
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
}
|
||||
|
||||
function findBinary() {
|
||||
const { platform, arch } = detectPlatformAndArch()
|
||||
const packageName = `opencode-${platform}-${arch}`
|
||||
const binaryName = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const platform = platformMap[os.platform()] ?? os.platform()
|
||||
const arch = archMap[os.arch()] ?? os.arch()
|
||||
const base = `opencode-${platform}-${arch}`
|
||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
||||
|
||||
try {
|
||||
// Use require.resolve to find the package
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const binaryPath = path.join(packageDir, "bin", binaryName)
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
return { binaryPath, binaryName }
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, the .exe is already included in the package and bin field points to it
|
||||
// No postinstall setup needed
|
||||
console.log("Windows detected: binary setup not needed (using packaged .exe)")
|
||||
return
|
||||
}
|
||||
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
const target = path.join(__dirname, "bin", ".opencode")
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
fs.linkSync(binaryPath, target)
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
fs.copyFileSync(binaryPath, target)
|
||||
return false
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// Ignore filesystem probes that are blocked by the host.
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
|
||||
if (platform === "linux") {
|
||||
if (isMusl()) {
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
|
||||
return [base]
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packageJsonPath = require.resolve(`${name}/package.json`)
|
||||
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const version = packageJson.optionalDependencies?.[name]
|
||||
if (!version) return
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return
|
||||
const packageDir = path.join(temp, "node_modules", name)
|
||||
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source, target) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(source, target)
|
||||
} catch {
|
||||
fs.copyFileSync(source, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
const result = childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const name of packageNames()) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name), targetBinary)
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(" or ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
void main()
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error("Postinstall script error:", error.message)
|
||||
process.exit(0)
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -32,22 +32,39 @@ console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
|
||||
[
|
||||
`echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`,
|
||||
'echo "" >&2',
|
||||
'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2',
|
||||
'echo "package manager like pnpm that does not run postinstall scripts by default." >&2',
|
||||
'echo "" >&2',
|
||||
'echo "To fix this, run the postinstall script manually:" >&2',
|
||||
`echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`,
|
||||
'echo "" >&2',
|
||||
`echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`,
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -70,11 +70,54 @@ Endpoint definitions declare which public errors can be emitted. Public
|
||||
HTTP error schemas carry their response status with `httpApiStatus` or the
|
||||
equivalent HttpApi schema annotation.
|
||||
|
||||
Effect's own HttpApi examples follow this pattern:
|
||||
|
||||
```ts
|
||||
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{ message: Schema.String },
|
||||
{ httpApiStatus: 401 },
|
||||
) {}
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<
|
||||
Authorization,
|
||||
{
|
||||
provides: CurrentUser
|
||||
}
|
||||
>()("app/Authorization", {
|
||||
security: { bearer: HttpApiSecurity.bearer },
|
||||
error: Unauthorized,
|
||||
}) {}
|
||||
```
|
||||
|
||||
Endpoint-level errors use the same idea:
|
||||
|
||||
```ts
|
||||
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
|
||||
{
|
||||
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
|
||||
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
HttpApiEndpoint.get("get", "/config", {
|
||||
success: Config.Info,
|
||||
error: ConfigApiError,
|
||||
})
|
||||
```
|
||||
|
||||
The service error and HTTP error may be the same class only when the wire
|
||||
shape is intentionally public. Use separate HTTP error schemas when the
|
||||
service error contains internals, low-level causes, retry hints, or data
|
||||
that should not be exposed to API clients.
|
||||
|
||||
Do not map every domain error into one universal HTTP error class. Prefer a
|
||||
small public error vocabulary by route group: shared shapes like
|
||||
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
|
||||
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
|
||||
contract.
|
||||
|
||||
## Mapping Guidance
|
||||
|
||||
- Keep one-off translations inline in the handler.
|
||||
@@ -86,6 +129,35 @@ that should not be exposed to API clients.
|
||||
breaking API change.
|
||||
- Use built-in `HttpApiError.*` only when its generated body and SDK
|
||||
surface are intentionally the public contract.
|
||||
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
|
||||
not the same as the internal domain error shape.
|
||||
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
|
||||
errors that are naturally tagged by `_tag`.
|
||||
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
|
||||
the public API error schema instead of relying on `NamedError.toObject()` in
|
||||
generic middleware.
|
||||
|
||||
## User-Facing Rendering
|
||||
|
||||
HTTP serialization and user rendering are separate boundaries. The server
|
||||
should send structured public errors; CLI and TUI code should format those
|
||||
structures through one shared formatter.
|
||||
|
||||
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
|
||||
decoded response body in an `Error`. The original body should remain available
|
||||
under `error.cause.body`; `FormatError` is the right place to unwrap and render
|
||||
that body. TUI aggregation helpers should call `FormatError` first, then fall
|
||||
back to generic `Error.message` / string rendering.
|
||||
|
||||
When several parallel startup requests fail from the same underlying issue,
|
||||
group identical rendered messages and list the affected request names once.
|
||||
For example:
|
||||
|
||||
```text
|
||||
Configuration is invalid at /path/to/opencode.json
|
||||
↳ Expected object, got "not-object" provider.bad.options
|
||||
Affected startup requests: config.providers, provider.list, app.agents, config.get
|
||||
```
|
||||
|
||||
## Middleware Guidance
|
||||
|
||||
@@ -99,6 +171,15 @@ middleware should shrink. It should not gain new name checks.
|
||||
Unknown `500` responses should log full details server-side with
|
||||
`Cause.pretty(cause)` and return a safe public body.
|
||||
|
||||
The config startup regression in #27056 is the failure mode this rule is meant
|
||||
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
|
||||
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
|
||||
generic `UnknownError`. The compatibility fix is to preserve config parse and
|
||||
validation errors as client-visible `400`s. The target architecture is better:
|
||||
config loading should fail on the typed error channel, config HTTP handlers
|
||||
should map those errors to declared `ConfigApiError` responses, and the generic
|
||||
middleware should never see them.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Prefer small vertical slices:
|
||||
@@ -113,6 +194,9 @@ Prefer small vertical slices:
|
||||
Good early domains are storage not-found, worktree errors, and provider
|
||||
auth validation errors because they currently drive HTTP behavior.
|
||||
|
||||
Config parse and validation errors are also a good early slice because they
|
||||
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
|
||||
|
||||
## Checklist For A PR
|
||||
|
||||
- [ ] Expected failures are typed errors, not defects.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Property-Based TUI Testing Working Notes
|
||||
|
||||
## Mock LLM Provider
|
||||
|
||||
- `SessionPrompt` gets model metadata through `Provider.Service.getModel(...)`.
|
||||
- Actual generation is routed through `LLM.Service` / `streamText(...)`, so the first mock should return an AI SDK `LanguageModelV3` from the provider service.
|
||||
- The normal `Provider.layer` builds providers from config/models.dev/plugin state. For first pass, the simulated graph can replace `Provider.Service` with a smaller simulation provider service instead of trying to flow through provider config.
|
||||
- Control state should own an ordered LLM script queue. The provider/model should consume from that queue when the AI SDK calls the language model.
|
||||
- First version can support text-only output. Tool calls and stream chunk fidelity can come next.
|
||||
- Missing script should fail loudly with a typed simulation error, not silently return an empty assistant message.
|
||||
- Implemented `SimulationProvider.layer`, replacing `Provider.Service` in `createSimulatedRoutes`.
|
||||
- The provider exposes provider `simulation` and model `mock`.
|
||||
- `doGenerate` and `doStream` both consume one queued script through `Simulation.Service.nextLLM()`.
|
||||
- Current script support: `text`, `thinking` (treated as text for now), and `error`.
|
||||
- Snapshot currently records `llmQueued` and `llmConsumed`, not per-step details yet.
|
||||
|
||||
## OpenTUI Fake Renderer
|
||||
|
||||
- OpenTUI Solid exposes `testRender(...)` from `@opentui/solid`.
|
||||
- The lower-level core API is `createTestRenderer(...)` from `@opentui/core/testing`.
|
||||
- `createTestRenderer(...)` returns `renderer`, `mockInput`, `mockMouse`, `renderOnce`, `captureCharFrame`, `captureSpans`, and `resize`.
|
||||
- `captureCharFrame()` is the simple screen-buffer string API used heavily in OpenTUI snapshots.
|
||||
- `captureSpans()` returns structured lines/spans plus cursor position, which is a better starting point for visible element discovery than parsing raw characters.
|
||||
- `mockInput` supports interactions like `typeText`, `pressEnter`, and `pressArrow`.
|
||||
- Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`.
|
||||
- `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`.
|
||||
- Backend route assembly checks `OPENCODE_SIMULATION_BACKEND`; `OPENCODE_SIMULATION` implies this flag.
|
||||
- `OPENCODE_SIMULATION_BACKEND=1` can run a real frontend against a simulated backend.
|
||||
- `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer.
|
||||
|
||||
## OpenTUI Action APIs
|
||||
|
||||
- Interactable discovery can walk `renderer.root.getChildren()` recursively.
|
||||
- `Renderable.focusable` and `Renderable.focused` are public and enough to discover focus targets.
|
||||
- `renderer.currentFocusedEditor` identifies active text input/edit-buffer targets for typing/submission.
|
||||
- `renderer.hitTest(x, y)` maps terminal coordinates through the hit grid to a renderable id.
|
||||
- Renderables have public geometry: `screenX`, `screenY`, `width`, `height`, and `num`.
|
||||
- Mouse listener metadata is stored internally on renderables; first pass checks `_mouseListener` / `_mouseListeners` at runtime to identify clickable targets. This is pragmatic but not a stable public API.
|
||||
- Test execution uses `mockInput.typeText`, `mockInput.pressEnter`, `mockInput.pressArrow`, and `mockMouse.click` from OpenTUI testing.
|
||||
- Implemented `SimulationActions` with `elements(...)`, `actions(...)`, and `execute(...)`.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Property-Based TUI Testing
|
||||
|
||||
Status: first-pass implementation plan.
|
||||
|
||||
The goal is to drive the TUI against the real opencode app/backend while replacing external effects with deterministic simulation boundaries. The first pass should produce the smallest end-to-end system that can run the real app in a deterministic simulation environment and assert only that the app does not crash.
|
||||
|
||||
## Scope
|
||||
|
||||
Build these pieces first:
|
||||
|
||||
- Mock `AppFileSystem.Service` layer.
|
||||
- Mock `FetchHttpClient` layer with schema-generated responses through `toArbitrary()`.
|
||||
- Backend simulation control endpoint.
|
||||
- Mock LLM provider controlled by the endpoint.
|
||||
- OpenTUI fake renderer/screen-buffer/interactable-element access.
|
||||
- Basic action generator that drives the TUI forward.
|
||||
- Simulation-only embedded MCP server that lets agents observe and drive the TUI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No semantic graph yet.
|
||||
- No advanced properties beyond no-crash.
|
||||
- No fake clock/timer control yet.
|
||||
- No shrinking yet.
|
||||
- No broad replacement of app services.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Load the normal app by default.
|
||||
- Keep overrides narrow and explicit.
|
||||
- The first core overrides are `AppFileSystem.Service` and `FetchHttpClient.layer`.
|
||||
- Do not replace `Provider.Service`, `SessionPrompt.Service`, `ToolRegistry.Service`, or the route tree wholesale unless we prove a narrow seam is impossible.
|
||||
- Use a backend control endpoint for LLM scripts and simulation state.
|
||||
- Force `OPENCODE_DB=:memory:` before any code imports `storage/db.ts`.
|
||||
- Run local simulation under `sandbox-exec` using the old branch setup as the starting point.
|
||||
- Use `sandbox-exec` as the safety boundary, not as the normal simulated I/O mechanism.
|
||||
- First built-in property: the app does not crash.
|
||||
- Only two simulation flags exist: `OPENCODE_SIMULATION` and `OPENCODE_SIMULATION_BACKEND`.
|
||||
- `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over stdio and implies backend simulation.
|
||||
- `OPENCODE_SIMULATION_BACKEND` without `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over loopback HTTP; in the TUI it shows the URL in the home screen.
|
||||
|
||||
## Target End-To-End Flow
|
||||
|
||||
1. Start opencode through the simulation runner.
|
||||
2. Runner sets `OPENCODE_DB=:memory:` before backend modules load.
|
||||
3. Runner installs the mock filesystem and mock HTTP client as narrow core overrides.
|
||||
4. Runner starts under `sandbox-exec` with host writes denied and external network denied.
|
||||
5. Runner mounts the TUI with a fake OpenTUI renderer instead of a real terminal.
|
||||
6. Test calls the simulation endpoint to seed filesystem/network/LLM state.
|
||||
7. Action generator performs one TUI action.
|
||||
8. Backend handles real app requests and uses endpoint-provided LLM scripts.
|
||||
9. Runner waits for quiescence.
|
||||
10. Built-in no-crash property checks TUI and backend errors.
|
||||
|
||||
## Mock AppFileSystem
|
||||
|
||||
Goal: backend-visible project/config/state files live in memory and never hit the host filesystem.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `packages/opencode/src/testing/simulation/filesystem.ts`.
|
||||
- Implement an in-memory filesystem that can back `AppFileSystem.Service`.
|
||||
- Seed it from JSON fixtures supplied through the simulation endpoint or runner config.
|
||||
- Serialize it into replay traces.
|
||||
- Fail unsupported operations with typed simulation errors instead of silently falling back to host FS.
|
||||
- Enable the full simulation runner with `OPENCODE_SIMULATION`.
|
||||
- Enable only backend simulation with `OPENCODE_SIMULATION_BACKEND`.
|
||||
- `OPENCODE_SIMULATION` implies `OPENCODE_SIMULATION_BACKEND`.
|
||||
- Use a fixed virtual root, not `process.cwd()`, so host paths are denied by default.
|
||||
- Use the old branch's Bun preload/plugin redirection only for code paths that bypass `AppFileSystem.Service`.
|
||||
- Let `sandbox-exec` catch any remaining direct `fs`, `Bun.file`, or process-level filesystem access.
|
||||
|
||||
Required capabilities:
|
||||
|
||||
- Files and directories.
|
||||
- Text and binary content.
|
||||
- Deterministic `stat` metadata.
|
||||
- Deterministic path resolution for workspace root, cwd, home, config, state, and temp.
|
||||
- Reads and writes used by tools and config loading.
|
||||
- Directory listing and recursive traversal for glob/grep equivalents.
|
||||
- Snapshot/diff support or enough primitives for existing snapshot code to work.
|
||||
|
||||
Direct bypass candidates identified so far:
|
||||
|
||||
- `tool/read.ts` uses `createReadStream` directly for text line reads.
|
||||
- `patch/index.ts` uses `fs/promises` and `readFileSync` directly.
|
||||
- `storage/db.ts` uses sync `fs` APIs and must be protected by forcing `OPENCODE_DB=:memory:` before import.
|
||||
- `lsp/server.ts`, `util/filesystem.ts`, `file/watcher.ts`, and several CLI/TUI utilities use direct host filesystem APIs.
|
||||
- These should be redirected only when needed; otherwise `sandbox-exec` should catch leaks.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Inspect `AppFileSystem.Service` interface and all methods used by backend code.
|
||||
- [x] List direct `@/util/filesystem`, `fs`, and `Bun.file` bypasses that matter in simulation mode.
|
||||
- [x] Define mock filesystem data model and fixture JSON format.
|
||||
- [x] Implement the `AppFileSystem.Service` layer.
|
||||
- [x] Add typed errors for unsupported operations and host-FS escapes.
|
||||
- [x] Add activation path from startup through `OPENCODE_SIMULATION` / `OPENCODE_SIMULATION_BACKEND`.
|
||||
- [x] Add a tiny fixture that includes `opencode.json`, a workspace root, and a few files.
|
||||
- [ ] Verify read/glob/grep/write/edit use the mock filesystem.
|
||||
- [ ] Verify sandbox denies host writes when a bypass is introduced.
|
||||
|
||||
## Mock FetchHttpClient
|
||||
|
||||
Goal: no backend code makes external network calls. Calls either return generated deterministic mock data or fail with a typed simulation error.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `packages/opencode/src/testing/simulation/network.ts`.
|
||||
- Provide a narrow replacement for `FetchHttpClient.layer` / `HttpClient.HttpClient` in simulation startup.
|
||||
- Allow loopback only when needed for local app/TUI communication.
|
||||
- Deny all non-loopback network by default.
|
||||
- Add a response registry controlled by the simulation endpoint.
|
||||
- For registered schemas, generate deterministic data with `toArbitrary()` and the run seed.
|
||||
|
||||
Schema inference problem:
|
||||
|
||||
- Raw HTTP requests do not always carry the desired response schema.
|
||||
- First implementation should find where schema information exists for each network call path.
|
||||
- If the schema is not available from the raw `HttpClient` call, add a small registry keyed by request matcher and schema.
|
||||
- The endpoint can register `{ matcher, schema, seedOffset }`, and the mock client can call `toArbitrary(schema)` to generate the response.
|
||||
- Unknown requests should fail loudly instead of returning generic data.
|
||||
|
||||
Network call families found in the first inventory:
|
||||
|
||||
- Effect `HttpClient` with schemas close to the call site:
|
||||
- `account/account.ts`: opencode account/device/auth/org/user/config APIs. Response schemas are local (`TokenRefresh`, `Org`, `User`, `RemoteConfig`, `DeviceAuth`, `DeviceToken`).
|
||||
- `provider/models.ts`: `${OPENCODE_MODELS_URL || "https://models.dev"}/api.json`. Response schema is `Record<string, Provider>` but currently parsed after `res.text`; register this URL to the provider catalog schema.
|
||||
- `share/share-next.ts`: share create/sync/remove. Create response schema is `ShareSchema`; sync/remove can be empty/status-only.
|
||||
- `skill/discovery.ts`: skill index response schema is `Index`; skill file downloads are raw bytes/text.
|
||||
- `session/instruction.ts`: configured remote instruction URLs return text.
|
||||
- `tool/mcp-websearch.ts`, `tool/websearch.ts`, and `tool/codesearch.ts`: MCP-style tool calls to Exa/Parallel. Request schemas are local; response shape is MCP JSON-RPC/SSE with `McpResult`.
|
||||
- `tool/webfetch.ts`: arbitrary user URL returns raw text/html/image bytes, so it needs explicit registration by URL/content type rather than generic schema generation.
|
||||
- Effect `HttpClient` that should usually be disabled in first-pass simulation:
|
||||
- `installation/index.ts`: update/install metadata.
|
||||
- `file/ripgrep.ts`: ripgrep binary download.
|
||||
- UI and workspace proxy paths: allow only explicitly registered workspace URLs or loopback/local app traffic.
|
||||
- Raw `fetch` paths:
|
||||
- `config/config.ts`: well-known and remote config fetches. The schema is loose config JSON; register by configured URL if tests need this path.
|
||||
- `lsp/server.ts`: language-server release/download fetches. Disable by config in simulation or deny unless explicitly registered.
|
||||
- plugin auth/provider helpers (`plugin/codex.ts`, `plugin/github-copilot/*`, CLI commands): not part of first-pass TUI smoke unless explicitly exercised.
|
||||
- Provider SDK calls:
|
||||
- Most model traffic happens inside AI SDK provider packages, not directly through Effect `HttpClient`.
|
||||
- First pass should avoid mocking arbitrary provider SDK HTTP. Instead, register a local mock provider/model through the normal provider path and deny provider SDK fetches unless explicitly registered.
|
||||
- Remote MCP servers:
|
||||
- Config `mcp.<name>.url` is the registration point. When the app is given a remote MCP URL, the simulation network should register that URL as an MCP protocol endpoint for that named server.
|
||||
- The schema is not a single app schema; it is the MCP JSON-RPC/SSE protocol plus configured tool/resource/prompt definitions. The mock network should handle MCP protocol methods for registered MCP URLs and generate tool/list/call responses from simulation state.
|
||||
- The MCP SDK transport may bypass Effect `HttpClient`, so this likely needs either transport-level injection if the SDK supports custom fetch, or the old preload/global `fetch` redirection for registered MCP URLs only.
|
||||
|
||||
Registration model:
|
||||
|
||||
- `SimulationNetwork.Service` owns a registry keyed by method + URL matcher.
|
||||
- Registry entries should include a `source`/`kind` so failures explain why a URL was allowed or denied.
|
||||
- Rough implementation exists at `packages/opencode/src/testing/simulation/network.ts`.
|
||||
- Current rough registry supports exact URL, regex URL, or predicate matchers, optional method filters, parsed request bodies, static responses, dynamic response functions, and full handlers.
|
||||
- `SimulationNetworkRoutes` imports known schemas from the services that own HTTP call sites and registers schema-backed routes for hardcoded/configurable URL families.
|
||||
- Configurable/client-provided URLs should be registered through route-family helpers, e.g. `account(baseUrl)`, `models(baseUrl)`, `share(baseUrl)`, `skills(baseUrl)`, and `installation(registryUrl)`.
|
||||
- Some production schemas are too broad for `Schema.toArbitrary()` today, such as provider catalog fields containing arbitrary mutable JSON. For those cases, the first-pass route can use a narrower generated schema whose values still decode under the production schema.
|
||||
- Supported entry kinds for the first pass:
|
||||
- `jsonSchema`: generate JSON from an Effect `Schema` via `toArbitrary()`.
|
||||
- `text`: return deterministic text/html/markdown content for exact URLs.
|
||||
- `bytes`: return deterministic binary content for exact URLs.
|
||||
- `status`: return empty/status-only responses.
|
||||
- `handler`: inspect method, URL, headers, and parsed body to build a custom response.
|
||||
- `mcp`: handle JSON-RPC/SSE MCP protocol for a configured MCP server URL.
|
||||
- `loopback`: allow local app/TUI traffic only.
|
||||
- Prefer explicit registration at configuration/control boundaries over guessing from arbitrary URLs:
|
||||
- Account/server URL registrations come from account/auth setup.
|
||||
- MCP URL registrations come from `config.mcp`.
|
||||
- Web fetch/search URLs come from the simulation control endpoint or generated tool action.
|
||||
- Provider model responses come from the mock provider script registry, not generic provider SDK HTTP.
|
||||
- Unknown non-loopback URLs fail with a typed simulation network error.
|
||||
|
||||
Layering caveat:
|
||||
|
||||
- Several `defaultLayer`s still provide `FetchHttpClient.layer` internally (`Account`, `ModelsDev`, `ToolRegistry`, `ShareNext`, `SkillDiscovery`, `Instruction`, `Installation`, `Ripgrep`, `Workspace`). A top-level `HttpClient.HttpClient` mock does not necessarily affect those self-contained default layers.
|
||||
- First-pass startup wiring must either use non-default service layers and provide `SimulationNetwork.layer` once, or make these default layers explicitly mock-aware.
|
||||
- The same caveat already exists for `AppFileSystem.defaultLayer` in some default layers, so the final simulation startup needs an explicit “normal app with narrow mock boundaries” layer assembly rather than blindly using all default layers.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Locate all backend uses of `HttpClient.HttpClient`, raw `fetch`, provider SDK fetches, webfetch/websearch/share/update paths.
|
||||
- [x] Classify first-pass network call families into schema-generated, text/bytes, MCP protocol, loopback, and denied.
|
||||
- [x] Decide where `toArbitrary()` lives or which package exports it.
|
||||
- [x] Define rough request matcher shape: exact URL, regex URL, or predicate.
|
||||
- [x] Add method-aware matching and parsed request body support.
|
||||
- [x] Define rough schema registration shape for generated responses.
|
||||
- [x] Add schema-backed route helpers for hardcoded and configurable URL families.
|
||||
- [ ] Define final schema registration shape for generated responses.
|
||||
- [ ] Define MCP URL registration from `config.mcp.<name>.url` to an MCP protocol handler.
|
||||
- [x] Implement rough seeded response generation with `Schema.toArbitrary()`.
|
||||
- [x] Add loopback allowlist handling.
|
||||
- [x] Add typed simulation error for unregistered non-loopback request.
|
||||
- [ ] Verify sandbox also blocks external network if mock client is bypassed.
|
||||
|
||||
## Control Endpoint And Mock LLM Provider
|
||||
|
||||
Goal: tests control backend behavior through an endpoint, and the model follows endpoint-provided scripts through the real prompt/session pipeline.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`.
|
||||
- Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`.
|
||||
- Keep the route inaccessible unless simulation mode is explicitly enabled.
|
||||
- First pass uses a raw route wrapper at `packages/opencode/src/server/routes/instance/httpapi/simulation.ts` to avoid SDK regeneration while the API shape is still moving.
|
||||
- Current control service can reset state, seed filesystem files, register static network responses, and return a snapshot.
|
||||
- Register/configure a local mock provider/model through the normal provider path.
|
||||
- The simulated route graph replaces `Provider.Service` with `SimulationProvider.layer`.
|
||||
- The mock model reads queued scripts from simulation control state.
|
||||
- Current mock provider supports text/thinking/error actions for the first step only. Tool calls and multi-round step selection are still pending.
|
||||
- No JSON-in-prompt fallback.
|
||||
- Missing script means typed simulation error.
|
||||
|
||||
Initial endpoints:
|
||||
|
||||
- `POST /experimental/simulation/reset`
|
||||
- `POST /experimental/simulation/filesystem/seed`
|
||||
- `POST /experimental/simulation/network/register`
|
||||
- `POST /experimental/simulation/llm/enqueue`
|
||||
- `GET /experimental/simulation/snapshot`
|
||||
|
||||
Initial LLM script:
|
||||
|
||||
```ts
|
||||
type LLMScriptAction =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
type LLMScript = {
|
||||
steps: LLMScriptAction[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the old useful rule: step `0` runs before tool results, step `N` runs after `N` tool-result rounds.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Define simulation mode activation flag/env.
|
||||
- [x] Add simulation control state and reset semantics.
|
||||
- [x] Add gated simulation endpoints for reset, filesystem seed, network register, and snapshot.
|
||||
- [x] Decide raw route vs typed HttpApi route. Raw route for first pass; no SDK regeneration yet.
|
||||
- [x] Implement mock provider/model on the normal provider path.
|
||||
- [x] Make missing scripts fail with a typed simulation error.
|
||||
- [x] Record consumed script count in simulation snapshot.
|
||||
- [ ] Support tool-call script actions.
|
||||
- [ ] Support multi-step script selection after tool result rounds.
|
||||
- [ ] Verify `session.prompt_async` exercises real `SessionPrompt` and `SessionProcessor`.
|
||||
|
||||
## OpenTUI Fake Renderer And Interactable Elements
|
||||
|
||||
Goal: run the TUI without a real terminal, inspect the screen buffer, and discover/act on interactable elements.
|
||||
|
||||
Known starting points:
|
||||
|
||||
- Current TUI creates a real renderer in `packages/opencode/src/cli/cmd/tui/app.tsx` through `createCliRenderer(...)`.
|
||||
- Existing tests use `@opentui/solid` `testRender(...)`.
|
||||
- Existing tests use `@opentui/core/testing` `createTestRenderer(...)` for renderer snapshots.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer.
|
||||
- Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`.
|
||||
- `OPENCODE_SIMULATION_BACKEND` leaves the frontend real but makes backend route assembly use simulated services.
|
||||
- Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner.
|
||||
- In simulation MCP modes, the TUI side starts an MCP server documented in `simulation-mcp-server.md`.
|
||||
- Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`.
|
||||
- OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution.
|
||||
- Do not render to a real terminal in simulation mode.
|
||||
- Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements.
|
||||
- Investigate OpenTUI APIs for reading the screen buffer from the fake renderer.
|
||||
- If OpenTUI does not expose enough semantic information, add a small TUI semantic registry later. Do not block first pass on a full registry.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Inspect `@opentui/core/testing` `createTestRenderer` capabilities.
|
||||
- [x] Inspect `@opentui/solid` `testRender` capabilities.
|
||||
- [x] Determine how to get a screen buffer string/snapshot from the fake renderer.
|
||||
- [x] Determine first structured capture API for interactable discovery: `captureSpans()`.
|
||||
- [x] Add first pass renderable-based interactable discovery for focused editors, focusable elements, and mouse handlers.
|
||||
- [x] Add a minimal renderer factory override to `tui(...)` or app startup.
|
||||
- [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness.
|
||||
- [ ] Verify TUI starts in fake renderer with no real terminal output.
|
||||
- [ ] Verify screen buffer can be captured after a render.
|
||||
|
||||
## Simulation MCP Server
|
||||
|
||||
Goal: let agents discover, inspect, and drive the simulated TUI through MCP without adding production remote-control behavior.
|
||||
|
||||
Design document:
|
||||
|
||||
- `packages/opencode/specs/simulation-mcp-server.md`
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Start in one of two modes: local stdio (`OPENCODE_SIMULATION=1`) or remote loopback HTTP (`OPENCODE_SIMULATION_BACKEND=1` without `OPENCODE_SIMULATION`).
|
||||
- Live in the TUI/frontend process so it can access the OpenTUI renderer.
|
||||
- Use stdio for the local agent-launched MCP mode.
|
||||
- Bind remote streamable HTTP MCP servers to `127.0.0.1` on an ephemeral port.
|
||||
- Print the URL to stdout for remote headless mode.
|
||||
- Show the URL at the bottom of the home screen for remote visible TUI mode.
|
||||
- Expose screen/spans/UI-state tools and resources.
|
||||
- Execute UI driving through `SimulationActions.execute(...)`, not a second action path.
|
||||
- Proxy filesystem/network/LLM/reset/snapshot operations to the backend simulation control endpoint.
|
||||
|
||||
Todos:
|
||||
|
||||
- [x] Add design doc and first-pass todo list.
|
||||
- [x] Implement TUI-side MCP server startup and shutdown.
|
||||
- [x] Add local stdio mode.
|
||||
- [x] Add remote loopback mode.
|
||||
- [x] Print remote headless URL to stdout.
|
||||
- [x] Show remote visible TUI URL on the home screen.
|
||||
- [x] Expose observation tools/resources.
|
||||
- [x] Expose generated action execution tools.
|
||||
- [x] Expose backend control proxy tools.
|
||||
- [x] Add a smoke test that connects with the MCP client and calls one observation tool.
|
||||
|
||||
## Basic Action Generator
|
||||
|
||||
Goal: drive the TUI forward with generated actions and assert only that the app does not crash.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add a seeded action generator under `packages/opencode/test/property` or `packages/opencode/src/testing/simulation` depending on whether it needs production imports.
|
||||
- Start with a tiny action set: submit prompt, key command, paste/type text, click/select visible interactable.
|
||||
- Prefer OpenTUI/fake-renderer interactions over direct component refs where possible.
|
||||
- Allow direct prompt ref use for the very first smoke path if OpenTUI interaction APIs are not ready.
|
||||
- After each action, wait for basic quiescence.
|
||||
- Built-in property is only `app.does-not-crash`.
|
||||
|
||||
Initial no-crash check:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Todos:
|
||||
|
||||
- [ ] Define `UIAction` union for the first pass.
|
||||
- [ ] Implement seeded RNG for action selection.
|
||||
- [ ] Generate ordinary prompt text and enqueue matching LLM scripts through the control endpoint.
|
||||
- [ ] Execute actions through fake renderer/OpenTUI APIs where available.
|
||||
- [ ] Add temporary prompt-ref execution path if needed for first smoke.
|
||||
- [ ] Wait for quiescence after each action.
|
||||
- [ ] Capture screen buffer and backend snapshot after each action.
|
||||
- [ ] Check only `app.does-not-crash`.
|
||||
- [ ] Persist a simple replay trace with seed, filesystem fixture, network registrations, LLM scripts, actions, and observations.
|
||||
|
||||
## First Milestone
|
||||
|
||||
The first milestone is one deterministic run that:
|
||||
|
||||
- Starts under `sandbox-exec`.
|
||||
- Uses `OPENCODE_DB=:memory:`.
|
||||
- Seeds the mock filesystem.
|
||||
- Mounts the TUI using a fake renderer.
|
||||
- Enqueues an LLM script through the control endpoint.
|
||||
- Submits an ordinary prompt through the TUI.
|
||||
- Receives a mocked model response through the real session pipeline.
|
||||
- Captures a screen buffer.
|
||||
- Starts the simulation MCP server in the selected transport mode.
|
||||
- Passes the no-crash property.
|
||||
|
||||
## First-Pass Todos
|
||||
|
||||
- [x] Mock filesystem layer works.
|
||||
- [ ] Mock FetchHttpClient works for registered schemas and fails unknown network. Rough static registry is implemented; schema generation remains.
|
||||
- [ ] Control endpoint can seed filesystem, register network schemas, enqueue LLM scripts, and snapshot state.
|
||||
- [ ] Mock provider/model consumes endpoint scripts through the real LLM path.
|
||||
- [ ] TUI runs with fake renderer.
|
||||
- [ ] Runner can inspect screen buffer.
|
||||
- [x] Simulation MCP server exposes screen/UI/actions/control to agents.
|
||||
- [ ] Runner can identify at least one interactable path to submit a prompt.
|
||||
- [ ] Basic action generator executes multiple deterministic steps.
|
||||
- [ ] No-crash property runs after each step.
|
||||
- [ ] Replay trace is written outside the sandbox.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Simulation MCP Server
|
||||
|
||||
Status: first-pass implementation plan.
|
||||
|
||||
The simulation MCP server gives agents a simulation-only control surface for the TUI. It lives in the TUI process because only the frontend has direct access to the OpenTUI renderer, captured screen buffer, focused editor, interactable elements, and input/mouse drivers.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support local stdio mode for agents that launch opencode as an MCP server.
|
||||
- Support remote loopback HTTP mode for users that want to run the server themselves.
|
||||
- Expose current TUI state to agents: screen text, structured spans, interactable elements, focused editor, and generated actions.
|
||||
- Let agents drive the UI through the same `SimulationActions` execution path used by property tests.
|
||||
- Proxy backend simulation control operations through the existing `/experimental/simulation/*` endpoint.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not start this server outside simulation modes.
|
||||
- Do not add a general remote-control API to production TUI mode.
|
||||
- Do not replace the property-test action generator; MCP should call the same action generator.
|
||||
- Do not expose arbitrary host filesystem or network access.
|
||||
|
||||
## Location
|
||||
|
||||
- Server module: `packages/opencode/src/cli/cmd/tui/simulation-mcp.ts`.
|
||||
- Startup: `packages/opencode/src/cli/cmd/tui/thread.ts`, next to fake renderer creation.
|
||||
- Action/state source: `packages/opencode/src/testing/simulation/actions.ts`.
|
||||
- Backend mutation source: existing simulation control endpoints.
|
||||
|
||||
## Modes
|
||||
|
||||
Local stdio mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION=1`.
|
||||
- Uses an OpenTUI test renderer and stdio MCP transport.
|
||||
- Prints nothing to stdout except MCP protocol messages.
|
||||
- Intended for agent MCP configs where the agent launches `opencode` as a local command.
|
||||
|
||||
Remote headless mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set.
|
||||
- Uses an OpenTUI test renderer and streamable HTTP MCP transport.
|
||||
- Prints the running MCP URL to stdout once.
|
||||
- Intended for users or harnesses that want to start opencode and connect to the URL manually.
|
||||
|
||||
Remote visible TUI mode:
|
||||
|
||||
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set and the user is running the TUI.
|
||||
- Uses the normal visible TUI renderer and streamable HTTP MCP transport.
|
||||
- Does not print the URL to stdout because stdout belongs to the TUI.
|
||||
- Shows `Simulation mode MCP: <url>` at the bottom of the home screen.
|
||||
|
||||
## Transport
|
||||
|
||||
- Local stdio mode uses `StdioServerTransport`.
|
||||
- Remote modes use streamable HTTP over loopback.
|
||||
- Remote modes bind host `127.0.0.1`.
|
||||
- Remote port is ephemeral by default and configurable through `OPENCODE_SIMULATION_MCP_PORT`.
|
||||
|
||||
## Initial Tools
|
||||
|
||||
Observation:
|
||||
|
||||
- `simulation_screen_get`: return the current captured character frame.
|
||||
- `simulation_spans_get`: return OpenTUI captured spans.
|
||||
- `simulation_ui_state_get`: return elements, available generated actions, and focus state.
|
||||
|
||||
Driving:
|
||||
|
||||
- `simulation_action_execute`: execute one generated action and render once.
|
||||
- `simulation_action_sequence_execute`: execute a bounded sequence and return the final state.
|
||||
- `simulation_render_once`: force one render and return the screen/state.
|
||||
|
||||
Backend control proxy:
|
||||
|
||||
- `simulation_control_reset`
|
||||
- `simulation_control_filesystem_seed`
|
||||
- `simulation_control_network_register`
|
||||
- `simulation_control_llm_enqueue`
|
||||
- `simulation_control_snapshot`
|
||||
|
||||
## Initial Resources
|
||||
|
||||
- `simulation://screen`
|
||||
- `simulation://spans`
|
||||
- `simulation://ui-state`
|
||||
- `simulation://backend-snapshot`
|
||||
|
||||
## Initial Prompt
|
||||
|
||||
- `simulation-driver`: short instructions for agents to inspect state, choose available generated actions, drive the UI, then inspect again.
|
||||
|
||||
## Safety
|
||||
|
||||
- Guard startup with `OPENCODE_SIMULATION` or `OPENCODE_SIMULATION_BACKEND`.
|
||||
- Bind to loopback only.
|
||||
- Close the MCP server before destroying the renderer.
|
||||
- Keep backend state changes routed through the existing simulation control endpoint.
|
||||
|
||||
## Todos
|
||||
|
||||
- [x] Add this design document.
|
||||
- [x] Implement a first-pass TUI-side MCP server.
|
||||
- [x] Support local stdio mode.
|
||||
- [x] Support remote loopback mode.
|
||||
- [x] Print remote headless URL to stdout.
|
||||
- [x] Show remote background TUI URL on the home screen.
|
||||
- [x] Expose screen/spans/UI-state observation tools.
|
||||
- [x] Expose action execution tools using `SimulationActions.execute`.
|
||||
- [x] Expose backend control proxy tools.
|
||||
- [x] Add an automated smoke test that starts the MCP server and calls `tools/list` plus one observation tool.
|
||||
- [ ] Add richer action generation with generated text and bounded sequence traces.
|
||||
- [ ] Add trace capture for every MCP-driven action.
|
||||
- [ ] Add protocol-level docs for external agent authors.
|
||||
@@ -0,0 +1,103 @@
|
||||
# 02 Semantic Discovery
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
This phase starts after the first-pass action generator can drive the TUI and assert that the app does not crash.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a semantic map of TUI states, available actions, backend requests, and backend state changes. This lets later runs focus on workflows instead of random screen poking.
|
||||
|
||||
## UI Semantics
|
||||
|
||||
We need a way to know what the runner can interact with on the current screen.
|
||||
|
||||
Preferred order:
|
||||
|
||||
- Use OpenTUI render tree/fake renderer APIs if they expose interactable elements.
|
||||
- Add a small TUI semantic registry only for missing metadata.
|
||||
- Avoid large per-component instrumentation at first.
|
||||
|
||||
Potential semantic element shape:
|
||||
|
||||
```ts
|
||||
type SemanticElement = {
|
||||
id: string
|
||||
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
|
||||
label: string
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
state?: Record<string, unknown>
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
actions: SemanticAction[]
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Mapping
|
||||
|
||||
Every generated UI action should have an action ID. TUI requests should include simulation headers so backend observations can be correlated.
|
||||
|
||||
Headers:
|
||||
|
||||
- `x-opencode-simulation-run`
|
||||
- `x-opencode-simulation-action`
|
||||
- `x-opencode-simulation-step`
|
||||
|
||||
Record requests and events with enough metadata to answer:
|
||||
|
||||
- Which UI actions produced which backend requests?
|
||||
- Which backend domains changed?
|
||||
- Which TUI states became reachable?
|
||||
- Which generated path caused the crash if a crash happens?
|
||||
|
||||
Backend domains to consider later:
|
||||
|
||||
- `session`
|
||||
- `message`
|
||||
- `part`
|
||||
- `permission`
|
||||
- `question`
|
||||
- `todo`
|
||||
- `tool`
|
||||
- `mcp`
|
||||
- `filesystem`
|
||||
- `network`
|
||||
- `status`
|
||||
|
||||
## Graph Shape
|
||||
|
||||
The graph should abstract states rather than storing every concrete buffer.
|
||||
|
||||
```ts
|
||||
type SemanticState = {
|
||||
id: string
|
||||
route: string
|
||||
dialog?: string
|
||||
elementSignature: string
|
||||
backendSignature?: string
|
||||
}
|
||||
|
||||
type SemanticTransition = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
action: UIAction
|
||||
uiChanged: string[]
|
||||
backendRequests: BackendRequestRecord[]
|
||||
backendEvents: BackendEventRecord[]
|
||||
failures: SimulationFailure[]
|
||||
}
|
||||
```
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Reassess OpenTUI APIs after first-pass fake renderer work.
|
||||
- [ ] Decide whether a TUI semantic registry is needed.
|
||||
- [ ] Add action IDs to generated actions.
|
||||
- [ ] Add action headers to TUI fetch wrapper.
|
||||
- [ ] Record backend request spans.
|
||||
- [ ] Record backend events and changed domains.
|
||||
- [ ] Define normalized UI state signatures.
|
||||
- [ ] Build first UI transition graph artifact.
|
||||
- [ ] Build first backend endpoint/domain graph artifact.
|
||||
- [ ] Use graph to bias action generation toward a selected workflow.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 03 Properties And Replay
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
The first pass only checks that the app does not crash. Add more properties only after the basic runner and traces are stable.
|
||||
|
||||
## Property API
|
||||
|
||||
Properties should be ordinary TypeScript functions registered with the runner.
|
||||
|
||||
```ts
|
||||
type Property = {
|
||||
name: string
|
||||
domains: string[]
|
||||
check: (ctx: PropertyContext) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
The `domains` field lets the runner skip checks when unrelated state changed.
|
||||
|
||||
First pass property:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Later candidate properties:
|
||||
|
||||
- No non-loopback network call.
|
||||
- Session eventually becomes idle after prompt-like actions.
|
||||
- No pending tool call remains after idle.
|
||||
- Every TUI-visible session message has valid message/part schemas.
|
||||
- Permission/question overlays correspond to backend pending requests.
|
||||
- Replay trace can be parsed and rerun.
|
||||
- Text should not flicker across stable frames.
|
||||
- Dialog focus should remain valid.
|
||||
- Route state and visible route agree.
|
||||
- Backend DB invariants hold after endpoint groups.
|
||||
- Tool call lifecycle events are balanced.
|
||||
- No generated action sequence can strand a session in busy state.
|
||||
|
||||
## Failure Reports
|
||||
|
||||
Failure reports should be human-readable and point to the smallest useful context.
|
||||
|
||||
Report fields:
|
||||
|
||||
- Failed property name.
|
||||
- Seed and action index.
|
||||
- Minimal replay command.
|
||||
- Last N UI actions.
|
||||
- Backend requests/events caused by the failing action.
|
||||
- Visible TUI buffer before and after.
|
||||
- Relevant session/message/tool IDs.
|
||||
|
||||
## Replay Trace
|
||||
|
||||
Trace fields:
|
||||
|
||||
- Seed and run configuration.
|
||||
- Mock filesystem fixture and workspace/config path mapping.
|
||||
- Mock network schema registrations.
|
||||
- Simulation control calls.
|
||||
- LLM scripts consumed.
|
||||
- UI action sequence.
|
||||
- HTTP request records.
|
||||
- Backend events.
|
||||
- UI observations before/after each action.
|
||||
- Property checks and failure details.
|
||||
- Normalization version.
|
||||
|
||||
## Shrinking
|
||||
|
||||
Shrinking should come after exact replay is reliable.
|
||||
|
||||
Candidate shrink steps:
|
||||
|
||||
- Delete contiguous chunks of actions.
|
||||
- Reduce generated prompt text.
|
||||
- Reduce LLM scripts to fewer actions/steps.
|
||||
- Prefer semantic action shrinking over raw key shrinking.
|
||||
- Preserve control calls needed to reproduce backend state.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Keep first pass to `app.does-not-crash` only.
|
||||
- [ ] Define trace JSON schema after first runner exists.
|
||||
- [ ] Write replay command that reruns an exact trace.
|
||||
- [ ] Add readable failure report formatter.
|
||||
- [ ] Add network property after mock network is stable.
|
||||
- [ ] Add session/tool lifecycle properties after backend mapping is stable.
|
||||
- [ ] Add shrinker only after replay is deterministic.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 04 DST Hardening
|
||||
|
||||
Status: speculative. Refine before implementation.
|
||||
|
||||
This phase moves from seeded generation plus replay toward deterministic simulation testing. Do not start here; first get the app running under the first-pass simulation environment.
|
||||
|
||||
## Stage 1: Record And Normalize
|
||||
|
||||
- Seed RNG for the runner.
|
||||
- Normalize timestamps and generated IDs in traces.
|
||||
- Record timer registrations and delayed events where easy.
|
||||
- Use quiescence waits instead of fake time.
|
||||
|
||||
## Stage 2: Deterministic Data Sources
|
||||
|
||||
- Add deterministic ID generation behind a narrow simulation mode if normalization becomes too noisy.
|
||||
- Replace `Math.random()` usage in simulation-facing paths with seeded RNG.
|
||||
- Keep provider, filesystem, and network deterministic through the first-pass simulation boundaries.
|
||||
|
||||
## Stage 3: Controlled Clock
|
||||
|
||||
- Move high-impact backend `Date.now()` call sites to Effect clock/time services where practical.
|
||||
- Add a simulation clock service.
|
||||
- Let the runner advance logical time.
|
||||
|
||||
## Stage 4: Controlled Timers And Event Loop
|
||||
|
||||
- Wrap TUI timer use through a scheduler service where practical.
|
||||
- Expose SDK event batching timers to the harness.
|
||||
- Let the runner advance timers as part of quiescence.
|
||||
|
||||
## Stage 5: Async Interleaving Exploration
|
||||
|
||||
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
|
||||
- Replay exact interleavings from traces.
|
||||
|
||||
## Differential Runs
|
||||
|
||||
Later, reuse the useful idea from the old branch's differential runner:
|
||||
|
||||
- Run the same trace against two app versions or two configurations.
|
||||
- Normalize volatile fields.
|
||||
- Report semantic diffs instead of timestamp/ID noise.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Define which nondeterminism remains after first-pass replay.
|
||||
- [ ] Decide whether deterministic IDs are needed or trace normalization is enough.
|
||||
- [ ] Identify highest-impact `Date.now()` call sites.
|
||||
- [ ] Design a minimal simulation clock only if needed.
|
||||
- [ ] Design timer control only after fake renderer/action runner behavior is stable.
|
||||
- [ ] Add differential runner after trace replay is reliable.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 05 Reference Notes
|
||||
|
||||
Status: reference material. Keep this short and update as implementation discovers new seams.
|
||||
|
||||
## Current TUI Map
|
||||
|
||||
- `packages/opencode/src/cli/cmd/tui/thread.ts`: starts the TUI worker and in-process transport.
|
||||
- `packages/opencode/src/cli/cmd/tui/app.tsx`: creates the OpenTUI renderer/keymap and renders the Solid app.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/sdk.tsx`: SDK client, custom fetch, event source, event batching.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/sync.tsx`: projects backend events into TUI state.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/route.tsx`: route state.
|
||||
- `packages/opencode/src/cli/cmd/tui/context/prompt.tsx`: current prompt ref.
|
||||
- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`: prompt input and submit path.
|
||||
- `packages/opencode/src/cli/cmd/tui/keymap.tsx`: base keymap registration and `useBindings` exports.
|
||||
- `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`: useful model for harness context exposure.
|
||||
|
||||
## Current Backend Map
|
||||
|
||||
- `packages/opencode/src/server/server.ts`: exposes `Server.Default().app.request(...)`.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/server.ts`: route tree and production layers.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`: prompt/prompt_async/session endpoints.
|
||||
- `packages/opencode/src/session/prompt.ts`: real prompt loop, tool resolution, LLM orchestration.
|
||||
- `packages/opencode/src/session/llm.ts`: provider language model seam and `streamText(...)` call.
|
||||
- `packages/opencode/src/provider/provider.ts`: normal provider discovery/loading path.
|
||||
- `packages/opencode/src/mcp/index.ts`: MCP network/process seam.
|
||||
- `packages/opencode/src/tool/registry.ts`: built-in and plugin tool registry.
|
||||
- `packages/opencode/src/storage/db.ts`: `OPENCODE_DB` and `:memory:` support.
|
||||
- `packages/opencode/src/id/id.ts`: timestamp/random ID generation.
|
||||
|
||||
## Prior Branch Notes
|
||||
|
||||
Branch: `jlongster/fuzz-backend`.
|
||||
|
||||
Useful ideas to reuse:
|
||||
|
||||
- Mock AI SDK provider emitted real language-model stream chunks.
|
||||
- Compact LLM script action format worked well.
|
||||
- Step selection by counting tool-result rounds worked well.
|
||||
- HTTP/SSE backend runner waited for `session.status` idle.
|
||||
- Tool discovery and schema-shaped fake input generation were useful.
|
||||
- TUI runner used internal prompt ref to submit scripted prompts.
|
||||
- Differential runner normalized volatile fields and compared runs.
|
||||
- SQLite was forced to `:memory:`.
|
||||
- `sandbox-exec` denied external network and host filesystem access.
|
||||
- Bun preload/plugin direction can catch imports that bypass service boundaries.
|
||||
|
||||
Things to avoid:
|
||||
|
||||
- No JSON-in-prompt protocol or fallback.
|
||||
- No unseeded `Math.random()` in generated actions.
|
||||
- No partial mock filesystem that silently falls back to host FS.
|
||||
- No broad replacement of app service graph when a narrow override works.
|
||||
|
||||
## Old Sandbox Setup
|
||||
|
||||
Starting files on prior branch:
|
||||
|
||||
- `packages/opencode/src/provider/sdk/mock/sandbox.sb`
|
||||
- `packages/opencode/src/provider/sdk/mock/run`
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
|
||||
- `(allow default)` so the process can boot.
|
||||
- `(deny network*)` with localhost re-allowed.
|
||||
- `(deny file-write*)`.
|
||||
- Deny reads from `$HOME/.local` and `$HOME/.config`.
|
||||
|
||||
Adapt this setup into the new simulation runner layout rather than inventing a new sandbox policy first.
|
||||
@@ -0,0 +1,881 @@
|
||||
# Property-Based TUI And Backend Testing Plan
|
||||
|
||||
Status: rough architectural draft.
|
||||
|
||||
This document sketches an incremental path for property-based, deterministic-simulation-style testing of the opencode TUI against a real opencode backend, without hitting external network services.
|
||||
|
||||
## Goals
|
||||
|
||||
- Drive the TUI as the primary user surface.
|
||||
- Exercise the real backend request, session, message, tool, permission, and event pipelines.
|
||||
- Replace external effects with deterministic local services.
|
||||
- Record enough information to replay failures.
|
||||
- Build a semantic model of UI actions, backend requests, and state transitions over time.
|
||||
- Start with a small useful runner, then grow toward deterministic simulation testing.
|
||||
|
||||
## Non-Goals For The First Pass
|
||||
|
||||
- Full fake-clock replacement for every `setTimeout`, `Date.now`, and animation path.
|
||||
- Exhaustive exploration of every visual TUI state.
|
||||
- Web app testing.
|
||||
- Real external LLM/provider, MCP, webfetch, websearch, update, or share network calls.
|
||||
|
||||
## Current Code Map
|
||||
|
||||
### TUI
|
||||
|
||||
- TUI startup is centered in `packages/opencode/src/cli/cmd/tui/thread.ts` and `packages/opencode/src/cli/cmd/tui/app.tsx`.
|
||||
- `TuiThreadCommand` starts a worker, builds an in-process fetch/event transport when possible, and calls `tui(...)`.
|
||||
- `tui(...)` creates the OpenTUI `CliRenderer`, creates the keymap, and renders the Solid app.
|
||||
- `SDKProvider` in `context/sdk.tsx` owns SDK creation, custom fetch injection, event subscription, event batching, retry, and timers.
|
||||
- `SyncProvider` in `context/sync.tsx` projects backend events into TUI state: sessions, messages, parts, permissions, questions, todos, diffs, MCP, formatter, LSP, and VCS.
|
||||
- `RouteProvider` in `context/route.tsx` owns route state.
|
||||
- `PromptRefProvider` in `context/prompt.tsx` exposes the current prompt ref.
|
||||
- The main prompt is `component/prompt/index.tsx`. It exposes `set`, `reset`, and `submit` through `PromptRef`, and its submit path eventually calls SDK session APIs.
|
||||
- `keymap.tsx` centralizes base keymap registration and re-exports `useBindings`; app and prompt commands are registered through this layer.
|
||||
- `plugin/api.tsx` already centralizes access to renderer, route, keymap, state, SDK client, dialog, KV, and event APIs. This is a useful model for a test harness API.
|
||||
|
||||
### Backend
|
||||
|
||||
- `packages/opencode/src/server/server.ts` exposes `Server.Default().app.request(...)`, which is useful for in-process HTTP tests.
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/server.ts` assembles all routes and provides production service layers.
|
||||
- `createRoutes(...)` currently provides concrete production layers inside the route builder, including `Provider.defaultLayer`, `MCP.defaultLayer`, `ToolRegistry.defaultLayer`, `AppFileSystem.defaultLayer`, and `FetchHttpClient.layer`.
|
||||
- `groups/session.ts` and `handlers/session.ts` define the important session HTTP surface: create, prompt, prompt_async, command, shell, abort, permission response, message reads, revert, and update paths.
|
||||
- `SessionPrompt.Service` in `session/prompt.ts` creates user messages, resolves prompt parts, resolves tools, loops over LLM/tool calls, and writes messages/parts.
|
||||
- `LLM.Service` in `session/llm.ts` is the main provider seam. It calls `Provider.Service.getLanguage(...)` and then `streamText(...)`.
|
||||
- `Provider.Service` in `provider/provider.ts` can dynamically load provider SDKs and may install packages or use network. Simulation should use the normal provider path with a local mock provider/model and sandbox/network guards, not wholesale service replacement.
|
||||
- `MCP.Service` in `mcp/index.ts` can open remote HTTP/SSE connections or local child processes. Simulation should keep normal app startup and disable/configure MCP by default; only add a narrow MCP control seam when a test needs MCP states.
|
||||
- `ToolRegistry.Service` in `tool/registry.ts` exposes built-in and plugin tools. Filesystem tools should run against the mock filesystem in simulation mode; process/network tools must be disabled or replaced.
|
||||
- `Database.Path` in `storage/db.ts` is controlled by `OPENCODE_DB` and supports `:memory:`. Tests already reset/close DB state.
|
||||
- `Identifier` in `id/id.ts`, many `Date.now()` calls, and some `Math.random()` use are determinism hazards.
|
||||
|
||||
### Previous `jlongster/fuzz-backend` Branch
|
||||
|
||||
Useful ideas:
|
||||
|
||||
- A mock AI SDK provider emitted real language-model stream chunks.
|
||||
- The old branch showed that a compact scripted action format works, but scripts must be supplied through simulation control APIs instead of user prompt text.
|
||||
- Actions included `text`, `thinking`, `tool_call`, `list_tools`, and `error`.
|
||||
- Step selection by counting tool-result rounds after the last user message was a good fit for model/tool loops.
|
||||
- The runner drove the backend through HTTP plus SSE, waited for `session.status` to become idle, and then inspected messages.
|
||||
- `/experimental/tool` discovery plus schema-based fake input generation was a useful generation seed.
|
||||
- The TUI runner used an internal component to select the mock model, set prompt text through `PromptRef`, submit, and wait for idle.
|
||||
- The differential runner normalized volatile fields and compared runs.
|
||||
- The runner forced SQLite to `:memory:` so each run started with a clean in-process database.
|
||||
- The runner used macOS `sandbox-exec` to deny external network and host filesystem access around the whole app process.
|
||||
- The branch included a mock filesystem direction; the concept is correct and should be made complete enough for backend tools and app services instead of relying on real workspace files.
|
||||
|
||||
Ideas to avoid or rework:
|
||||
|
||||
- Do not hardcode the mock provider into normal provider discovery.
|
||||
- Do not use unseeded `Math.random()`.
|
||||
- Do not make the user-visible prompt text carry hidden control instructions at all.
|
||||
- Do not implement a partial mock filesystem and assume all filesystem effects are covered; the backend mock filesystem must be a first-class simulation service with explicit unsupported-operation failures.
|
||||
|
||||
## Core Design Decision: Endpoint Control, Not Prompt Control
|
||||
|
||||
The primary harness should control backend behavior through a test-only simulation control endpoint or in-process control service. The TUI should then submit ordinary prompt text through the normal UI.
|
||||
|
||||
This is better than embedding control data in the prompt because:
|
||||
|
||||
- It keeps prompt contents realistic, so prompt UI behavior can be tested independently from backend scripting.
|
||||
- It keeps transcripts and message history understandable.
|
||||
- It works for non-prompt workflows like command palette actions, session summarization, permission flows, shell mode, model switching, and future MCP controls.
|
||||
- It lets the runner prepare backend state before the next UI action.
|
||||
- It gives us a natural place to force future backend state, such as MCP state, tool results, filesystem state, provider errors, and pending permission/question state.
|
||||
- It makes replay traces explicit: `control.enqueueLLM(...)`, then `ui.submitPrompt(...)`.
|
||||
|
||||
There should be no JSON-in-prompt fallback. If no endpoint-enqueued script matches a model request, the mock LLM should fail with a clear simulation error. This keeps user-visible prompt text realistic and makes replay traces explicit.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The system has five layers:
|
||||
|
||||
1. Simulation backend services.
|
||||
2. TUI driver and observation harness.
|
||||
3. Semantic UI and backend graph builder.
|
||||
4. Property runner, generator, replay, and shrinker.
|
||||
5. Later DST controls for clock, timers, schedulers, and async ordering.
|
||||
|
||||
The initial runner loop should look like this:
|
||||
|
||||
```text
|
||||
seed -> start isolated backend -> mount TUI -> observe state
|
||||
repeat N times:
|
||||
choose next UI action from current semantic state
|
||||
optionally enqueue backend script/control data
|
||||
execute the UI action
|
||||
wait for quiescence
|
||||
record UI/backend/network/event observations
|
||||
run relevant properties
|
||||
update semantic graph
|
||||
on failure:
|
||||
persist replay trace and human-readable report
|
||||
```
|
||||
|
||||
## Simulation Backend Services
|
||||
|
||||
### Production App With Narrow Overrides
|
||||
|
||||
The runner should load the normal app by default. Avoid building a separate test route tree or installing a broad graph of mock services. The goal is to run production wiring and only override the few core effect boundaries that must be deterministic.
|
||||
|
||||
The first required override is `AppFileSystem.Service`, so backend-visible files come from the in-memory mock filesystem. Other overrides should be added only when the app cannot be controlled through configuration, the simulation control endpoint, or the sandbox policy.
|
||||
|
||||
Possible narrow shape:
|
||||
|
||||
```ts
|
||||
// Conceptual API, not final names.
|
||||
export function createRoutes(input?: {
|
||||
cors?: CorsOptions
|
||||
overrides?: {
|
||||
appFileSystem?: Layer.Layer<AppFileSystem.Service>
|
||||
}
|
||||
}) {
|
||||
return productionRoutesWithProductionServices(input)
|
||||
}
|
||||
```
|
||||
|
||||
The important part is not the exact type. The important part is that simulation mode should not need to re-provide provider, MCP, tool registry, network, or most backend services. It should load the whole app and make the smallest viable changes, starting with the filesystem boundary.
|
||||
|
||||
### Simulation Control State
|
||||
|
||||
Add simulation-only control state that owns deterministic run state. This is not a replacement for app services; it is the small state store used by control endpoints and the mock provider.
|
||||
|
||||
Proposed source location:
|
||||
|
||||
- `packages/opencode/src/testing/simulation/service.ts`
|
||||
- `packages/opencode/src/testing/simulation/provider.ts`
|
||||
- `packages/opencode/src/testing/simulation/filesystem.ts`
|
||||
- `packages/opencode/src/testing/simulation/httpapi.ts`
|
||||
- `packages/opencode/src/testing/simulation/network.ts`
|
||||
- `packages/opencode/src/testing/simulation/runner.ts`
|
||||
|
||||
The service should be instance-scoped where possible and keyed by a `runID`.
|
||||
|
||||
Core responsibilities:
|
||||
|
||||
- Hold seeded RNG state.
|
||||
- Hold queued LLM scripts.
|
||||
- Hold mock filesystem state.
|
||||
- Record UI action IDs, backend request IDs, events, tool calls, and state changes.
|
||||
- Enforce network policy.
|
||||
- Provide snapshots for replay/failure reports.
|
||||
- Reset state between runs.
|
||||
|
||||
Conceptual control API:
|
||||
|
||||
```ts
|
||||
type SimulationControl = {
|
||||
reset(input: { runID: string; seed: string }): Effect.Effect<void>
|
||||
enqueueLLM(input: { runID: string; match?: LLMScriptMatch; script: LLMScript }): Effect.Effect<void>
|
||||
snapshot(input: { runID: string }): Effect.Effect<SimulationSnapshot>
|
||||
recordAction(input: UIActionRecord): Effect.Effect<void>
|
||||
recordRequest(input: BackendRequestRecord): Effect.Effect<void>
|
||||
recordEvent(input: BackendEventRecord): Effect.Effect<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Control Endpoint
|
||||
|
||||
Add a gated endpoint under the instance HTTP API, probably `/experimental/simulation/*`.
|
||||
|
||||
Suggested endpoints:
|
||||
|
||||
- `POST /experimental/simulation/reset`
|
||||
- `POST /experimental/simulation/llm/enqueue`
|
||||
- `GET /experimental/simulation/snapshot`
|
||||
- `POST /experimental/simulation/action/start`
|
||||
- `POST /experimental/simulation/action/end`
|
||||
|
||||
Access should be impossible in normal production use unless explicit simulation mode is enabled. If the endpoint is added to the typed HttpApi surface, regenerate the JS SDK with `./packages/sdk/js/script/build.ts`. The runner can also call the endpoint with raw fetch to avoid making this a public user-facing API.
|
||||
|
||||
### Mock LLM Provider
|
||||
|
||||
The main LLM mock should be a real provider/model path, not a replacement for `SessionPrompt` or a wholesale replacement of `Provider.Service`.
|
||||
|
||||
Preferred seam:
|
||||
|
||||
- Register or configure a local simulation provider/model through the normal provider system.
|
||||
- Implement its language model with an AI SDK-compatible mock language model adapted to the current AI SDK version.
|
||||
- Let the existing `LLM.Service` call `streamText(...)`, process tools, and emit normal stream events.
|
||||
|
||||
This preserves more of the real backend path than replacing `LLM.Service` or `Provider.Service` directly.
|
||||
|
||||
The mock model should read the next script from `Simulation.Service` using request context:
|
||||
|
||||
- `runID`
|
||||
- `sessionID`
|
||||
- `messageID` or last user message ID
|
||||
- model/provider ID
|
||||
- tool round number
|
||||
|
||||
If no endpoint-enqueued script matches, the mock model should fail with a typed simulation error that includes the run ID, session ID, model, and tool round. Silent default responses and prompt parsing would hide missing runner setup.
|
||||
|
||||
Script action schema:
|
||||
|
||||
```ts
|
||||
type LLMScriptAction =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
type LLMScript = {
|
||||
steps: LLMScriptAction[][]
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
|
||||
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Keep the old rule that step `0` runs before tool results and step `N` runs after `N` tool-result rounds. The endpoint-backed service should also record which script step was consumed so replay reports are explicit.
|
||||
|
||||
### Database, Filesystem, Tools, MCP, And Network
|
||||
|
||||
Initial policy:
|
||||
|
||||
- Force `OPENCODE_DB=:memory:` for the simulation backend process. This should be a hard simulation-mode invariant, not a per-test preference.
|
||||
- Run the app/backend under macOS `sandbox-exec` by default for local simulation runs, following the old branch's technique: deny external network, deny host filesystem writes, and only allow the minimum paths needed to boot the process and communicate over loopback/in-process transports.
|
||||
- Add a first-class backend mock filesystem and provide it by overriding `AppFileSystem.Service` instead of relying on a real temp workspace for app-visible files.
|
||||
- Use the normal provider system with a local simulation provider/model; do not replace `Provider.Service` unless a later implementation proves a small seam is unavoidable.
|
||||
- Keep MCP on the normal app path and disable/configure it by default so it starts no network or child processes. Add narrow MCP controls later only for tests that explicitly target MCP states.
|
||||
- Use sandbox/network guards to reject non-loopback network. Only override `HttpClient.HttpClient` if a minimal core override is needed to make failures typed and observable.
|
||||
- Disable or fake webfetch, websearch, share, update, repo clone, and other external tools through normal config/tool policy where possible.
|
||||
- Run read/glob/grep/write/edit against the mock filesystem, not the host filesystem.
|
||||
- Treat bash/shell execution as opt-in and fake it by default, because real process execution bypasses the mock filesystem and sandbox policy is the last line of defense.
|
||||
|
||||
Later policy:
|
||||
|
||||
- Add deterministic fake child process and shell tools.
|
||||
- Add deterministic fake LSP/file-watcher events.
|
||||
|
||||
The mock filesystem should be authoritative for backend-visible project files. The host filesystem should only be used for runner artifacts, bundled source/config needed to start the app, and sandbox-allowed runtime plumbing. Any app path that escapes the mock filesystem should fail with a typed simulation error so missing coverage is obvious.
|
||||
|
||||
### In-Memory Database
|
||||
|
||||
Simulation mode should set the database to memory globally for the backend process:
|
||||
|
||||
```text
|
||||
OPENCODE_DB=:memory:
|
||||
```
|
||||
|
||||
This must happen before any import path evaluates `storage/db.ts`, because `Database.Path` is computed at module load. The simulation bootstrap should own process startup so this cannot be missed. Each run should start from an empty DB and seed any required sessions/state through public services or simulation controls.
|
||||
|
||||
### Sandbox Isolation
|
||||
|
||||
The local runner should reuse the old branch's `sandbox-exec` setup on macOS as the starting implementation. Specifically, adapt `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/sandbox.sb` and `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/run` into the new simulation runner layout.
|
||||
|
||||
The old setup did the right first-order thing:
|
||||
|
||||
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
|
||||
- Start from `(allow default)` so the process can boot.
|
||||
- Deny all network with `(deny network*)`.
|
||||
- Re-allow localhost network for local server/TUI communication.
|
||||
- Deny all filesystem writes with `(deny file-write*)`.
|
||||
- Deny reads from sensitive user config/state directories like `$HOME/.local` and `$HOME/.config`.
|
||||
|
||||
The sandbox is not the primary abstraction for deterministic behavior; it is the safety boundary that proves missed hooks cannot touch the host filesystem or external network.
|
||||
|
||||
Initial sandbox policy should stay close to the old branch:
|
||||
|
||||
- Deny outbound network except loopback when a real listener is used.
|
||||
- Deny host filesystem writes inside the sandbox. The parent runner can write trace artifacts outside the sandbox after collecting them over stdout, HTTP, or another explicit control channel.
|
||||
- Deny reads from user config/state locations unless explicitly mounted as test fixtures.
|
||||
- Fail fast when a denied operation happens so the trace records a simulation escape.
|
||||
|
||||
The mock filesystem and network guards should still exist inside the app. `sandbox-exec` catches leaks; it should not be the mechanism that normal simulated I/O depends on.
|
||||
|
||||
### Backend Mock Filesystem
|
||||
|
||||
The mock filesystem should be implemented as a real simulation service, not as test fixture files on disk.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Store files, directories, symlinks if needed, executable bits if needed, mtimes, and binary/text content in memory.
|
||||
- Provide deterministic path resolution for workspace root, current directory, home, config, state, and temp paths.
|
||||
- Expose operations needed by `AppFileSystem.Service`, read/write/edit tools, glob/grep/ripgrep equivalents, config reads, snapshot/diff, and prompt file attachment resolution.
|
||||
- Emit deterministic file change/snapshot events when writes happen.
|
||||
- Make unsupported operations fail explicitly with typed simulation errors.
|
||||
- Support seeding initial file trees from JSON fixtures and serializing filesystem state into replay traces.
|
||||
|
||||
Implementation should prefer a narrow `AppFileSystem.Service` override first. If file/ripgrep services or filesystem tools bypass that boundary, make the smallest targeted change to route them through the mock filesystem rather than replacing the whole tool registry. If some backend paths still import `@/util/filesystem` or direct host filesystem APIs, use the same Bun preload/plugin technique from the old branch to redirect those imports in simulation mode. The sandbox should then catch any remaining direct `fs`, `Bun.file`, or child-process access that was not routed through the mock filesystem.
|
||||
|
||||
### Backend Quiescence
|
||||
|
||||
The first useful quiescence definition should be pragmatic:
|
||||
|
||||
- No session has `session.status.type === "busy"`.
|
||||
- The TUI sync queue has flushed.
|
||||
- The runner has seen all events produced by the current action.
|
||||
- The renderer has completed at least one frame after the last event.
|
||||
- No pending simulation-controlled LLM stream or tool call remains.
|
||||
|
||||
This is not full DST yet. It is enough to avoid racing the next action against obvious async work.
|
||||
|
||||
## TUI Driver And Observation Harness
|
||||
|
||||
### Harness Injection
|
||||
|
||||
Do not add another hardcoded environment-runner component like the old `Mock` component. Instead, extend `tui(...)`/`App` with an optional test harness hook.
|
||||
|
||||
Conceptual shape:
|
||||
|
||||
```ts
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
fetch?: typeof fetch
|
||||
events?: EventSource
|
||||
testing?: TuiHarness.Input
|
||||
})
|
||||
```
|
||||
|
||||
The `App` can mount a tiny `TuiHarnessProbe` only when `testing` is provided. The probe exposes the same kinds of context that `plugin/api.tsx` already gathers:
|
||||
|
||||
- route
|
||||
- keymap
|
||||
- prompt ref
|
||||
- sync state
|
||||
- SDK client
|
||||
- renderer
|
||||
- dialog state
|
||||
- KV state
|
||||
- event bus
|
||||
- local model/agent state
|
||||
|
||||
This gives tests a stable internal API without coupling to a specific visible component.
|
||||
|
||||
### Driver Modes
|
||||
|
||||
Start with two modes:
|
||||
|
||||
- Semantic in-process mode: uses context APIs, keymap commands, prompt refs, SDK fetch wrappers, and renderer snapshots. This is the main property runner.
|
||||
- Terminal/PTY mode: later, spawn the real binary in a PTY, inject bytes, and read terminal snapshots. This catches lower-level terminal regressions but is slower.
|
||||
|
||||
The semantic mode should still exercise OpenTUI rendering, Solid state, keymap registration, SDK calls, backend routes, SSE/events, and prompt submit flows.
|
||||
|
||||
### Action Types
|
||||
|
||||
Initial action types:
|
||||
|
||||
```ts
|
||||
type UIAction =
|
||||
| { type: "command"; command: string }
|
||||
| { type: "prompt.set"; text: string }
|
||||
| { type: "prompt.submit"; text?: string; llm?: LLMScript }
|
||||
| { type: "key"; key: string; modifiers?: string[] }
|
||||
| { type: "paste"; text: string }
|
||||
| { type: "click"; elementID: string }
|
||||
| { type: "wait"; condition: "idle" | "frame"; timeoutMs: number }
|
||||
```
|
||||
|
||||
The runner should prefer semantic commands first. Raw key and mouse actions are useful, but command-level actions are easier to shrink and replay.
|
||||
|
||||
### Observation Types
|
||||
|
||||
Every action should record before/after observations:
|
||||
|
||||
```ts
|
||||
type UIObservation = {
|
||||
route: unknown
|
||||
dialogDepth: number
|
||||
focusedElement?: string
|
||||
semanticElements: SemanticElement[]
|
||||
bufferHash?: string
|
||||
visibleText?: string
|
||||
syncSummary: SyncSummary
|
||||
errors: SimulationError[]
|
||||
}
|
||||
```
|
||||
|
||||
Initial `visibleText` can come from renderer/test-render snapshots where available. Later PTY mode should capture the terminal buffer directly.
|
||||
|
||||
### TUI State Changers To Track
|
||||
|
||||
Frontend state can change because of:
|
||||
|
||||
- Keyboard events.
|
||||
- Mouse events.
|
||||
- Paste and IME submit deferrals.
|
||||
- Terminal resize and theme detection.
|
||||
- SDK HTTP responses.
|
||||
- SDK event stream messages.
|
||||
- Timers used for batching, focus, prompt submit, animations, retry, and placeholders.
|
||||
- Local KV, prompt history, prompt stash, model recents/favorites.
|
||||
- Plugin registration, routes, slots, commands, events, and toasts.
|
||||
- Clipboard/selection flows.
|
||||
- Process signals and terminal suspend/resume.
|
||||
|
||||
The first runner does not need full control over all of these. It should record them when they occur and gradually move high-impact sources under simulation control.
|
||||
|
||||
## Semantic UI Graph
|
||||
|
||||
### Semantic Registry
|
||||
|
||||
Add a TUI semantic registry that components can use to announce interactive elements and available actions.
|
||||
|
||||
Conceptual element shape:
|
||||
|
||||
```ts
|
||||
type SemanticElement = {
|
||||
id: string
|
||||
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
|
||||
label: string
|
||||
enabled: boolean
|
||||
visible: boolean
|
||||
state?: Record<string, unknown>
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
actions: SemanticAction[]
|
||||
}
|
||||
```
|
||||
|
||||
First components to instrument:
|
||||
|
||||
- `Prompt` for text input, submit, shell mode, slash commands, file/agent attachments.
|
||||
- App commands registered in `app.tsx`.
|
||||
- Prompt commands registered in `component/prompt/index.tsx`.
|
||||
- `DialogSelect` for option movement/filter/select.
|
||||
- Permission and question overlays.
|
||||
- Route state in `RouteProvider`.
|
||||
- Session message parts, especially tool and error parts.
|
||||
|
||||
This should be additive metadata. It should not change rendering behavior.
|
||||
|
||||
### Graph Shape
|
||||
|
||||
The graph should abstract states instead of storing every concrete UI snapshot.
|
||||
|
||||
```ts
|
||||
type SemanticState = {
|
||||
id: string
|
||||
route: string
|
||||
dialog?: string
|
||||
elementSignature: string
|
||||
backendSignature?: string
|
||||
}
|
||||
|
||||
type SemanticTransition = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
action: UIAction
|
||||
uiChanged: string[]
|
||||
backendRequests: BackendRequestRecord[]
|
||||
backendEvents: BackendEventRecord[]
|
||||
coverage: string[]
|
||||
failures: SimulationFailure[]
|
||||
}
|
||||
```
|
||||
|
||||
State hashing should initially normalize volatile IDs/timestamps. As deterministic IDs/clocks land, less normalization will be needed.
|
||||
|
||||
### Discovery Pass
|
||||
|
||||
The discovery runner randomly chooses from currently available semantic actions, executes them, observes transitions, and writes a graph artifact.
|
||||
|
||||
Suggested output:
|
||||
|
||||
- `.opencode/simulation/ui-graph.json`
|
||||
- `.opencode/simulation/backend-graph.json`
|
||||
- `.opencode/simulation/runs/<runID>.jsonl`
|
||||
|
||||
The graph is not expected to be perfect. It should answer practical questions:
|
||||
|
||||
- What actions are available from each abstract UI state?
|
||||
- Which actions produce backend requests?
|
||||
- Which actions open dialogs, create sessions, request permissions, create tool parts, or show errors?
|
||||
- Which action sequences reach the prompt, session, permission, question, model selection, MCP, and session list states?
|
||||
|
||||
### Directed Runner
|
||||
|
||||
After discovery, the directed runner should use the graph to bias generation toward requested targets.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Focus prompt submit with tool calls."
|
||||
- "Exercise permission approve/reject flows."
|
||||
- "Exercise session list and route changes."
|
||||
- "Exercise backend prompt_async and SessionPrompt loop states."
|
||||
|
||||
The directed runner can plan a route through the graph to a target state, then run generated variants from there.
|
||||
|
||||
## Mapping UI Actions To Backend Requests
|
||||
|
||||
Every generated action should have an `actionID`.
|
||||
|
||||
The TUI fetch wrapper should add headers:
|
||||
|
||||
- `x-opencode-simulation-run`
|
||||
- `x-opencode-simulation-action`
|
||||
- `x-opencode-simulation-step`
|
||||
|
||||
The backend should record request spans:
|
||||
|
||||
```ts
|
||||
type BackendRequestRecord = {
|
||||
runID: string
|
||||
actionID?: string
|
||||
requestID: string
|
||||
method: string
|
||||
path: string
|
||||
endpoint?: string
|
||||
status: number
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
Async work needs explicit correlation. For example, `prompt_async` returns before the session run finishes. The handler should attach the current `actionID` to the created user message/session run in `Simulation.Service`, so later LLM/tool/session events can be attributed to the same UI action.
|
||||
|
||||
Backend events should also be recorded:
|
||||
|
||||
```ts
|
||||
type BackendEventRecord = {
|
||||
runID: string
|
||||
actionID?: string
|
||||
eventID: string
|
||||
type: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
domains: string[]
|
||||
}
|
||||
```
|
||||
|
||||
This gives the graph the important edge information: UI action -> HTTP request -> backend state/event changes -> TUI sync changes.
|
||||
|
||||
## Backend Semantic Analysis
|
||||
|
||||
Backend semantic analysis should start from cheap instrumentation:
|
||||
|
||||
- HTTP endpoint entry/exit.
|
||||
- Bus/SyncEvent publications.
|
||||
- Session status changes.
|
||||
- Message and part writes.
|
||||
- Permission and question asks/replies.
|
||||
- Tool start/finish/error.
|
||||
- LLM stream start/finish/error.
|
||||
|
||||
The first backend state domains:
|
||||
|
||||
- `session`
|
||||
- `message`
|
||||
- `part`
|
||||
- `permission`
|
||||
- `question`
|
||||
- `todo`
|
||||
- `tool`
|
||||
- `mcp`
|
||||
- `filesystem`
|
||||
- `network`
|
||||
- `status`
|
||||
|
||||
Later, add DB snapshots or table-level hashes for deeper invariants. Do not read and diff the whole database after every action until we know it is needed.
|
||||
|
||||
## Property API
|
||||
|
||||
Properties should be ordinary TypeScript functions registered with the runner.
|
||||
|
||||
Conceptual API:
|
||||
|
||||
```ts
|
||||
type Property = {
|
||||
name: string
|
||||
domains: string[]
|
||||
check: (ctx: PropertyContext) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
`domains` lets the runner skip checks when unrelated state changed.
|
||||
|
||||
Example properties:
|
||||
|
||||
```ts
|
||||
property({
|
||||
name: "app.does-not-crash",
|
||||
domains: ["tui", "backend"],
|
||||
async check(ctx) {
|
||||
ctx.expect(ctx.tui.errors).toEqual([])
|
||||
ctx.expect(ctx.backend.errors).toEqual([])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Built-in properties for pass one:
|
||||
|
||||
- The app does not crash. This includes uncaught TUI render errors and unhandled backend failures caused by the generated action.
|
||||
|
||||
Later properties:
|
||||
|
||||
- No non-loopback network call.
|
||||
- Session eventually becomes idle after prompt-like actions.
|
||||
- No pending tool call remains after idle.
|
||||
- Every TUI-visible session message has valid message/part schemas.
|
||||
- Permission/question overlays correspond to backend pending requests.
|
||||
- Replay trace can be parsed and rerun.
|
||||
- Text should not flicker across stable frames.
|
||||
- Dialog focus should remain valid.
|
||||
- Route state and visible route agree.
|
||||
- Backend DB invariants hold after every endpoint group.
|
||||
- Tool call lifecycle events are balanced.
|
||||
- No generated action sequence can strand a session in busy state.
|
||||
|
||||
## Failure Reports And Replay
|
||||
|
||||
On failure, persist a trace and a concise report.
|
||||
|
||||
Trace should include:
|
||||
|
||||
- Seed and run configuration.
|
||||
- Initial mock filesystem fixture and workspace/config path mapping.
|
||||
- Simulation control calls.
|
||||
- UI action sequence.
|
||||
- LLM scripts consumed.
|
||||
- HTTP request records.
|
||||
- Backend events.
|
||||
- UI observations before/after each action.
|
||||
- Property checks and failure details.
|
||||
- Normalization version.
|
||||
|
||||
Human report should include:
|
||||
|
||||
- Failed property name.
|
||||
- Seed and action index.
|
||||
- Minimal replay command.
|
||||
- The last N UI actions.
|
||||
- The backend requests/events caused by the failing action.
|
||||
- Visible TUI text/buffer before and after.
|
||||
- Any session/message/tool IDs relevant to the failure.
|
||||
|
||||
Initial replay can simply rerun the exact trace. Shrinking can come later.
|
||||
|
||||
Shrinking plan:
|
||||
|
||||
- Delete contiguous chunks of actions.
|
||||
- Reduce generated prompt text.
|
||||
- Reduce LLM scripts to fewer actions/steps.
|
||||
- Prefer semantic action shrinking over raw key shrinking.
|
||||
- Preserve explicit control calls needed to reproduce backend state.
|
||||
|
||||
## DST Roadmap
|
||||
|
||||
Full deterministic simulation testing requires more than seeded random actions. It requires control over time and async scheduling. Build this gradually.
|
||||
|
||||
### Stage 1: Record And Normalize
|
||||
|
||||
- Seed RNG for the runner.
|
||||
- Normalize timestamps and generated IDs in traces.
|
||||
- Record timer registrations and delayed events where easy.
|
||||
- Use quiescence waits instead of fake time.
|
||||
|
||||
### Stage 2: Deterministic Data Sources
|
||||
|
||||
- Add deterministic ID generation behind an injectable service or simulation mode.
|
||||
- Replace `Math.random()` usage in TUI placeholders/tests with seeded RNG in simulation mode.
|
||||
- Replace provider, MCP, network, and unsafe tools with deterministic services.
|
||||
|
||||
### Stage 3: Controlled Clock
|
||||
|
||||
- Move high-impact backend `Date.now()` call sites to Effect clock/time services.
|
||||
- Add a simulation clock service.
|
||||
- Let the runner advance logical time.
|
||||
|
||||
### Stage 4: Controlled Timers And Event Loop
|
||||
|
||||
- Wrap TUI timer use through a scheduler service where practical.
|
||||
- Expose SDK event batching timers to the harness.
|
||||
- Let the runner advance timers as part of quiescence.
|
||||
|
||||
### Stage 5: Async Interleaving Exploration
|
||||
|
||||
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
|
||||
- Replay exact interleavings from traces.
|
||||
|
||||
## Implementation Passes
|
||||
|
||||
### Pass 1: Backend-Only Deterministic Prompt Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Local mock LLM provider/model registered through the normal provider path.
|
||||
- Simulation control state and raw or typed control endpoint.
|
||||
- Sandboxed backend runner that loads the normal app and applies only narrow core overrides, starting with `AppFileSystem.Service`.
|
||||
- Process bootstrap that forces `OPENCODE_DB=:memory:` before backend modules load.
|
||||
- Initial backend mock filesystem service with seeded fixture support.
|
||||
- macOS `sandbox-exec` runner wrapper that denies external network and host filesystem access.
|
||||
- Seeded generation of LLM scripts based on available tools.
|
||||
- Replay trace for backend-only prompt runs.
|
||||
|
||||
Scope:
|
||||
|
||||
- Create session.
|
||||
- Seed mock filesystem contents.
|
||||
- Enqueue LLM script.
|
||||
- Call `prompt_async`.
|
||||
- Wait for idle over events.
|
||||
- Assert basic backend properties.
|
||||
|
||||
Validation:
|
||||
|
||||
- Run 10 to 100 generated backend prompt cases without external network.
|
||||
- Prove filesystem reads/writes hit the mock filesystem and not the host filesystem.
|
||||
- Prove tool-call, text, reasoning, and error scripts hit the real `SessionPrompt` and `SessionProcessor` path.
|
||||
|
||||
### Pass 2: TUI Prompt Smoke Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Optional `testing` hook in `tui(...)`/`App`.
|
||||
- In-process TUI harness exposing prompt ref, route, sync, keymap, SDK, and renderer.
|
||||
- Fetch/event wrappers that add simulation action headers and record requests/events.
|
||||
- A runner action that enqueues LLM script, sets prompt text through TUI, submits, waits for idle, and checks no crash.
|
||||
|
||||
Scope:
|
||||
|
||||
- Prompt input and submit only.
|
||||
- Normal text and one tool-call script.
|
||||
- Real backend, fake external services.
|
||||
|
||||
Validation:
|
||||
|
||||
- Run TUI -> prompt -> backend -> LLM script -> tool/result -> TUI message display.
|
||||
- Persist and replay a trace.
|
||||
|
||||
### Pass 3: Semantic UI Registry
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `TuiSemanticProvider` and registry API.
|
||||
- Instrument prompt, app commands, prompt commands, route state, dialog select, permission, and question components.
|
||||
- Snapshot current semantic elements from the harness.
|
||||
- Random semantic action generator.
|
||||
|
||||
Scope:
|
||||
|
||||
- Commands, prompt text/submit, dialog option select, permission approve/reject, question answer/reject.
|
||||
|
||||
Validation:
|
||||
|
||||
- Generate random semantic actions for a fixed number of steps.
|
||||
- Build a small UI transition graph.
|
||||
- Replay any generated sequence.
|
||||
|
||||
### Pass 4: Directed Property Runner
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Property registration API.
|
||||
- Domain-based property filtering.
|
||||
- Built-in no-crash/no-network/session-idle/tool-lifecycle properties.
|
||||
- Directed generation targets based on semantic graph.
|
||||
|
||||
Scope:
|
||||
|
||||
- User asks for a focus area and iteration/depth count.
|
||||
- Runner biases actions toward graph paths related to that area.
|
||||
|
||||
Validation:
|
||||
|
||||
- `prompt submit with tool calls` target produces many prompt/tool/session variants.
|
||||
- `permission flows` target reaches permission UI and exercises approve/reject.
|
||||
|
||||
### Pass 5: Backend Graph And Endpoint Mapping
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Request and event correlation by action ID.
|
||||
- Backend domain change records.
|
||||
- Endpoint/action graph export.
|
||||
- Basic backend state signatures.
|
||||
|
||||
Scope:
|
||||
|
||||
- Session, message, part, permission, question, todo, tool, and status domains.
|
||||
|
||||
Validation:
|
||||
|
||||
- Given a UI action, report which backend endpoints and state domains changed.
|
||||
- Given a backend endpoint/domain, report UI actions that reached it.
|
||||
|
||||
### Pass 6: Determinism Hardening
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Seeded RNG everywhere in the runner.
|
||||
- Deterministic ID/time mode for high-impact backend paths.
|
||||
- Timer registration recording.
|
||||
- More complete network/process guards.
|
||||
- Complete backend mock filesystem coverage for configured filesystem tools and app services.
|
||||
|
||||
Scope:
|
||||
|
||||
- Reduce trace normalization.
|
||||
- Make failures replay reliably across machines.
|
||||
|
||||
Validation:
|
||||
|
||||
- Same seed and trace produce same observations modulo approved volatile fields.
|
||||
|
||||
### Pass 7: Shrinking And Differential Runs
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Action sequence shrinker.
|
||||
- Prompt/script shrinker.
|
||||
- Dual-run differential runner similar in spirit to the old branch.
|
||||
- Stable normalization rules for diff output.
|
||||
|
||||
Scope:
|
||||
|
||||
- Compare current branch against baseline or two configurations.
|
||||
|
||||
Validation:
|
||||
|
||||
- Induced failure shrinks to a short reproducible action trace.
|
||||
- Differential runner reports meaningful semantic diffs, not timestamp/ID noise.
|
||||
|
||||
## Suggested Initial File Layout
|
||||
|
||||
```text
|
||||
packages/opencode/src/testing/simulation/service.ts
|
||||
packages/opencode/src/testing/simulation/provider.ts
|
||||
packages/opencode/src/testing/simulation/filesystem.ts
|
||||
packages/opencode/src/testing/simulation/httpapi.ts
|
||||
packages/opencode/src/testing/simulation/network.ts
|
||||
packages/opencode/src/testing/simulation/mcp.ts
|
||||
packages/opencode/src/testing/simulation/tool-registry.ts
|
||||
packages/opencode/src/testing/simulation/sandbox.sb
|
||||
packages/opencode/src/testing/simulation/run.ts
|
||||
packages/opencode/src/cli/cmd/tui/testing/harness.tsx
|
||||
packages/opencode/src/cli/cmd/tui/testing/semantic.tsx
|
||||
packages/opencode/test/property/backend-runner.test.ts
|
||||
packages/opencode/test/property/tui-runner.test.ts
|
||||
packages/opencode/test/property/properties.ts
|
||||
packages/opencode/test/property/generator.ts
|
||||
```
|
||||
|
||||
If we want the harness code completely out of production bundles, keep more of it under `test/property`. The server route, TUI optional hook, and any simulation-gated services that the app imports need to live under `src`.
|
||||
|
||||
## Open Questions To Resolve During Implementation
|
||||
|
||||
- Should the simulation endpoint be a typed HttpApi route that regenerates SDK, or an internal raw route used only by the runner?
|
||||
- Should the first mock model target the current AI SDK provider interface directly, or temporarily fake `LLM.Service` while the provider mock is adapted?
|
||||
- How much renderer tree metadata does OpenTUI expose for stable bounds and visible text snapshots?
|
||||
- Which tools should be enabled by default in generation: read/glob/grep/todo only, or write/edit against the mock filesystem too?
|
||||
- Where should trace artifacts live by default so they are easy to inspect but not accidentally committed?
|
||||
|
||||
## Recommended Starting Point
|
||||
|
||||
Start with Pass 1 and Pass 2.
|
||||
|
||||
The smallest useful end-to-end test is:
|
||||
|
||||
1. Start an isolated backend under `sandbox-exec` with `OPENCODE_DB=:memory:`, simulation provider, fake MCP, guarded network, and seeded mock filesystem.
|
||||
2. Mount the TUI with a harness hook and in-process fetch/event transport.
|
||||
3. Enqueue an LLM script through simulation control.
|
||||
4. Set the prompt to ordinary text like `hello` and submit through `PromptRef`.
|
||||
5. Wait for session idle and TUI sync.
|
||||
6. Assert no TUI/backend errors and that the expected assistant text/tool part appears.
|
||||
7. Persist a replay trace containing the seed, control call, UI action, requests, events, and observations.
|
||||
|
||||
This gives immediate value while leaving room for semantic graph discovery, directed properties, failure shrinking, and true DST controls later.
|
||||
@@ -65,24 +65,24 @@ export type ActiveOrg = {
|
||||
org: Org
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
export const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
export class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
export class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
@@ -90,14 +90,14 @@ class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
export class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
export class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
@@ -110,22 +110,22 @@ class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError"
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
export const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
export class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
export class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
export class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
export class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Identifier } from "@/id/id"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
const log = Log.create({ service: "bus" })
|
||||
|
||||
@@ -185,11 +187,12 @@ export function createID() {
|
||||
}
|
||||
|
||||
export async function publish<D extends BusEvent.Definition>(
|
||||
ctx: InstanceContext,
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) {
|
||||
return runPromise((svc) => svc.publish(def, properties, options))
|
||||
return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
|
||||
|
||||
@@ -66,6 +66,8 @@ const AgentCreateCommand = effectCmd({
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const agentSvc = yield* Agent.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
@@ -127,7 +129,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 Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
|
||||
const generated = await runLocalEffect(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()
|
||||
|
||||
@@ -435,6 +435,9 @@ export const GithubRunCommand = effectCmd({
|
||||
const sessionSvc = yield* Session.Service
|
||||
const sessionShare = yield* SessionShare.Service
|
||||
const sessionPrompt = yield* SessionPrompt.Service
|
||||
const busSvc = yield* Bus.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
const isMock = args.token || args.event
|
||||
|
||||
@@ -548,7 +551,7 @@ export const GithubRunCommand = effectCmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await Effect.runPromise(
|
||||
session = await runLocalEffect(
|
||||
sessionSvc.create({
|
||||
permission: [
|
||||
{
|
||||
@@ -559,11 +562,11 @@ export const GithubRunCommand = effectCmd({
|
||||
],
|
||||
}),
|
||||
)
|
||||
subscribeSessionEvents()
|
||||
await subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await Effect.runPromise(sessionShare.share(session.id))
|
||||
await runLocalEffect(sessionShare.share(session.id))
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
@@ -870,7 +873,7 @@ export const GithubRunCommand = effectCmd({
|
||||
return { userPrompt: prompt, promptFiles: imgData }
|
||||
}
|
||||
|
||||
function subscribeSessionEvents() {
|
||||
async function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
|
||||
@@ -893,33 +896,35 @@ export const GithubRunCommand = effectCmd({
|
||||
}
|
||||
|
||||
let text = ""
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
await runLocalEffect(
|
||||
busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title || Object.keys(part.state.input).length > 0
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
printEvent(color, tool, title)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
text = part.text
|
||||
|
||||
if (part.time?.end) {
|
||||
UI.empty()
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title || Object.keys(part.state.input).length > 0
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
printEvent(color, tool, title)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (part.type === "text") {
|
||||
text = part.text
|
||||
|
||||
if (part.time?.end) {
|
||||
UI.empty()
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function summarize(response: string) {
|
||||
@@ -936,7 +941,7 @@ export const GithubRunCommand = effectCmd({
|
||||
async function chat(message: string, files: PromptFiles = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
|
||||
return Effect.runPromise(
|
||||
return runLocalEffect(
|
||||
Effect.gen(function* () {
|
||||
const prompt = sessionPrompt
|
||||
const result = yield* prompt.prompt({
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@openc
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
|
||||
@@ -236,6 +237,7 @@ export const RunCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const agentSvc = yield* Agent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const localInstance = yield* InstanceRef
|
||||
yield* Effect.promise(async () => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||
@@ -508,7 +510,9 @@ export const RunCommand = effectCmd({
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
const entry = await Effect.runPromise(agentSvc.get(name))
|
||||
const entry = await Effect.runPromise(
|
||||
agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)),
|
||||
)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
@@ -68,6 +68,7 @@ import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
|
||||
import { DiffViewer } from "./routes/diff"
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
@@ -102,6 +103,7 @@ const appBindingCommands = [
|
||||
"theme.switch",
|
||||
"theme.switch_mode",
|
||||
"theme.mode.lock",
|
||||
"diff.open",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"app.debug",
|
||||
@@ -165,6 +167,10 @@ export function tui(input: {
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
renderer?: CliRenderer
|
||||
mode?: "dark" | "light"
|
||||
onReady?: (ctx: { renderer: CliRenderer }) => void | Promise<void | { simulationMcpUrl?: string }>
|
||||
onStop?: (stop: () => Promise<void>) => void
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
|
||||
@@ -182,10 +188,11 @@ export function tui(input: {
|
||||
TuiAudio.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
const renderer = input.renderer ?? (await createCliRenderer(rendererConfig(input.config)))
|
||||
const [simulationMcpUrl, setSimulationMcpUrl] = createSignal<string | undefined>()
|
||||
// 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"
|
||||
const mode = input.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
|
||||
@@ -198,7 +205,7 @@ export function tui(input: {
|
||||
)}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ArgsProvider {...input.args} simulationMcpUrl={simulationMcpUrl}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
@@ -232,7 +239,7 @@ export function tui(input: {
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
<AppLifecycle onSnapshot={input.onSnapshot} onStop={input.onStop} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
@@ -256,9 +263,17 @@ export function tui(input: {
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
const ready = await input.onReady?.({ renderer })
|
||||
if (ready?.simulationMcpUrl) setSimulationMcpUrl(ready.simulationMcpUrl)
|
||||
})
|
||||
}
|
||||
|
||||
function AppLifecycle(props: { onSnapshot?: () => Promise<string[]>; onStop?: (stop: () => Promise<void>) => void }) {
|
||||
const exit = useExit()
|
||||
props.onStop?.(() => exit())
|
||||
return <App onSnapshot={props.onSnapshot} />
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
@@ -339,6 +354,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [diffOpen, setDiffOpen] = createSignal(false)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
|
||||
)
|
||||
@@ -650,6 +666,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "diff.open",
|
||||
title: "Open diff viewer",
|
||||
slashName: "diff",
|
||||
run: () => {
|
||||
setDiffOpen(true)
|
||||
dialog.clear()
|
||||
},
|
||||
category: "VCS",
|
||||
},
|
||||
{
|
||||
name: "help.show",
|
||||
title: "Help",
|
||||
@@ -859,6 +885,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
console.log("installation.update-available", evt)
|
||||
const version = evt.properties.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
@@ -948,6 +975,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
<TuiPluginRuntime.Slot name="app_bottom" />
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
<Show when={diffOpen()}>
|
||||
<DiffViewer onClose={() => setDiffOpen(false)} />
|
||||
</Show>
|
||||
</Show>
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
|
||||
@@ -27,6 +27,11 @@ export type PromptInfo = {
|
||||
|
||||
const MAX_HISTORY_ENTRIES = 50
|
||||
|
||||
export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
|
||||
if (!previous) return false
|
||||
return JSON.stringify(previous) === JSON.stringify(next)
|
||||
}
|
||||
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
@@ -83,6 +88,10 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
return
|
||||
}
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const Definitions = {
|
||||
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
|
||||
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
||||
status_view: keybind("<leader>s", "View status"),
|
||||
diff_open: keybind("<leader>d", "Open diff viewer"),
|
||||
|
||||
session_export: keybind("<leader>x", "Export session to editor"),
|
||||
session_copy: keybind("none", "Copy session transcript"),
|
||||
@@ -251,6 +252,7 @@ export const CommandMap = {
|
||||
sidebar_toggle: "session.sidebar.toggle",
|
||||
scrollbar_toggle: "session.toggle.scrollbar",
|
||||
status_view: "opencode.status",
|
||||
diff_open: "diff.open",
|
||||
session_export: "session.export",
|
||||
session_copy: "session.copy",
|
||||
session_new: "session.new",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FormatError } from "@/cli/error"
|
||||
|
||||
/**
|
||||
* Aggregate Promise.allSettled results into a single Error that names every
|
||||
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||
@@ -15,7 +17,19 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
)
|
||||
if (failed.length === 0) return null
|
||||
|
||||
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
|
||||
const reasons = Array.from(
|
||||
failed
|
||||
.map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
|
||||
.reduce((grouped, failure) => {
|
||||
grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
|
||||
return grouped
|
||||
}, new Map<string, string[]>())
|
||||
.entries(),
|
||||
)
|
||||
.map(([message, names]) =>
|
||||
names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
|
||||
)
|
||||
.join("; ")
|
||||
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||
const err = new Error(summary)
|
||||
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||
@@ -23,6 +37,9 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
}
|
||||
|
||||
function reasonMessage(reason: unknown): string {
|
||||
const formatted = FormatError(reason)
|
||||
if (formatted) return formatted
|
||||
|
||||
if (reason instanceof Error) return reason.message
|
||||
if (typeof reason === "string") return reason
|
||||
if (reason && typeof reason === "object") {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Args {
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
simulationMcpUrl?: () => string | undefined
|
||||
continue?: boolean
|
||||
sessionID?: string
|
||||
fork?: boolean
|
||||
|
||||
@@ -457,7 +457,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const exists = sessionStore.pinned.includes(sessionID)
|
||||
const next = exists
|
||||
? sessionStore.pinned.filter((x) => x !== sessionID)
|
||||
: [sessionID, ...sessionStore.pinned]
|
||||
: [...sessionStore.pinned, sessionID]
|
||||
setSessionStore("pinned", next)
|
||||
save()
|
||||
})
|
||||
|
||||
@@ -962,6 +962,7 @@ function getSyntaxRules(theme: Theme) {
|
||||
style: {
|
||||
foreground: theme.markdownHeading,
|
||||
bold: true,
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createMemo, type Setter } from "solid-js"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export type ThinkingMode = "show" | "minimal" | "hide"
|
||||
export type ThinkingMode = "show" | "hide"
|
||||
|
||||
const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const
|
||||
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
|
||||
|
||||
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
|
||||
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
|
||||
@@ -20,7 +19,7 @@ export function isThinkingMode(value: unknown): value is ThinkingMode {
|
||||
return typeof value === "string" && (MODES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Cycle order matches the slash command: show → minimal → hide → show.
|
||||
// Cycle order matches the slash command: show → hide → show.
|
||||
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
|
||||
const idx = MODES.indexOf(current)
|
||||
return MODES[(idx + 1) % MODES.length] ?? "show"
|
||||
@@ -33,7 +32,7 @@ export function useThinkingMode() {
|
||||
// The KVProvider only renders children once kv.ready, so reads here are safe.
|
||||
const hadStored = kv.get("thinking_mode") !== undefined
|
||||
const legacy = kv.get("thinking_visibility")
|
||||
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "minimal")
|
||||
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
|
||||
|
||||
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
|
||||
// overload set; passing an updater fn through a property access loses the
|
||||
@@ -47,21 +46,21 @@ export function useThinkingMode() {
|
||||
|
||||
// Preserve previous experience for users who had explicitly toggled the
|
||||
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
|
||||
// get the new "minimal" default.
|
||||
// get the new "hide" default (collapsed thinking).
|
||||
if (!hadStored) {
|
||||
if (legacy === true) set("show")
|
||||
else if (legacy === false) set("hide")
|
||||
}
|
||||
|
||||
if ((stored() as string) === "minimal") set("hide")
|
||||
|
||||
const mode = createMemo<ThinkingMode>(() => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
|
||||
const value = stored()
|
||||
return isThinkingMode(value) ? value : "minimal"
|
||||
return isThinkingMode(value) ? value : "hide"
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
set,
|
||||
locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ function AssistantReasoning(props: {
|
||||
const thinking = useThinkingMode()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
|
||||
const inMinimal = createMemo(() => thinking.mode() === "minimal")
|
||||
const inMinimal = createMemo(() => thinking.mode() === "hide")
|
||||
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
||||
// in the v2 SDK); we settle on parent-message completion instead.
|
||||
const isDone = createMemo(() => props.completedAt() !== undefined)
|
||||
@@ -404,7 +404,7 @@ function AssistantReasoning(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && thinking.mode() !== "hide"}>
|
||||
<Show when={content()}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
<box
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { parsePatch } from "diff"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
type DiffFile = {
|
||||
readonly file: string
|
||||
readonly patch: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
const stripPrefix = (file: string | undefined) => {
|
||||
if (!file || file === "/dev/null") return undefined
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const splitRawDiff = (text: string) => {
|
||||
const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
if (starts.length === 0) return text.trim() ? [text] : []
|
||||
return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
|
||||
}
|
||||
|
||||
const parseRawDiff = (text: string): DiffFile[] => {
|
||||
const chunks = splitRawDiff(text)
|
||||
return chunks.flatMap((chunk) => {
|
||||
const parsed = parsePatch(chunk)[0]
|
||||
const file = stripPrefix(parsed?.newFileName) ?? stripPrefix(parsed?.oldFileName)
|
||||
if (!parsed || !file) return []
|
||||
|
||||
const counts = parsed.hunks.flatMap((hunk) => hunk.lines).reduce(
|
||||
(acc, line) => ({
|
||||
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
|
||||
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
file,
|
||||
patch: chunk,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: parsed.oldFileName === "/dev/null" ? "added" : parsed.newFileName === "/dev/null" ? "deleted" : "modified",
|
||||
} satisfies DiffFile,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const lineKind = (line: string) => {
|
||||
if (line.startsWith("+")) return "added"
|
||||
if (line.startsWith("-")) return "deleted"
|
||||
if (line.startsWith("@@")) return "hunk"
|
||||
if (line.startsWith("diff --git") || line.startsWith("index ")) return "meta"
|
||||
return "context"
|
||||
}
|
||||
|
||||
export function DiffViewer(props: { onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [raw] = createResource(async () => {
|
||||
const result = await sdk.client.vcs.diff2.raw(undefined, { throwOnError: true })
|
||||
return result.data ?? ""
|
||||
})
|
||||
const files = createMemo(() => parseRawDiff(raw() ?? ""))
|
||||
const current = createMemo(() => files()[selected()])
|
||||
const lines = createMemo(() => current()?.patch.trimEnd().split(/\r?\n/) ?? [])
|
||||
|
||||
const move = (delta: number) => {
|
||||
const total = files().length
|
||||
if (total === 0) return
|
||||
setSelected((selected() + delta + total) % total)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (selected() >= files().length) setSelected(Math.max(0, files().length - 1))
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
priority: 2000,
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "j",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
{
|
||||
key: "q",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2500}
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>Diff</text>
|
||||
<text fg={theme.textMuted}>working tree</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>j/k select · q/esc close</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={2}>
|
||||
<box
|
||||
width={32}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>Files</text>
|
||||
<Switch>
|
||||
<Match when={raw.loading}>
|
||||
<text fg={theme.textMuted}>Loading diff...</text>
|
||||
</Match>
|
||||
<Match when={raw.error}>
|
||||
<text fg={theme.error}>Failed to load diff</text>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<text fg={theme.text}>No changes</text>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file, index) => (
|
||||
<box flexDirection="row" gap={1} backgroundColor={index() === selected() ? theme.backgroundElement : undefined}>
|
||||
<text fg={index() === selected() ? theme.accent : theme.text}>{index() === selected() ? "›" : " "}</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{file.file}
|
||||
</text>
|
||||
<text fg={theme.diffAdded}>+{file.additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file.deletions}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.borderActive}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<Show
|
||||
when={current()}
|
||||
fallback={<text fg={theme.textMuted}>{raw.loading ? "Loading diff..." : raw.error ? "Failed to load diff" : "No diff to show"}</text>}
|
||||
>
|
||||
{(file) => (
|
||||
<>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={theme.text}>{file().file}</text>
|
||||
<text fg={theme.textMuted}>{file().status}</text>
|
||||
<text fg={theme.diffAdded}>+{file().additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file().deletions}</text>
|
||||
</box>
|
||||
<scrollbox flexGrow={1} minHeight={0}>
|
||||
<For each={lines()}>
|
||||
{(line) => {
|
||||
const kind = lineKind(line)
|
||||
return (
|
||||
<text
|
||||
fg={
|
||||
kind === "added"
|
||||
? theme.diffAdded
|
||||
: kind === "deleted"
|
||||
? theme.diffRemoved
|
||||
: kind === "hunk"
|
||||
? theme.accent
|
||||
: kind === "meta"
|
||||
? theme.textMuted
|
||||
: theme.text
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
{line || " "}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createSignal, onMount } from "solid-js"
|
||||
import { createEffect, createSignal, onMount, Show } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSync } from "../context/sync"
|
||||
@@ -88,7 +88,10 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<box width="100%" flexShrink={0} paddingLeft={2} paddingRight={2}>
|
||||
<Show when={args.simulationMcpUrl?.()}>
|
||||
{(url) => <text fg="yellow">Simulation mode MCP: {url()}</text>}
|
||||
</Show>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
</box>
|
||||
</>
|
||||
|
||||
@@ -218,7 +218,7 @@ export function Session() {
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => thinkingMode() !== "hide")
|
||||
const showThinking = createMemo(() => true)
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
@@ -689,9 +689,8 @@ export function Session() {
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
if (next === "minimal") return "Switch thinking to minimal"
|
||||
if (next === "hide") return "Hide thinking"
|
||||
return "Show thinking"
|
||||
if (next === "hide") return "Collapse thinking"
|
||||
return "Expand thinking"
|
||||
})(),
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
@@ -700,16 +699,6 @@ export function Session() {
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
run: () => {
|
||||
// Env override forces minimal for the process. Updating KV here would
|
||||
// silently diverge from what's rendered; tell the user instead.
|
||||
if (thinking.locked()) {
|
||||
toast.show({
|
||||
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
|
||||
variant: "info",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
thinking.set(nextThinkingMode(thinkingMode()))
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -1512,7 +1501,7 @@ const PART_MAPPING = {
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in minimal mode: a single line throughout, so the
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
@@ -1523,7 +1512,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
|
||||
// Flips independently of the parent message completing.
|
||||
const isDone = createMemo(() => props.part.time.end !== undefined)
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
|
||||
const duration = createMemo(() => {
|
||||
const end = props.part.time.end
|
||||
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
||||
@@ -1539,10 +1528,10 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() && ctx.thinkingMode() !== "hide"}>
|
||||
<Show when={content()}>
|
||||
<Switch>
|
||||
<Match when={!inMinimal() || expanded()}>
|
||||
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
|
||||
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
@@ -1558,7 +1547,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
|
||||
content={(inMinimal() ? "▼ " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import {
|
||||
OPENCODE_PROCESS_ROLE,
|
||||
OPENCODE_RUN_ID,
|
||||
ensureRunID,
|
||||
sanitizedProcessEnv,
|
||||
} from "@opencode-ai/core/util/opencode-process"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import type { SimulationMcpRuntimeState } from "./simulation-mcp"
|
||||
import type { rpc } from "./worker"
|
||||
import { SimulationDebugLog } from "../../../testing/simulation/debug-log"
|
||||
import { SimulationNetworkLog } from "./simulation-network-log"
|
||||
import { fileURLToPath } from "url"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
|
||||
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
|
||||
const simulatedDirectory = "/opencode"
|
||||
const simulatedCwdEnv = "OPENCODE_SIMULATION_CWD"
|
||||
|
||||
function fakeCwd(directory: string) {
|
||||
process.env.PWD = directory
|
||||
Object.defineProperty(process, "cwd", {
|
||||
value: () => directory,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
interface Transport {
|
||||
readonly url: string
|
||||
readonly fetch: typeof fetch
|
||||
readonly events: EventSource
|
||||
}
|
||||
|
||||
interface RunningInstance {
|
||||
readonly client: RpcClient
|
||||
readonly done: Promise<void>
|
||||
readonly stopTui: () => Promise<void>
|
||||
readonly stopWorker: () => Promise<void>
|
||||
}
|
||||
|
||||
function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(input, init)
|
||||
const body = request.body ? await request.text() : undefined
|
||||
const headers = Object.fromEntries(request.headers.entries())
|
||||
SimulationDebugLog.write("simulate.fetch.start", { method: request.method, url: request.url })
|
||||
const startedAt = Date.now()
|
||||
const startedAtIso = new Date(startedAt).toISOString()
|
||||
try {
|
||||
const result = await client.call("fetch", {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
SimulationDebugLog.write("simulate.fetch.end", {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: result.status,
|
||||
})
|
||||
SimulationNetworkLog.record({
|
||||
time: startedAtIso,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: result.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
requestHeaders: headers,
|
||||
requestBody: body,
|
||||
responseHeaders: result.headers,
|
||||
responseBody: result.body,
|
||||
})
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: result.headers,
|
||||
})
|
||||
} catch (err) {
|
||||
SimulationNetworkLog.record({
|
||||
time: startedAtIso,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
requestHeaders: headers,
|
||||
requestBody: body,
|
||||
responseHeaders: {},
|
||||
responseBody: "",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return fn as typeof fetch
|
||||
}
|
||||
|
||||
function createEventSource(client: RpcClient, onSubscribe?: () => void): EventSource {
|
||||
return {
|
||||
subscribe: async (handler) => {
|
||||
// SimulationDebugLog.write("simulate.events.subscribe")
|
||||
onSubscribe?.()
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
// SimulationDebugLog.write("simulate.events.received", {
|
||||
// directory: e.directory,
|
||||
// workspace: e.workspace,
|
||||
// type: e.payload?.type,
|
||||
// sync: e.payload?.type === "sync",
|
||||
// })
|
||||
handler(e)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function target() {
|
||||
const workerPath = Reflect.get(globalThis, "OPENCODE_WORKER_PATH")
|
||||
if (typeof workerPath === "string") return workerPath
|
||||
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||
if (await Filesystem.exists(fileURLToPath(dist))) return dist
|
||||
return new URL("./worker.ts", import.meta.url)
|
||||
}
|
||||
|
||||
export const SimulateCommand = cmd({
|
||||
command: "simulate",
|
||||
describe: "start restartable simulated opencode tui",
|
||||
handler: async () => {
|
||||
SimulationDebugLog.reset()
|
||||
fakeCwd(simulatedDirectory)
|
||||
const file = await target()
|
||||
const cwd = simulatedDirectory
|
||||
const config = await TuiConfig.get()
|
||||
const simulationMcpMode = Flag.OPENCODE_SIMULATION ? "stdio" : "remote"
|
||||
|
||||
let currentInstance: RunningInstance | undefined
|
||||
let currentRuntime: SimulationMcpRuntimeState | undefined
|
||||
let restartRequested = false
|
||||
let restartWaiter:
|
||||
| {
|
||||
readonly promise: Promise<{ restarted: true }>
|
||||
readonly resolve: (value: { restarted: true }) => void
|
||||
readonly reject: (error: unknown) => void
|
||||
}
|
||||
| undefined
|
||||
|
||||
const error = (e: unknown) => {
|
||||
Log.Default.error("process error", { error: errorMessage(e) })
|
||||
}
|
||||
const reload = () => {
|
||||
currentInstance?.client.call("reload", undefined).catch((err) => {
|
||||
Log.Default.warn("worker reload failed", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
process.on("uncaughtException", error)
|
||||
process.on("unhandledRejection", error)
|
||||
process.on("SIGUSR2", reload)
|
||||
|
||||
const simulationMcpModule = await import("./simulation-mcp")
|
||||
const simulationMcp = await simulationMcpModule.TuiSimulationMcp.createSimulationMcpServer({
|
||||
mode: simulationMcpMode,
|
||||
runtime: {
|
||||
current: () => currentRuntime,
|
||||
restart: async () => {
|
||||
if (restartWaiter) return restartWaiter.promise
|
||||
if (!currentInstance) throw new Error("Simulation TUI is not ready")
|
||||
restartRequested = true
|
||||
let resolve!: (value: { restarted: true }) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<{ restarted: true }>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
restartWaiter = { promise, resolve, reject }
|
||||
currentInstance.stopTui().catch((err) => restartWaiter?.reject(err))
|
||||
return promise
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const stopWorker = async (client: RpcClient, worker: Worker) => {
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => {
|
||||
Log.Default.warn("worker shutdown failed", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
worker.terminate()
|
||||
}
|
||||
|
||||
const start = async (): Promise<RunningInstance> => {
|
||||
const worker = new Worker(file, {
|
||||
env: sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
PWD: simulatedDirectory,
|
||||
[simulatedCwdEnv]: simulatedDirectory,
|
||||
// Simulated filesystem lives in InMemoryFs — SQLite needs to be in-memory
|
||||
// too, since real fs writes to `/opencode/.local/share/opencode/...` will
|
||||
// fail (the directory only exists in the sim FS).
|
||||
OPENCODE_DB: ":memory:",
|
||||
}),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error("thread error", {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
error: e.error,
|
||||
})
|
||||
}
|
||||
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
let eventSubscribedResolve!: () => void
|
||||
const eventSubscribed = new Promise<void>((resolve) => {
|
||||
eventSubscribedResolve = resolve
|
||||
})
|
||||
const transport: Transport = {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
events: createEventSource(client, eventSubscribedResolve),
|
||||
}
|
||||
|
||||
const { tui } = await import("./app")
|
||||
const simulationRenderer = Flag.OPENCODE_SIMULATION
|
||||
? await (await import("./simulation")).TuiSimulation.createSimulationRenderer()
|
||||
: undefined
|
||||
|
||||
let stopTui = async () => {}
|
||||
let stopReadyResolve!: () => void
|
||||
const stopReady = new Promise<void>((resolve) => {
|
||||
stopReadyResolve = resolve
|
||||
})
|
||||
let readyResolve!: () => void
|
||||
let readyReject!: (error: unknown) => void
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
readyResolve = resolve
|
||||
readyReject = reject
|
||||
})
|
||||
const done = tui({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
renderer: simulationRenderer?.renderer,
|
||||
mode: simulationRenderer ? "dark" : undefined,
|
||||
onStop: (stop) => {
|
||||
stopTui = stop
|
||||
stopReadyResolve()
|
||||
},
|
||||
onReady: async (ctx) => {
|
||||
try {
|
||||
currentRuntime = {
|
||||
harness: simulationRenderer
|
||||
? simulationMcpModule.TuiSimulationMcp.harnessFromSimulationRenderer(simulationRenderer)
|
||||
: simulationMcpModule.TuiSimulationMcp.harnessFromRenderer(ctx.renderer),
|
||||
controlUrl: transport.url,
|
||||
controlFetch: transport.fetch,
|
||||
}
|
||||
if (simulationRenderer) await simulationRenderer.renderOnce()
|
||||
readyResolve()
|
||||
return { simulationMcpUrl: simulationMcp.url }
|
||||
} catch (err) {
|
||||
readyReject(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
args: {},
|
||||
})
|
||||
|
||||
await Promise.all([ready, stopReady, eventSubscribed])
|
||||
return {
|
||||
client,
|
||||
done,
|
||||
stopTui: () => stopTui(),
|
||||
stopWorker: async () => {
|
||||
simulationRenderer?.destroy()
|
||||
await stopWorker(client, worker)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
currentInstance = await start()
|
||||
restartWaiter?.resolve({ restarted: true })
|
||||
restartWaiter = undefined
|
||||
restartRequested = false
|
||||
} catch (err) {
|
||||
restartWaiter?.reject(err)
|
||||
throw err
|
||||
}
|
||||
|
||||
await currentInstance.done
|
||||
currentRuntime = undefined
|
||||
await currentInstance.stopWorker()
|
||||
currentInstance = undefined
|
||||
if (!restartRequested) break
|
||||
}
|
||||
} finally {
|
||||
currentRuntime = undefined
|
||||
await currentInstance?.stopTui().catch(() => {})
|
||||
await currentInstance?.stopWorker()
|
||||
await simulationMcp.stop()
|
||||
process.off("uncaughtException", error)
|
||||
process.off("unhandledRejection", error)
|
||||
process.off("SIGUSR2", reload)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export * as TuiSimulate from "./simulate"
|
||||
@@ -0,0 +1,891 @@
|
||||
import { SimulationActions } from "@/testing/simulation/actions"
|
||||
import { SimulationNetworkLog } from "./simulation-network-log"
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import type { CapturedFrame, CliRenderer } from "@opentui/core"
|
||||
import { createMockKeys, createMockMouse } from "@opentui/core/testing"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import z from "zod/v4"
|
||||
import type { SimulationRenderer } from "./simulation"
|
||||
|
||||
export type SimulationMcpMode = "stdio" | "remote"
|
||||
|
||||
export interface SimulationMcpHarness {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: SimulationActions.MockInput
|
||||
readonly mockMouse: SimulationActions.MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
readonly screen: () => string
|
||||
}
|
||||
|
||||
export interface SimulationMcpOptions {
|
||||
readonly mode: SimulationMcpMode
|
||||
readonly harness: SimulationMcpHarness
|
||||
readonly controlUrl: string
|
||||
readonly controlFetch?: typeof fetch
|
||||
}
|
||||
|
||||
export interface SimulationMcpRuntimeState {
|
||||
readonly harness: SimulationMcpHarness
|
||||
readonly controlUrl: string
|
||||
readonly controlFetch?: typeof fetch
|
||||
}
|
||||
|
||||
export interface RestartableSimulationMcpOptions {
|
||||
readonly mode: SimulationMcpMode
|
||||
readonly runtime: {
|
||||
readonly current: () => SimulationMcpRuntimeState | undefined
|
||||
readonly restart: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type Options = SimulationMcpOptions | RestartableSimulationMcpOptions
|
||||
|
||||
export interface SimulationMcpServer {
|
||||
readonly mode: SimulationMcpMode
|
||||
readonly url?: string
|
||||
readonly stop: () => Promise<void>
|
||||
}
|
||||
|
||||
const DefaultRemotePort = 43110
|
||||
const MaxPortAttempts = 100
|
||||
const MasterInstanceID = "master"
|
||||
|
||||
interface RemoteInstance {
|
||||
readonly id: string
|
||||
readonly port: number
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
readonly result?: unknown
|
||||
readonly error?: {
|
||||
readonly code?: number
|
||||
readonly message?: string
|
||||
}
|
||||
}
|
||||
|
||||
type RenderBuffer = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
getRealCharBytes(includeAnsi?: boolean): Uint8Array
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const ActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("typeText"), text: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("pressKey"),
|
||||
key: z.string(),
|
||||
modifiers: z
|
||||
.object({
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
super: z.boolean().optional(),
|
||||
hyper: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("pressEnter") }),
|
||||
z.object({ type: z.literal("pressArrow"), direction: z.enum(["up", "down", "left", "right"]) }),
|
||||
z.object({ type: z.literal("focus"), target: z.number() }),
|
||||
z.object({ type: z.literal("click"), target: z.number(), x: z.number(), y: z.number() }),
|
||||
]) satisfies z.ZodType<SimulationActions.Action>
|
||||
|
||||
const FileContentSchema = z.union([
|
||||
z.string(),
|
||||
z.object({ encoding: z.literal("base64"), data: z.string() }),
|
||||
])
|
||||
|
||||
const NetworkRegistrationSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("json"),
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.unknown(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("text"),
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("status"),
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
const LlmScriptActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("text"), content: z.string() }),
|
||||
z.object({ type: z.literal("thinking"), content: z.string() }),
|
||||
z.object({ type: z.literal("error"), message: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.any(),
|
||||
}),
|
||||
])
|
||||
|
||||
const LlmScriptSchema = z.object({
|
||||
steps: z.array(z.array(LlmScriptActionSchema)),
|
||||
usage: z
|
||||
.object({
|
||||
inputTokens: z.number(),
|
||||
outputTokens: z.number(),
|
||||
totalTokens: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
finish: z.enum(["stop", "tool-calls", "error", "length", "unknown"]).optional(),
|
||||
})
|
||||
|
||||
const ScriptActionSchema = z.union([
|
||||
ActionSchema,
|
||||
z.object({ type: z.literal("writeFile"), path: z.string(), content: FileContentSchema }),
|
||||
z.object({ type: z.literal("enqueueLLM"), scripts: z.array(LlmScriptSchema) }),
|
||||
z.object({ type: z.literal("wait"), ms: z.number().min(0).max(30_000).optional() }),
|
||||
])
|
||||
|
||||
const ScriptSchema = z.union([z.array(ScriptActionSchema), z.object({ actions: z.array(ScriptActionSchema) })])
|
||||
const TargetSchema = z.union([z.string(), z.array(z.string()).min(1), z.literal("all")])
|
||||
const BackendFetchSchema = z.object({
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.unknown().optional(),
|
||||
})
|
||||
const remoteInstances = new Map<string, RemoteInstance>()
|
||||
|
||||
function currentBuffer(renderer: CliRenderer): RenderBuffer {
|
||||
return Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer
|
||||
}
|
||||
|
||||
function remotePort() {
|
||||
const port = Number(process.env.OPENCODE_SIMULATION_MCP_PORT)
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535) return port
|
||||
return DefaultRemotePort
|
||||
}
|
||||
|
||||
function masterEnabled() {
|
||||
return process.env.OPENCODE_SIMULATION_MCP_MASTER === "1" || process.env.OPENCODE_SIMULATION_MCP_MASTER === "true"
|
||||
}
|
||||
|
||||
function childURL(port: number) {
|
||||
return `http://127.0.0.1:${port}/mcp`
|
||||
}
|
||||
|
||||
function jsonRpcError(response: JsonRpcResponse) {
|
||||
return response.error?.message ?? `MCP request failed${response.error?.code === undefined ? "" : `: ${response.error.code}`}`
|
||||
}
|
||||
|
||||
function isPortUnavailable(error: unknown) {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
|
||||
}
|
||||
|
||||
function serveRemote(
|
||||
fetch: (request: Request) => Response | Promise<Response>,
|
||||
port = remotePort(),
|
||||
attempts = MaxPortAttempts,
|
||||
): ReturnType<typeof Bun.serve> {
|
||||
try {
|
||||
return Bun.serve({ hostname: "127.0.0.1", port, idleTimeout: 0, fetch })
|
||||
} catch (error) {
|
||||
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
|
||||
return serveRemote(fetch, port + 1, attempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function harnessFromSimulationRenderer(renderer: SimulationRenderer): SimulationMcpHarness {
|
||||
return renderer
|
||||
}
|
||||
|
||||
export function harnessFromRenderer(renderer: CliRenderer): SimulationMcpHarness {
|
||||
return {
|
||||
renderer,
|
||||
mockInput: createMockKeys(renderer),
|
||||
mockMouse: createMockMouse(renderer),
|
||||
renderOnce: async () => {
|
||||
renderer.requestRender()
|
||||
await renderer.idle()
|
||||
},
|
||||
screen: () => decoder.decode(currentBuffer(renderer).getRealCharBytes(true)),
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(value: unknown) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function current(options: Options) {
|
||||
if ("runtime" in options) {
|
||||
const value = options.runtime.current()
|
||||
if (value) return value
|
||||
throw new Error("Simulation TUI is not ready")
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function state(options: Options) {
|
||||
const running = current(options)
|
||||
return {
|
||||
focused: {
|
||||
renderable: running.harness.renderer.currentFocusedRenderable?.num,
|
||||
editor: Boolean(running.harness.renderer.currentFocusedEditor),
|
||||
},
|
||||
elements: SimulationActions.elements(running.harness.renderer),
|
||||
actions: SimulationActions.actions(running.harness.renderer),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(options: Options) {
|
||||
const running = current(options)
|
||||
return {
|
||||
screen: running.harness.screen(),
|
||||
ui: state(options),
|
||||
}
|
||||
}
|
||||
|
||||
async function control(options: Options, method: string, pathname: string, body?: unknown) {
|
||||
const running = current(options)
|
||||
const response = await (running.controlFetch ?? fetch)(new URL(pathname, running.controlUrl), {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = text ? JSON.parse(text) : undefined
|
||||
if (response.ok) return data
|
||||
throw new Error(typeof data?.error === "string" ? data.error : `Simulation control request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
async function backendFetch(options: Options, input: z.infer<typeof BackendFetchSchema>) {
|
||||
const running = current(options)
|
||||
const headers = new Headers(input.headers)
|
||||
const body =
|
||||
input.body === undefined ? undefined : typeof input.body === "string" ? input.body : JSON.stringify(input.body)
|
||||
if (body !== undefined && !headers.has("content-type") && typeof input.body !== "string") headers.set("content-type", "application/json")
|
||||
|
||||
const response = await (running.controlFetch ?? fetch)(new URL(input.url, running.controlUrl), {
|
||||
method: input.method ?? (body === undefined ? "GET" : "POST"),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
return {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: await response.text(),
|
||||
}
|
||||
}
|
||||
|
||||
async function mcpRequest(url: string, method: string, params: unknown, timeout = 500) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: crypto.randomUUID(), method, params }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
})
|
||||
if (!response.ok) throw new Error(`MCP request failed: HTTP ${response.status}`)
|
||||
const data = (await response.json()) as JsonRpcResponse
|
||||
if (data.error) throw new Error(jsonRpcError(data))
|
||||
return data.result
|
||||
}
|
||||
|
||||
async function initializeChild(url: string, timeout?: number) {
|
||||
await mcpRequest(
|
||||
url,
|
||||
"initialize",
|
||||
{
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-simulation-master", version: InstallationVersion },
|
||||
},
|
||||
timeout,
|
||||
)
|
||||
await mcpRequest(url, "notifications/initialized", {}, timeout).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function childToolCall(instance: RemoteInstance, name: string, args: unknown) {
|
||||
await initializeChild(instance.url, 2_000)
|
||||
return mcpRequest(instance.url, "tools/call", { name, arguments: args }, 30_000)
|
||||
}
|
||||
|
||||
function instances() {
|
||||
return [{ id: MasterInstanceID, port: remotePort(), url: childURL(remotePort()) }, ...remoteInstances.values()]
|
||||
}
|
||||
|
||||
function selectedTargets(target: z.infer<typeof TargetSchema>) {
|
||||
const ids = target === "all" ? instances().map((item) => item.id) : Array.isArray(target) ? target : [target]
|
||||
return ids.map((id) => {
|
||||
if (id === MasterInstanceID) return { id, local: true as const }
|
||||
const remote = remoteInstances.get(id)
|
||||
if (!remote) throw new Error(`Unknown simulation instance: ${id}`)
|
||||
return { id, local: false as const, remote }
|
||||
})
|
||||
}
|
||||
|
||||
async function discoverInstances(input: { startPort?: number; maxPorts?: number; consecutiveFailures?: number } = {}) {
|
||||
const startPort = input.startPort ?? remotePort() + 1
|
||||
const maxPorts = input.maxPorts ?? 30
|
||||
const consecutiveFailures = input.consecutiveFailures ?? 3
|
||||
let failures = 0
|
||||
const found: RemoteInstance[] = []
|
||||
|
||||
for (let offset = 0; offset < maxPorts && failures < consecutiveFailures; offset++) {
|
||||
const port = startPort + offset
|
||||
if (port === remotePort()) continue
|
||||
const instance = { id: `simulation-${port}`, port, url: childURL(port) }
|
||||
try {
|
||||
await initializeChild(instance.url)
|
||||
await childToolCall(instance, "simulation_control_snapshot", {})
|
||||
remoteInstances.set(instance.id, instance)
|
||||
found.push(instance)
|
||||
failures = 0
|
||||
} catch {
|
||||
failures++
|
||||
remoteInstances.delete(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { instances: instances(), discovered: found, scanned: { startPort, maxPorts, consecutiveFailures } }
|
||||
}
|
||||
|
||||
type ScriptAction = z.infer<typeof ScriptActionSchema>
|
||||
type ScriptCounts = { uiActions: number; fileWrites: number; llmScriptsQueued: number; waits: number }
|
||||
|
||||
async function executeAction(options: Options, action: ScriptAction, counts: ScriptCounts) {
|
||||
if (action.type === "writeFile") {
|
||||
await control(options, "POST", "/experimental/simulation/filesystem/write", {
|
||||
path: action.path,
|
||||
content: action.content,
|
||||
})
|
||||
counts.fileWrites++
|
||||
return
|
||||
}
|
||||
if (action.type === "enqueueLLM") {
|
||||
await control(options, "POST", "/experimental/simulation/llm/enqueue", { scripts: action.scripts })
|
||||
counts.llmScriptsQueued += action.scripts.length
|
||||
return
|
||||
}
|
||||
if (action.type === "wait") {
|
||||
await new Promise((resolve) => setTimeout(resolve, action.ms ?? 1_000))
|
||||
await current(options).harness.renderOnce()
|
||||
counts.waits++
|
||||
return
|
||||
}
|
||||
await SimulationActions.execute(current(options).harness, action)
|
||||
counts.uiActions++
|
||||
}
|
||||
|
||||
async function runScript(options: Options, file: string) {
|
||||
const parsed = ScriptSchema.parse(await Bun.file(file).json())
|
||||
const actions = Array.isArray(parsed) ? parsed : parsed.actions
|
||||
const counts: ScriptCounts = { uiActions: 0, fileWrites: 0, llmScriptsQueued: 0, waits: 0 }
|
||||
for (const action of actions) await executeAction(options, action, counts)
|
||||
return { file, actions: actions.length, ...counts, snapshot: snapshot(options) }
|
||||
}
|
||||
|
||||
// ─── Step-controlled script execution ───────────────────────────────────────
|
||||
//
|
||||
// `simulation_script_load` parses a script and stores its actions in process
|
||||
// memory keyed by a generated id. The script is NOT executed yet. Subsequent
|
||||
// calls to `simulation_script_step` advance the cursor by one or more actions
|
||||
// at a time, returning the snapshot and updated counts after each batch. This
|
||||
// lets MCP clients drive scripts at their own pace and inspect state between
|
||||
// steps. Only one script is active at a time per process; loading a new one
|
||||
// while a previous one is still pending requires either consuming it to
|
||||
// completion, calling `simulation_script_cancel`, or specifying replace=true.
|
||||
|
||||
interface LoadedScript {
|
||||
readonly id: string
|
||||
readonly file: string | null
|
||||
readonly actions: ScriptAction[]
|
||||
cursor: number
|
||||
readonly counts: ScriptCounts
|
||||
readonly loadedAt: string
|
||||
}
|
||||
|
||||
let loaded: LoadedScript | undefined
|
||||
let loadSeq = 0
|
||||
|
||||
function loadedSummary(state: LoadedScript) {
|
||||
return {
|
||||
id: state.id,
|
||||
file: state.file,
|
||||
total: state.actions.length,
|
||||
cursor: state.cursor,
|
||||
remaining: state.actions.length - state.cursor,
|
||||
done: state.cursor >= state.actions.length,
|
||||
counts: { ...state.counts },
|
||||
loadedAt: state.loadedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScript(input: {
|
||||
file?: string
|
||||
script?: unknown
|
||||
replace?: boolean
|
||||
}) {
|
||||
if (loaded && loaded.cursor < loaded.actions.length && !input.replace) {
|
||||
throw new Error(
|
||||
`A script is already loaded (id=${loaded.id}, ${loaded.actions.length - loaded.cursor} actions remaining). Pass replace=true or call simulation_script_cancel first.`,
|
||||
)
|
||||
}
|
||||
const raw = input.file
|
||||
? await Bun.file(input.file).json()
|
||||
: (input.script ?? (() => {
|
||||
throw new Error("simulation_script_load requires either `file` or `script`.")
|
||||
})())
|
||||
const parsed = ScriptSchema.parse(raw)
|
||||
const actions = Array.isArray(parsed) ? parsed : parsed.actions
|
||||
loaded = {
|
||||
id: `script-${(++loadSeq).toString(36)}`,
|
||||
file: input.file ?? null,
|
||||
actions: [...actions] as ScriptAction[],
|
||||
cursor: 0,
|
||||
counts: { uiActions: 0, fileWrites: 0, llmScriptsQueued: 0, waits: 0 },
|
||||
loadedAt: new Date().toISOString(),
|
||||
}
|
||||
return loadedSummary(loaded)
|
||||
}
|
||||
|
||||
async function stepScript(options: Options, input: { steps?: number; renderEach?: boolean }) {
|
||||
if (!loaded) throw new Error("No script loaded. Call simulation_script_load first.")
|
||||
const max = input.steps ?? 1
|
||||
const executed: ScriptAction[] = []
|
||||
for (let i = 0; i < max && loaded.cursor < loaded.actions.length; i++) {
|
||||
const action = loaded.actions[loaded.cursor]!
|
||||
executed.push(action)
|
||||
await executeAction(options, action, loaded.counts)
|
||||
loaded.cursor++
|
||||
if (input.renderEach && i < max - 1) await current(options).harness.renderOnce()
|
||||
}
|
||||
return {
|
||||
executed,
|
||||
state: loadedSummary(loaded),
|
||||
snapshot: snapshot(options),
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScript() {
|
||||
const was = loaded ? loadedSummary(loaded) : null
|
||||
loaded = undefined
|
||||
return { cancelled: was !== null, was }
|
||||
}
|
||||
|
||||
function statusScript() {
|
||||
return loaded ? loadedSummary(loaded) : null
|
||||
}
|
||||
|
||||
async function runOnTargets<A>(
|
||||
options: Options,
|
||||
target: z.infer<typeof TargetSchema>,
|
||||
local: () => Promise<A>,
|
||||
remote: (instance: RemoteInstance) => Promise<unknown>,
|
||||
) {
|
||||
const output = []
|
||||
for (const item of selectedTargets(target)) {
|
||||
output.push({ id: item.id, result: item.local ? await local() : await remote(item.remote) })
|
||||
}
|
||||
return { results: output, snapshot: snapshot(options) }
|
||||
}
|
||||
|
||||
function createServer(options: Options) {
|
||||
const server = new McpServer(
|
||||
{ name: "opencode-simulation", version: InstallationVersion },
|
||||
{
|
||||
instructions:
|
||||
"Use simulation_ui_state_get before acting. Prefer generated actions and execute them with simulation_action_execute. Inspect state after each action. Use control tools to seed filesystem, network, and LLM state.",
|
||||
},
|
||||
)
|
||||
|
||||
server.registerResource("screen", "simulation://screen", { mimeType: "text/plain" }, () => ({
|
||||
contents: [{ uri: "simulation://screen", mimeType: "text/plain", text: current(options).harness.screen() }],
|
||||
}))
|
||||
server.registerResource("ui-state", "simulation://ui-state", { mimeType: "application/json" }, () => ({
|
||||
contents: [{ uri: "simulation://ui-state", mimeType: "application/json", text: JSON.stringify(state(options)) }],
|
||||
}))
|
||||
server.registerResource("backend-snapshot", "simulation://backend-snapshot", { mimeType: "application/json" }, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: "simulation://backend-snapshot",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(await control(options, "GET", "/experimental/simulation/snapshot")),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.registerPrompt("simulation-driver", { description: "Instructions for driving the simulated TUI." }, () => ({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Inspect simulation_ui_state_get, choose one generated action, call simulation_action_execute, then inspect again. Use control tools to seed deterministic backend state.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.registerTool("simulation_screen_get", { description: "Get the current TUI screen buffer." }, () =>
|
||||
toolResult({ screen: current(options).harness.screen() }),
|
||||
)
|
||||
server.registerTool("simulation_ui_state_get", { description: "Get elements, focus state, and generated actions." }, () =>
|
||||
toolResult(state(options)),
|
||||
)
|
||||
server.registerTool("simulation_render_once", { description: "Force one render and return current state." }, async () => {
|
||||
await current(options).harness.renderOnce()
|
||||
return toolResult(snapshot(options))
|
||||
})
|
||||
server.registerTool(
|
||||
"simulation_backend_fetch",
|
||||
{
|
||||
description: "Proxy one fetch request through the simulated backend and return the raw response.",
|
||||
inputSchema: BackendFetchSchema,
|
||||
},
|
||||
async (input) => toolResult(await backendFetch(options, input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_action_execute",
|
||||
{
|
||||
description: "Execute one generated simulation action and render once.",
|
||||
inputSchema: z.object({ action: ActionSchema }),
|
||||
},
|
||||
async (input) => {
|
||||
await SimulationActions.execute(current(options).harness, input.action)
|
||||
return toolResult(snapshot(options))
|
||||
},
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_action_sequence_execute",
|
||||
{
|
||||
description: "Execute a bounded sequence of simulation actions and return final state.",
|
||||
inputSchema: z.object({ actions: z.array(ActionSchema).max(50) }),
|
||||
},
|
||||
async (input) => {
|
||||
for (const action of input.actions) await SimulationActions.execute(current(options).harness, action)
|
||||
return toolResult(snapshot(options))
|
||||
},
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_script_run",
|
||||
{
|
||||
description: "Run a JSON simulation script from a host filesystem path.",
|
||||
inputSchema: z.object({ path: z.string() }),
|
||||
},
|
||||
async (input) => toolResult(await runScript(options, input.path)),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_load",
|
||||
{
|
||||
description:
|
||||
"Load a simulation script into memory WITHOUT executing it. Pass `path` to load from a JSON file on disk, or `script` to load inline JSON. Returns the parsed action count and a script id. Use `simulation_script_step` to execute actions one (or N) at a time. Only one script may be loaded at once unless `replace` is true.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().optional(),
|
||||
script: z.any().optional(),
|
||||
replace: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => toolResult(await loadScript({ file: input.path, script: input.script, replace: input.replace })),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_step",
|
||||
{
|
||||
description:
|
||||
"Execute the next action(s) of the loaded script and return the snapshot afterwards. Defaults to one step. Pass `steps` to advance multiple actions in a single call (1-100). When `renderEach` is true, the simulated TUI is forced to render between steps.",
|
||||
inputSchema: z.object({
|
||||
steps: z.number().int().min(1).max(100).optional(),
|
||||
renderEach: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => toolResult(await stepScript(options, { steps: input.steps, renderEach: input.renderEach })),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_status",
|
||||
{
|
||||
description:
|
||||
"Return the currently-loaded script's progress: id, total actions, cursor, remaining, and execution counts. Returns null when nothing is loaded.",
|
||||
},
|
||||
async () => toolResult({ status: statusScript() }),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_script_cancel",
|
||||
{ description: "Discard the currently-loaded script (if any). Subsequent step calls fail until a new script is loaded." },
|
||||
async () => toolResult(cancelScript()),
|
||||
)
|
||||
|
||||
if ("runtime" in options) {
|
||||
server.registerTool("simulation_restart", { description: "Restart the simulated TUI and backend while keeping MCP alive." }, async () =>
|
||||
toolResult(await options.runtime.restart()),
|
||||
)
|
||||
}
|
||||
|
||||
server.registerTool("simulation_control_reset", { description: "Reset backend simulation state." }, async () =>
|
||||
toolResult(await control(options, "POST", "/experimental/simulation/reset")),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_filesystem_seed",
|
||||
{
|
||||
description: "Seed backend simulated filesystem files.",
|
||||
inputSchema: z.object({ files: z.record(z.string(), FileContentSchema) }),
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/seed", input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_filesystem_write",
|
||||
{
|
||||
description: "Write one file into the backend simulated filesystem.",
|
||||
inputSchema: z.object({ path: z.string(), content: FileContentSchema }),
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/write", input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_network_register",
|
||||
{
|
||||
description: "Register one backend simulated network response.",
|
||||
inputSchema: NetworkRegistrationSchema,
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/network/register", input)),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_control_llm_enqueue",
|
||||
{
|
||||
description: "Queue backend mock LLM scripts.",
|
||||
inputSchema: z.object({ scripts: z.array(LlmScriptSchema) }),
|
||||
},
|
||||
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/llm/enqueue", input)),
|
||||
)
|
||||
server.registerTool("simulation_control_snapshot", { description: "Get backend simulation state snapshot." }, async () =>
|
||||
toolResult(await control(options, "GET", "/experimental/simulation/snapshot")),
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_network_log_get",
|
||||
{
|
||||
description:
|
||||
"Return the persistent network log: every HTTP request the simulated TUI sent to the backend, with method, URL, status code, headers, and response body (body truncated at 32KB per entry). Capped to the last 500 entries.",
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
urlIncludes: z.string().optional(),
|
||||
statusMin: z.number().int().optional(),
|
||||
statusMax: z.number().int().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => {
|
||||
let entries = SimulationNetworkLog.snapshot()
|
||||
if (input.urlIncludes) entries = entries.filter((e) => e.url.includes(input.urlIncludes!))
|
||||
if (typeof input.statusMin === "number") entries = entries.filter((e) => e.status >= input.statusMin!)
|
||||
if (typeof input.statusMax === "number") entries = entries.filter((e) => e.status <= input.statusMax!)
|
||||
if (typeof input.limit === "number") entries = entries.slice(-input.limit)
|
||||
return toolResult({ entries, total: entries.length })
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_network_log_clear",
|
||||
{
|
||||
description: "Clear the simulated TUI's persistent network log. Use between scripted runs to isolate observations.",
|
||||
},
|
||||
async () => {
|
||||
SimulationNetworkLog.clear()
|
||||
return toolResult({ cleared: true })
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_log_get",
|
||||
{
|
||||
description:
|
||||
"Return the in-memory log buffer captured by `@opencode-ai/core/util/log` inside the simulated backend (capped at 5000 most recent entries). Filter by `level` to return only entries at or above that level. Also supports optional `limit` and substring filters.",
|
||||
inputSchema: z.object({
|
||||
level: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).optional(),
|
||||
limit: z.number().int().min(1).max(5000).optional(),
|
||||
messageIncludes: z.string().optional(),
|
||||
serviceIncludes: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => {
|
||||
const data = await control(options, "GET", "/experimental/simulation/log/entries")
|
||||
let entries = (data?.entries ?? []) as Array<{
|
||||
time: string
|
||||
level: "DEBUG" | "INFO" | "WARN" | "ERROR"
|
||||
tags: Record<string, unknown>
|
||||
message: string
|
||||
}>
|
||||
if (input.level) {
|
||||
const priority = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 } as const
|
||||
const threshold = priority[input.level]
|
||||
entries = entries.filter((e) => priority[e.level] >= threshold)
|
||||
}
|
||||
if (input.messageIncludes) entries = entries.filter((e) => e.message.includes(input.messageIncludes!))
|
||||
if (input.serviceIncludes)
|
||||
entries = entries.filter((e) => String(e.tags?.service ?? "").includes(input.serviceIncludes!))
|
||||
if (typeof input.limit === "number") entries = entries.slice(-input.limit)
|
||||
return toolResult({ entries, total: entries.length })
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
"simulation_log_clear",
|
||||
{ description: "Clear the simulated backend's in-memory log buffer." },
|
||||
async () => toolResult(await control(options, "POST", "/experimental/simulation/log/clear")),
|
||||
)
|
||||
|
||||
if (masterEnabled()) {
|
||||
server.registerTool(
|
||||
"simulation_instances_discover",
|
||||
{
|
||||
description: "Discover child simulation MCP servers on sequential localhost ports.",
|
||||
inputSchema: z.object({
|
||||
startPort: z.number().optional(),
|
||||
maxPorts: z.number().optional(),
|
||||
consecutiveFailures: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
async (input) => toolResult(await discoverInstances(input)),
|
||||
)
|
||||
server.registerTool("simulation_instances_list", { description: "List known simulation instances." }, () =>
|
||||
toolResult({ instances: instances() }),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_action_execute",
|
||||
{
|
||||
description: "Execute one UI action on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, action: ActionSchema }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
async () => {
|
||||
await SimulationActions.execute(current(options).harness, input.action)
|
||||
return snapshot(options)
|
||||
},
|
||||
(instance) => childToolCall(instance, "simulation_action_execute", { action: input.action }),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_filesystem_write",
|
||||
{
|
||||
description: "Write one file on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, path: z.string(), content: FileContentSchema }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
() => control(options, "POST", "/experimental/simulation/filesystem/write", { path: input.path, content: input.content }),
|
||||
(instance) => childToolCall(instance, "simulation_control_filesystem_write", { path: input.path, content: input.content }),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_llm_enqueue",
|
||||
{
|
||||
description: "Queue LLM scripts on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, scripts: z.array(LlmScriptSchema) }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
() => control(options, "POST", "/experimental/simulation/llm/enqueue", { scripts: input.scripts }),
|
||||
(instance) => childToolCall(instance, "simulation_control_llm_enqueue", { scripts: input.scripts }),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.registerTool(
|
||||
"simulation_instances_script_run",
|
||||
{
|
||||
description: "Run a JSON simulation script on one or more simulation instances.",
|
||||
inputSchema: z.object({ target: TargetSchema, path: z.string() }),
|
||||
},
|
||||
async (input) =>
|
||||
toolResult(
|
||||
await runOnTargets(
|
||||
options,
|
||||
input.target,
|
||||
() => runScript(options, input.path),
|
||||
(instance) => childToolCall(instance, "simulation_script_run", { path: input.path }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export async function createSimulationMcpServer(options: Options): Promise<SimulationMcpServer> {
|
||||
if (options.mode === "stdio") {
|
||||
const server = createServer(options)
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
return {
|
||||
mode: options.mode,
|
||||
stop: () => server.close(),
|
||||
}
|
||||
}
|
||||
|
||||
const servers = new Set<McpServer>()
|
||||
|
||||
const http = serveRemote(
|
||||
async (request) => {
|
||||
if (new URL(request.url).pathname !== "/mcp") return new Response("Not found", { status: 404 })
|
||||
const server = createServer(options)
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
servers.add(server)
|
||||
request.signal.addEventListener("abort", () => {
|
||||
servers.delete(server)
|
||||
void server.close()
|
||||
})
|
||||
await server.connect(transport)
|
||||
return transport.handleRequest(request)
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
mode: options.mode,
|
||||
url: `http://${http.hostname}:${http.port}/mcp`,
|
||||
stop: async () => {
|
||||
http.stop(true)
|
||||
await Promise.all([...servers].map((server) => server.close()))
|
||||
servers.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiSimulationMcp from "./simulation-mcp"
|
||||
@@ -0,0 +1,46 @@
|
||||
// Per-process network log for the simulated TUI. Captures every HTTP request
|
||||
// the TUI sends to the worker-backed backend (via `createWorkerFetch`).
|
||||
// Exposed through the simulation MCP server so tests / agents can inspect what
|
||||
// the running TUI is actually doing.
|
||||
|
||||
export interface NetworkLogEntry {
|
||||
readonly id: number
|
||||
readonly time: string
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly status: number
|
||||
readonly durationMs: number
|
||||
readonly requestHeaders: Record<string, string>
|
||||
readonly requestBody?: string
|
||||
readonly responseHeaders: Record<string, string>
|
||||
readonly responseBody: string
|
||||
readonly responseTruncated: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 500
|
||||
const MAX_BODY_BYTES = 32_768
|
||||
|
||||
const entries: NetworkLogEntry[] = []
|
||||
let nextId = 1
|
||||
|
||||
function truncate(text: string): { body: string; truncated: boolean } {
|
||||
if (text.length <= MAX_BODY_BYTES) return { body: text, truncated: false }
|
||||
return { body: text.slice(0, MAX_BODY_BYTES), truncated: true }
|
||||
}
|
||||
|
||||
export function record(entry: Omit<NetworkLogEntry, "id" | "responseTruncated"> & { responseBody: string }) {
|
||||
const { body, truncated } = truncate(entry.responseBody)
|
||||
entries.push({ ...entry, id: nextId++, responseBody: body, responseTruncated: truncated })
|
||||
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES)
|
||||
}
|
||||
|
||||
export function snapshot(): NetworkLogEntry[] {
|
||||
return entries.slice()
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
entries.length = 0
|
||||
}
|
||||
|
||||
export * as SimulationNetworkLog from "./simulation-network-log"
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import type { CapturedFrame } from "@opentui/core"
|
||||
import type { SimulationActions } from "@/testing/simulation/actions"
|
||||
|
||||
export interface SimulationRenderer {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mockInput: SimulationActions.MockInput
|
||||
readonly mockMouse: SimulationActions.MockMouse
|
||||
readonly renderOnce: () => Promise<void>
|
||||
readonly screen: () => string
|
||||
readonly spans: () => CapturedFrame
|
||||
readonly destroy: () => void
|
||||
}
|
||||
|
||||
export async function createSimulationRenderer(): Promise<SimulationRenderer> {
|
||||
const { createTestRenderer } = await import("@opentui/core/testing")
|
||||
const setup = await createTestRenderer({
|
||||
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
|
||||
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
|
||||
screenMode: "main-screen",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
|
||||
return {
|
||||
renderer: setup.renderer,
|
||||
mockInput: setup.mockInput,
|
||||
mockMouse: setup.mockMouse,
|
||||
renderOnce: setup.renderOnce,
|
||||
screen: setup.captureCharFrame,
|
||||
spans: setup.captureSpans,
|
||||
destroy: () => setup.renderer.destroy(),
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiSimulation from "./simulation"
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
@@ -13,8 +14,26 @@ 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"
|
||||
import { SimulationDebugLog } from "../../../testing/simulation/debug-log"
|
||||
import fs from "fs/promises"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
if (process.env.OPENCODE_SIMULATION_CWD) {
|
||||
process.env.PWD = process.env.OPENCODE_SIMULATION_CWD
|
||||
process.env.OPENCODE_TEST_HOME = process.env.OPENCODE_SIMULATION_CWD
|
||||
Global.Path.data = `${process.env.OPENCODE_SIMULATION_CWD}/.local/share/opencode`
|
||||
Global.Path.cache = `${process.env.OPENCODE_SIMULATION_CWD}/.cache/opencode`
|
||||
Global.Path.config = `${process.env.OPENCODE_SIMULATION_CWD}/.config/opencode`
|
||||
Global.Path.state = `${process.env.OPENCODE_SIMULATION_CWD}/.local/state/opencode`
|
||||
Global.Path.tmp = `${process.env.OPENCODE_SIMULATION_CWD}/tmp/opencode`
|
||||
Global.Path.bin = `${Global.Path.cache}/bin`
|
||||
Global.Path.repos = `${Global.Path.data}/repos`
|
||||
Object.defineProperty(process, "cwd", {
|
||||
value: () => process.env.OPENCODE_SIMULATION_CWD!,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
void fs.writeFile("/tmp/opencode-http-errors.log", "")
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -41,6 +60,12 @@ process.on("uncaughtException", (e) => {
|
||||
|
||||
// Subscribe to global events and forward them via RPC
|
||||
GlobalBus.on("event", (event) => {
|
||||
SimulationDebugLog.write("worker.global.event", {
|
||||
directory: event.directory,
|
||||
workspace: event.workspace,
|
||||
type: event.payload?.type,
|
||||
syncType: event.payload?.syncEvent?.type,
|
||||
})
|
||||
Rpc.emit("global.event", event)
|
||||
})
|
||||
|
||||
@@ -48,6 +73,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
SimulationDebugLog.write("worker.fetch.start", { method: input.method, url: input.url })
|
||||
const headers = { ...input.headers }
|
||||
const auth = ServerAuth.header()
|
||||
if (auth && !headers["authorization"] && !headers["Authorization"]) {
|
||||
@@ -60,6 +86,7 @@ export const rpc = {
|
||||
})
|
||||
const response = await Server.Default().app.fetch(request)
|
||||
const body = await response.text()
|
||||
SimulationDebugLog.write("worker.fetch.end", { method: input.method, url: input.url, status: response.status })
|
||||
return {
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
|
||||
@@ -2,16 +2,9 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { errorFormat } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
interface ErrorLike {
|
||||
name?: string
|
||||
_tag?: string
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ConfigIssue = { message: string; path: string[] }
|
||||
|
||||
function isTaggedError(error: unknown, tag: string): boolean {
|
||||
function isTaggedError(error: unknown, tag: string): error is Record<string, unknown> {
|
||||
return isRecord(error) && error._tag === tag
|
||||
}
|
||||
|
||||
@@ -39,22 +32,27 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
|
||||
: []
|
||||
}
|
||||
|
||||
export function FormatError(input: unknown) {
|
||||
export function FormatError(input: unknown): string | undefined {
|
||||
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
|
||||
const formatted = FormatError(input.cause.body)
|
||||
if (formatted) return formatted
|
||||
}
|
||||
|
||||
// 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 ?? ""
|
||||
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
|
||||
return stringField(input, "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.`
|
||||
const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
|
||||
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
}
|
||||
|
||||
// AccountServiceError, AccountTransportError: TaggedErrorClass
|
||||
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
|
||||
return (input as ErrorLike).message ?? ""
|
||||
return stringField(input, "message") ?? ""
|
||||
}
|
||||
|
||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||
@@ -64,7 +62,7 @@ export function FormatError(input: unknown) {
|
||||
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
|
||||
: []
|
||||
return [
|
||||
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
|
||||
`Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`,
|
||||
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
`Try: \`opencode models\` to list available models`,
|
||||
`Or check your config (opencode.json) provider/model names`,
|
||||
@@ -112,6 +110,7 @@ export function FormatError(input: unknown) {
|
||||
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
||||
return ""
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function FormatUnknownError(input: unknown): string {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
@@ -13,7 +13,13 @@ export async function upgrade() {
|
||||
if (!latest) return
|
||||
|
||||
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
|
||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.UpdateAvailable.type,
|
||||
properties: { version: latest },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,12 +28,26 @@ export async function upgrade() {
|
||||
const kind = Installation.getReleaseType(InstallationVersion, latest)
|
||||
|
||||
if (config.autoupdate === "notify" || kind !== "patch") {
|
||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.UpdateAvailable.type,
|
||||
properties: { version: latest },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
|
||||
.then(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.Updated.type,
|
||||
properties: { version: latest },
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Exit, Schema, SchemaGetter } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { configEntryNameFromPath } from "./entry-name"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
@@ -112,12 +110,7 @@ export async function load(dir: string) {
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse agent ${item}`
|
||||
const { Session } = await import("@/session/session")
|
||||
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
log.error("failed to load agent", { agent: item, err })
|
||||
return undefined
|
||||
})
|
||||
@@ -144,12 +137,7 @@ export async function loadMode(dir: string) {
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse mode ${item}`
|
||||
const { Session } = await import("@/session/session")
|
||||
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
log.error("failed to load mode", { mode: item, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -2,9 +2,7 @@ export * as ConfigCommand from "./command"
|
||||
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Cause, Exit, Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Bus } from "@/bus"
|
||||
import { configEntryNameFromPath } from "./entry-name"
|
||||
import { InvalidError } from "./error"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
@@ -32,12 +30,7 @@ export async function load(dir: string) {
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse command ${item}`
|
||||
const { Session } = await import("@/session/session")
|
||||
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
log.error("failed to load command", { command: item, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -96,11 +96,11 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
|
||||
if (err) return
|
||||
// if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
if (evt.type === "create") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ExportCommand } from "./cli/cmd/export"
|
||||
import { ImportCommand } from "./cli/cmd/import"
|
||||
import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { SimulateCommand } from "./cli/cmd/tui/simulate"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
@@ -40,6 +41,7 @@ import { Heap } from "./cli/heap"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
const processMetadata = ensureProcessMetadata("main")
|
||||
|
||||
@@ -67,7 +69,7 @@ function show(out: string) {
|
||||
process.stderr.write(out)
|
||||
}
|
||||
|
||||
const cli = yargs(args)
|
||||
const baseCli = yargs(args)
|
||||
.parserConfiguration({ "populate--": true })
|
||||
.scriptName("opencode")
|
||||
.wrap(100)
|
||||
@@ -178,6 +180,8 @@ const cli = yargs(args)
|
||||
.command(SessionCommand)
|
||||
.command(PluginCommand)
|
||||
.command(DbCommand)
|
||||
|
||||
const cli = (Flag.OPENCODE_SIMULATION || Flag.OPENCODE_SIMULATION_BACKEND ? baseCli.command(SimulateCommand) : baseCli)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
|
||||
@@ -69,16 +69,16 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedErr
|
||||
}) {}
|
||||
|
||||
// Response schemas for external version APIs
|
||||
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||
const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||
const BrewInfoV2 = Schema.Struct({
|
||||
export const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||
export const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||
export const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||
export const BrewInfoV2 = Schema.Struct({
|
||||
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
||||
})
|
||||
const ChocoPackage = Schema.Struct({
|
||||
export const ChocoPackage = Schema.Struct({
|
||||
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
||||
})
|
||||
const ScoopManifest = NpmPackage
|
||||
export const ScoopManifest = NpmPackage
|
||||
|
||||
export interface Interface {
|
||||
readonly info: () => Effect.Effect<Info>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user