Compare commits

...
Author SHA1 Message Date
James Long 53242d581b fix lint 2026-05-17 23:55:38 -04:00
DaxandJames Long 7d85813a0f fix: preserve bus instance context (#28051) 2026-05-17 21:58:52 -04:00
James Long ef96fdfd83 wip 2026-05-17 21:49:29 -04:00
James Long 241b7b5792 wip 2026-05-17 21:49:29 -04:00
James Long 53d45b2894 add simulation network log 2026-05-17 21:49:29 -04:00
James Long 9d11b62ff0 convert lsp client to effect 2026-05-17 21:49:27 -04:00
James Long 1c453bd4e1 fix 2026-05-17 15:53:11 -04:00
James Long 4f455f1869 real tool calls 2026-05-17 15:04:21 -04:00
James Long d33517c6b5 fake lsp 2026-05-17 15:04:04 -04:00
James Long 99c758e343 fix dep graph so simulated services propagates throughout whole system 2026-05-17 14:59:14 -04:00
James Long 27de1ee982 wip 2026-05-17 14:57:41 -04:00
James Long 8e0676ebea add backend fetch 2026-05-17 14:57:34 -04:00
James Long 990a705063 git 2026-05-17 14:57:30 -04:00
James Long 33a78ead3d simulation git 2026-05-17 14:57:25 -04:00
James Long 3739eee8cf scripts 2026-05-17 14:57:20 -04:00
James Long 816f5e526b tool for writing files 2026-05-17 14:57:12 -04:00
James Long 63abe037d8 spawner 2026-05-17 14:57:03 -04:00
James Long f2733330f7 add just-bash for filesystem 2026-05-17 14:54:19 -04:00
James Long c41dc21292 file generator 2026-05-17 14:51:35 -04:00
James Long f80bf7a423 optimize 2026-05-17 14:51:29 -04:00
James Long 13ea36f575 wip 2026-05-17 14:50:41 -04:00
James Long 02b7367b4e add default script 2026-05-17 14:38:30 -04:00
James Long aa4cf681a9 mcp server 2026-05-17 14:38:29 -04:00
James Long f1c0b2651a actions 2026-05-17 14:34:51 -04:00
James Long eab9a4f564 llm provider and test renderer 2026-05-17 14:34:50 -04:00
James Long 012fc184bc simulation control endpoints 2026-05-17 14:34:21 -04:00
James Long 0ed88cfc21 network routing 2026-05-17 14:34:20 -04:00
James Long 98689fb125 fake network 2026-05-17 14:28:09 -04:00
James Long 62bd7c82d3 add mock filesystem 2026-05-17 14:28:07 -04:00
opencode f98449c9b5 sync release versions for v1.15.4 2026-05-17 17:44:50 +00:00
Frank 468eb68878 zen: update monitoring query 2026-05-17 13:15:39 -04:00
DaxandGitHub 23b594de6e fix: preserve bus instance context (#28051) 2026-05-17 12:56:42 -04:00
Frank 5060577ee1 sync 2026-05-17 12:37:47 -04:00
Aiden ClineGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
d3ebb1f7c0 ignore: cleanup readme (#28049)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-05-17 11:26:23 -05:00
Shoubhit DashandGitHub e4cc4e1682 fix(lsp): preserve instance ref for update events (#28016) 2026-05-17 16:37:00 +05:30
opencode-agent[bot] 53e89f9d52 chore: generate 2026-05-17 08:06:12 +00:00
Frank de247b7aae sync 2026-05-17 04:04:39 -04:00
Frank 0b8050d453 sync 2026-05-17 01:59:20 -04:00
be6a89a3b8 docs: update README.es.md (#27739)
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-05-16 19:33:24 +02:00
Kit LangtonandGitHub c0a8b509c7 style(tui): distinguish markdown h1 headings (#27814) 2026-05-16 11:18:17 -04:00
opencode f80651fa91 sync release versions for v1.15.3 2026-05-16 14:07:33 +00:00
Shoubhit DashandGitHub 68af95390d fix: hide task background instructions by default (#27872) 2026-05-16 19:18:15 +05:30
opencode-agent[bot] 321db7a819 chore: generate 2026-05-16 13:27:23 +00:00
Dax Raad 6d2219e001 fix(cli): preserve instance context in async commands 2026-05-16 09:25:45 -04:00
opencode-agent[bot] dd432e3cde chore: generate 2026-05-16 13:21:57 +00:00
Dax Raad 77db212a0a fix read stream byte cap 2026-05-16 09:20:35 -04:00
opencode f56263791c sync release versions for v1.15.2 2026-05-16 12:53:10 +00:00
Dax Raad 88363f1ed9 fixed issue in opencode run 2026-05-16 08:27:25 -04:00
JackandGitHub c5db39f626 Remove Ring 2.6 1T from Zen docs (#27849) 2026-05-16 17:55:32 +08:00
vimtor b5aed287ca chore: reduce alerts noise 2026-05-16 09:39:13 +02:00
DaxandGitHub 53849bd866 fix(sync): publish events on injected project bus (#27825) 2026-05-16 05:56:49 +00:00
opencode e33912bfee sync release versions for v1.15.1 2026-05-16 05:44:44 +00:00
Aiden ClineandGitHub 548648a3d9 core: reduce prompts for shell, todowrite, and task tools (#26821) 2026-05-16 00:18:47 -05:00
Caleb NortonandGitHub 4643e13170 feat(nix): add opencode-electron derivation (#16163) 2026-05-15 23:20:48 -05:00
Dax Raad 042e6a5c86 tui: newly pinned sessions now append to the end of the list instead of jumping to the top 2026-05-15 23:57:40 -04:00
Dax Raad e36d6a0cbe core: clarify postinstall error message when binary is missing
Users installing with pnpm or --ignore-scripts now see a clear explanation
of why the native binary is missing and exact steps to fix it. The shell
script wrapper provides more actionable guidance than the previous Node.js
fallback that only suggested running the postinstall script without context
about why it wasn't run automatically.
2026-05-15 23:12:00 -04:00
SebastianandGitHub cc9c0b15c7 Revert "add dialog prompt submit keybind (#27807)" (#27815) 2026-05-16 04:44:32 +02:00
Kit LangtonandGitHub f3b0d3d7ac fix(tui): dedupe consecutive prompt history entries (#27816) 2026-05-16 02:36:35 +00:00
opencode-agent[bot] 764c6bc517 chore: update nix node_modules hashes 2026-05-16 01:20:56 +00:00
SebastianandGitHub d441e931f9 add dialog prompt submit keybind (#27807) 2026-05-16 03:11:46 +02:00
SebastianandGitHub ad79ad9ea8 upgrade opentui to 0.2.11 (#27808) 2026-05-16 03:05:54 +02:00
opencode-agent[bot] d6b23fd8f6 chore: generate 2026-05-16 00:44:10 +00:00
Kit LangtonandGitHub 5911bd532d fix(tui): show config error details on startup (#27803) 2026-05-15 20:42:56 -04:00
Dax Raad 2385123f03 Fix thinking toggle defaults 2026-05-15 20:27:36 -04:00
DaxandGitHub 09549661e1 Fix npm CLI binary installation (#27801) 2026-05-15 18:43:37 -04:00
185 changed files with 105266 additions and 2056 deletions
+1
View File
@@ -7,6 +7,7 @@ on:
- ci
- dev
- beta
- fix/npm-native-binary-install
- snapshot-*
workflow_dispatch:
inputs:
+6 -1
View File
@@ -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,
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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
View File
@@ -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.
---
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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 levoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante.
- Supporto LSP pronto alluso
- 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 unapp 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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
-12
View File
@@ -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)
+857 -660
View File
File diff suppressed because it is too large Load Diff
+301
View File
@@ -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.
+7 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.0",
"version": "1.15.4",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -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 -1
View File
@@ -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)
+41 -33
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+3 -1
View File
@@ -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"],
+1
View File
@@ -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>>
+38
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.0",
"version": "1.15.4",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+19
View File
@@ -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
+3 -2
View File
@@ -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",
+1
View File
@@ -244,6 +244,7 @@ for (const item of targets) {
{
name,
version: Script.version,
preferUnplugged: true,
os: [item.os],
cpu: [item.arch],
},
+167 -80
View File
@@ -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)
}
+20 -3
View File
@@ -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,
+84
View File
@@ -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.
+11 -11
View File
@@ -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,
+4 -1
View File
@@ -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) {
+3 -1
View File
@@ -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()
+35 -30
View File
@@ -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({
+5 -1
View File
@@ -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 + "!",
+35 -5
View File
@@ -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()),
+14 -15
View File
@@ -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 {
+24 -4
View File
@@ -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(() => {})
}
+2 -14
View File
@@ -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
})
+1 -8
View File
@@ -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
})
+4 -4
View File
@@ -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" })
}
})
+5 -1
View File
@@ -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") ||
+6 -6
View File
@@ -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