Compare commits

..

4 Commits

Author SHA1 Message Date
Haileyesus
7e92de7cb7 feat(providers): add comprehensive guide for provider module setup and usage 2026-05-12 20:18:09 +03:00
Haileyesus
bacca8d62b fix(security): centralize safe frontmatter parsing
Move frontmatter parsing into server/shared/frontmatter.ts so every backend caller
uses the same gray-matter configuration instead of importing gray-matter directly.

The goal is to keep executable JS and JSON frontmatter engines disabled for
all markdown discovered from the filesystem, not only command routes.

Provider skills and shared skill metadata now go through parseFrontMatter too.
That closes the gap where plugin or provider markdown could regain default
gray-matter behavior simply because it lived outside the original command path.

Classify the new parser in backend boundaries so modules can depend on the
safe shared API without reaching into legacy utility paths.
2026-05-12 20:05:52 +03:00
Haileyesus
aabf331e91 fix(providers): guard invalid skill command namespaces
Claude plugin ids come from local settings and installed plugin metadata.

Invalid ids such as empty strings or @ should not become command namespaces.

Skip plugin folders when no safe plugin name can be derived.

This prevents malformed slash commands like /:command from reaching the UI.

Add regression coverage for empty and @ plugin ids.

Keyboard selection in the slash menu should match mouse selection.

Only skills are inserted into the composer because they are provider invocations.

Built-in and custom commands execute directly and close the menu on success or failure.
2026-05-11 18:58:55 +03:00
Haileyesus
053e43447a feat(providers): surface skills in slash command menu
Provider skills were hidden behind provider-specific filesystem rules.

That made the backend and UI unable to offer one discovery path for skills.

Add a normalized skills contract, provider service, and provider skills API.

Keep provider-specific lookup rules inside adapters so routes and UI stay generic.

Claude needs plugin handling because enabled plugins resolve through installed_plugins.json.

Plugin folders can expose commands or skills, so Claude scans both forms.

Claude plugin commands are namespaced to avoid collisions with user and project skills.

Codex, Gemini, and Cursor adapters map their expected skill roots into the same contract.

The slash menu now shows skills beside built-in and custom commands for discovery.

The menu avoids mid-message activation, duplicate rows, loose namespace matches, and input overlap.

Provider tests cover discovery locations and Claude plugin edge cases.
2026-05-11 18:05:24 +03:00
201 changed files with 3686 additions and 15045 deletions

View File

@@ -3,100 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
### New Features
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
### New Features
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
### Bug Fixes
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
### Maintenance
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
### New Features
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
### Bug Fixes
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
### New Features
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
### Bug Fixes
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
### Documentation
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
### Maintenance
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
### Bug Fixes
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
### Styling
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes
* add clarification on auto mode ([392c73b](https://github.com/siteboon/claudecodeui/commit/392c73b6933600ea8a589c5d4eff5f7b830f99c5))
* enhance regex to correctly parse wrapper file paths for claude.exe ([#741](https://github.com/siteboon/claudecodeui/issues/741)) ([beb0a50](https://github.com/siteboon/claudecodeui/commit/beb0a50413beddfb16f6b49103e1b6b80567cb90))
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30) ## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
### New Features ### New Features

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
--- ---
@@ -62,7 +62,7 @@
- **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen - **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
- **Plugin-System** CloudCLI mit eigenen Plugins erweitern neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Plugin-System** CloudCLI mit eigenen Plugins erweitern neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung - **TaskMaster AI Integration** *(Optional)* Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`) - **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js))
## Schnellstart ## Schnellstart

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
--- ---

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
--- ---
@@ -60,7 +60,7 @@
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적 - **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리 - **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인) - **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)
## 빠른 시작 ## 빠른 시작

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
--- ---
@@ -62,7 +62,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history - **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) - **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
## Quick Start ## Quick Start

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
--- ---
@@ -62,7 +62,7 @@
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю - **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow - **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`) - **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)
## Быстрый старт ## Быстрый старт

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
--- ---
@@ -62,7 +62,7 @@
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et - **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi - **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak) - **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`shared/modelConstants.js`](shared/modelConstants.js) dosyasına bak)
## Hızlı Başlangıç ## Hızlı Başlangıç

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div> <div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
--- ---
@@ -60,7 +60,7 @@
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录 - **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理 - **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取 - **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js)
## 快速开始 ## 快速开始

View File

@@ -1,242 +0,0 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI又名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用從任何地方查看您的專案與工作階段。</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
## 截圖
<div align="center">
<table>
<tr>
<td align="center">
<h3>桌面檢視</h3>
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
<br>
<em>顯示專案總覽和聊天的主介面</em>
</td>
<td align="center">
<h3>行動裝置體驗</h3>
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
<br>
<em>具有觸控導覽的響應式行動裝置設計</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 選擇</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
<br>
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
</td>
</tr>
</table>
</div>
## 功能
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
- **互動聊天介面** — 內建聊天 UI輕鬆與 Agents 交流
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
## 快速開始
### CloudCLI Cloud推薦
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
### 自架(開源)
#### npm
啟動 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash
npx @cloudcli-ai/cloudcli
```
或進行全域安裝,便於日常使用:
```bash
npm install -g @cloudcli-ai/cloudcli
cloudcli
```
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
更多設定選項、PM2、遠端伺服器設定等請參閱 **[文件 →](https://cloudcli.ai/docs)**。
#### Docker Sandboxes實驗性
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```bash
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
---
## 哪個選項更適合你?
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
| | CloudCLI UI自架 | CloudCLI Cloud |
|---|---|---|
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
| **機器需保持開機嗎** | 是 | 否 |
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
| **REST API** | 是 | 是 |
| **n8n 節點** | 否 | 是 |
| **團隊共享** | 否 | 是 |
| **平台費用** | 免費開源 | 起價 $7/月 |
> 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
---
## 安全與工具設定
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
### 啟用工具
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
2. **選擇性啟用** — 僅啟用所需工具
3. **套用設定** — 偏好設定儲存在本機
<div align="center">
![工具設定彈出視窗](public/screenshots/tools-modal.png)
*工具設定介面 — 只啟用你需要的內容*
</div>
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
---
## 外掛
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
### 可用外掛
| 外掛 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
### 自行建構
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
---
## 常見問題
<details>
<summary>與 Claude Code Remote Control 有何不同?</summary>
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
- **完整 UI** — 除了聊天介面還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
</details>
<details>
<summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
</details>
<details>
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
</details>
<details>
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
會的。自架模式下CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
</details>
---
## 社群與支援
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
## 授權條款
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
## 致謝
### 使用技術
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[React](https://react.dev/)** — 使用者介面函式庫
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
### 贊助商
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
</div>

View File

@@ -1,218 +0,0 @@
# CloudCLI UI Nginx subpath deployment template.
#
# Purpose:
# Serve CloudCLI UI from a path prefix such as:
# http://localhost/ai/
# https://example.com/ai/
#
# CloudCLI itself still runs at the root of its own HTTP server, for example:
# http://127.0.0.1:3001/
#
# Nginx receives public requests under /ai, strips that prefix, and forwards the
# remaining path to CloudCLI. For example:
# /ai/ -> /
# /ai/session/abc -> /session/abc
# /ai/assets/index.js -> /assets/index.js
#
# Important Nginx limitation:
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
# The configurable variables below are still useful for proxy/filter values,
# but if you change /ai to a different subpath, also update every line marked:
# [SUBPATH LITERAL]
#
# To use a different subpath, replace these literal matchers:
# location = /ai
# location ^~ /ai/
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
#
# Recommended deployment shape:
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
# are also proxied because the current frontend still calls those endpoints
# with root-relative URLs.
worker_processes 1;
events {
# Maximum simultaneous connections handled by each worker process.
# The default is enough for local testing and small self-hosted deployments.
worker_connections 1024;
}
http {
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
# This map gives us the right Connection header for both cases:
# Upgrade present -> "upgrade"
# Upgrade absent -> "close"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
# add ssl_certificate / ssl_certificate_key lines.
listen 80 default_server;
# Use your real hostname in production, for example:
# server_name cloudcli.example.com;
server_name localhost 127.0.0.1;
# ---- User settings -------------------------------------------------
#
# Public path prefix where users access CloudCLI.
# Do not add a trailing slash.
#
# This variable can be used in redirects and response rewrites. It
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
# lines too if you change it.
set $cloudcli_subpath /ai;
# Private upstream URL where the CloudCLI server is listening.
# For a default local server this is usually http://127.0.0.1:3001.
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 200m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
location = /ai {
return 301 $cloudcli_subpath/;
}
# Main prefixed CloudCLI UI route.
#
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
# $cloudcli_subpath.
location ^~ /ai/ {
# Strip the public subpath before proxying. CloudCLI expects to see
# root paths such as /, /session/:id, /assets/..., /manifest.json.
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
# Forward the rewritten request to the private CloudCLI server.
proxy_pass $cloudcli_upstream;
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
# browser reaches a socket endpoint under the subpath.
proxy_http_version 1.1;
# Preserve useful request metadata for logs and future app support.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
# WebSocket upgrade headers. Harmless for normal HTTP requests.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-running agent and terminal sessions can stay open for a long
# time, so avoid closing idle proxied connections too aggressively.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable gzip from the upstream response so sub_filter can inspect
# and rewrite HTML/JSON/JS response bodies.
proxy_set_header Accept-Encoding "";
# Rewrite browser-visible root-relative URLs so the runtime can
# discover that the app is mounted under the subpath.
#
# Examples:
# href="/manifest.json" -> href="/ai/manifest.json"
# src="/assets/app.js" -> src="/ai/assets/app.js"
#
# These rewrites are important for React Router basename detection.
sub_filter_once off;
sub_filter_types
application/json
application/manifest+json
application/javascript
text/javascript;
sub_filter 'href="/' 'href="$cloudcli_subpath/';
sub_filter 'src="/' 'src="$cloudcli_subpath/';
# The production HTML and JS register the service worker at /sw.js.
# Rewrite that registration so the worker is served from /ai/sw.js.
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
# The manifest and service worker contain root-relative paths too.
# Rewriting them keeps PWA metadata and cached manifest requests
# under the same public subpath.
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
}
# Root API proxy.
#
# The current CloudCLI frontend calls APIs with root-relative URLs such
# as /api/auth/login. Keep this location unless the frontend becomes
# fully prefix-aware for API requests.
location ^~ /api/ {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Main app WebSocket proxy.
#
# The frontend opens /ws for realtime chat/session/task updates.
location /ws {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Shell WebSocket proxy.
#
# The browser terminal uses /shell. It requires the same WebSocket
# upgrade handling as /ws.
location /shell {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Optional health endpoint proxy used by the frontend version checker.
location = /health {
proxy_pass $cloudcli_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
}
}
}

261
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.31.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.31.5",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165", "@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
@@ -39,7 +39,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -139,33 +138,35 @@
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk": { "node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.116.tgz",
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==", "integrity": "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw==",
"license": "SEE LICENSE IN README.md", "license": "SEE LICENSE IN README.md",
"dependencies": {
"@anthropic-ai/sdk": "^0.81.0",
"@modelcontextprotocol/sdk": "^1.29.0"
},
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165", "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165" "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116"
}, },
"peerDependencies": { "peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.116.tgz",
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==", "integrity": "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -176,9 +177,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.116.tgz",
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==", "integrity": "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -189,9 +190,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.116.tgz",
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==", "integrity": "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -202,9 +203,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.116.tgz",
"integrity": "sha512-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==", "integrity": "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -215,9 +216,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.116.tgz",
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==", "integrity": "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -228,9 +229,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.116.tgz",
"integrity": "sha512-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==", "integrity": "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -241,9 +242,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.116.tgz",
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==", "integrity": "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -254,9 +255,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.165", "version": "0.2.116",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.116.tgz",
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==", "integrity": "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -267,14 +268,12 @@
] ]
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
"version": "0.100.1", "version": "0.81.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==", "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1"
"standardwebhooks": "^1.0.0"
}, },
"bin": { "bin": {
"anthropic-ai-sdk": "bin/cli" "anthropic-ai-sdk": "bin/cli"
@@ -1837,7 +1836,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
}, },
@@ -2602,7 +2600,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.9",
"ajv": "^8.17.1", "ajv": "^8.17.1",
@@ -2643,7 +2640,6 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-types": "^3.0.0", "mime-types": "^3.0.0",
"negotiator": "^1.0.0" "negotiator": "^1.0.0"
@@ -2653,11 +2649,10 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.20.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2674,7 +2669,6 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -2699,7 +2693,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -2713,7 +2706,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.6.0" "node": ">=6.6.0"
} }
@@ -2723,7 +2715,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -2767,7 +2758,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
@@ -2789,7 +2779,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -2799,7 +2788,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"depd": "~2.0.0", "depd": "~2.0.0",
"inherits": "~2.0.4", "inherits": "~2.0.4",
@@ -2820,7 +2808,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
@@ -2836,15 +2823,13 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -2854,7 +2839,6 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -2867,7 +2851,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "^1.54.0" "mime-db": "^1.54.0"
}, },
@@ -2884,17 +2867,15 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": { "node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
"version": "6.15.2", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
}, },
@@ -2910,7 +2891,6 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
@@ -2926,7 +2906,6 @@
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.3", "debug": "^4.4.3",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
@@ -2953,7 +2932,6 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
@@ -2973,42 +2951,22 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.1.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"content-type": "^2.0.0", "content-type": "^1.0.5",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
@@ -4325,13 +4283,6 @@
"url": "https://ko-fi.com/dangreen" "url": "https://ko-fi.com/dangreen"
} }
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT",
"peer": true
},
"node_modules/@tailwindcss/typography": { "node_modules/@tailwindcss/typography": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
@@ -4629,13 +4580,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -5465,7 +5409,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
}, },
@@ -5479,11 +5422,10 @@
} }
}, },
"node_modules/ajv-formats/node_modules/ajv": { "node_modules/ajv-formats/node_modules/ajv": {
"version": "8.20.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -5499,8 +5441,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "7.3.0", "version": "7.3.0",
@@ -7544,15 +7485,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dot-prop": { "node_modules/dot-prop": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
@@ -8566,7 +8498,6 @@
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"eventsource-parser": "^3.0.1" "eventsource-parser": "^3.0.1"
}, },
@@ -8575,11 +8506,10 @@
} }
}, },
"node_modules/eventsource-parser": { "node_modules/eventsource-parser": {
"version": "3.1.0", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
@@ -8687,13 +8617,12 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.5.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ip-address": "^10.2.0" "ip-address": "10.1.0"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -8809,13 +8738,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense",
"peer": true
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -9873,11 +9795,10 @@
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.23", "version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -10249,9 +10170,9 @@
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.2.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -10694,8 +10615,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
@@ -10966,11 +10886,10 @@
} }
}, },
"node_modules/jose": { "node_modules/jose": {
"version": "6.2.3", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
@@ -11032,7 +10951,6 @@
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0" "ts-algebra": "^2.0.0"
@@ -11052,8 +10970,7 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"peer": true
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
@@ -14045,7 +13962,6 @@
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.20.0" "node": ">=16.20.0"
} }
@@ -15346,7 +15262,6 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"depd": "^2.0.0", "depd": "^2.0.0",
@@ -15363,7 +15278,6 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
@@ -16422,17 +16336,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -17143,8 +17046,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.4.0", "version": "2.4.0",
@@ -19030,7 +18932,6 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC", "license": "ISC",
"peer": true,
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.28 || ^4" "zod": "^3.25.28 || ^4"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.31.5",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "dist-server/server/index.js", "main": "dist-server/server/index.js",
@@ -10,7 +10,6 @@
"files": [ "files": [
"server/", "server/",
"shared/", "shared/",
"public/api-docs.html",
"dist/", "dist/",
"dist-server/", "dist-server/",
"scripts/", "scripts/",
@@ -66,7 +65,7 @@
"author": "CloudCLI UI Contributors", "author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165", "@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
@@ -95,7 +94,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

View File

@@ -820,49 +820,32 @@ data: {"type":"done"}</code></pre>
</div> </div>
</div> </div>
<script> <script type="module">
// Import model constants
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
// Dynamic URL replacement // Dynamic URL replacement
const apiUrl = window.location.origin; const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => { document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl; el.textContent = apiUrl;
}); });
// Populate model documentation from the live provider API // Dynamically populate model documentation
const PROVIDER_ORDER = [ window.addEventListener('DOMContentLoaded', () => {
{ id: 'claude', name: 'Anthropic' },
{ id: 'codex', name: 'OpenAI' },
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
];
async function populateModels() {
const modelCell = document.getElementById('model-options-cell'); const modelCell = document.getElementById('model-options-cell');
if (!modelCell) return; if (modelCell) {
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
const token = localStorage.getItem('auth-token'); modelCell.innerHTML = `
const headers = token ? { Authorization: `Bearer ${token}` } : {}; Model identifier for the AI provider:<br><br>
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
const results = await Promise.allSettled( <strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
PROVIDER_ORDER.map(({ id }) => <strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json()) `;
) }
); });
const providerModels = results.map((result, i) => {
const { name } = PROVIDER_ORDER[i];
if (result.status === 'rejected' || !result.value?.data?.models) {
return `<strong>${name}:</strong> <em>unavailable</em>`;
}
const { OPTIONS, DEFAULT } = result.value.data.models;
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
}).join('<br><br>');
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
}
document.addEventListener('DOMContentLoaded', populateModels);
// Tab switching // Tab switching
window.showTab = function(tabName) { window.showTab = function(tabName) {

View File

@@ -75,7 +75,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history - **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) - **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/shared/modelConstants.js) for the full list of supported models)
## Quick Start ## Quick Start

View File

@@ -17,8 +17,7 @@ import crypto from 'crypto';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js'; import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
import { import {
createNotificationEvent, createNotificationEvent,
@@ -28,14 +27,10 @@ import {
} from './services/notification-orchestrator.js'; } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map(); const activeSessions = new Map();
const pendingToolApprovals = new Map(); const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
const abortedSessionIds = new Set();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
@@ -208,8 +203,8 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || []; sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet) // Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT; sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
// Model logged at query start below // Model logged at query start below
// Map system prompt configuration // Map system prompt configuration
@@ -289,75 +284,43 @@ function transformMessage(sdkMessage) {
return sdkMessage; return sdkMessage;
} }
function readNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/** /**
* Extracts token usage from SDK messages. * Extracts token usage from SDK result messages
* Prefers per-step `message.usage` (Claude message payload), then falls back * @param {Object} resultMessage - SDK result message
* to result-level usage/modelUsage for compatibility across SDK versions.
* @param {Object} sdkMessage - SDK stream message
* @returns {Object|null} Token budget object or null * @returns {Object|null} Token budget object or null
*/ */
function extractTokenBudget(sdkMessage) { function extractTokenBudget(resultMessage) {
if (!sdkMessage || typeof sdkMessage !== 'object') { if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
return null; return null;
} }
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage; // Get the first model's usage data
if (messageUsage && typeof messageUsage === 'object') { const modelKey = Object.keys(resultMessage.modelUsage)[0];
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens); const modelData = resultMessage.modelUsage[modelKey];
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
const cacheTokens = cacheCreationTokens + cacheReadTokens;
const inputTokens = directInputTokens + cacheTokens;
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
return { if (!modelData) {
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
return null; return null;
} }
// Fallback for older SDK messages with only modelUsage // Use cumulative tokens if available (tracks total for the session)
const modelKey = Object.keys(sdkMessage.modelUsage)[0]; // Otherwise fall back to per-request tokens
const modelData = sdkMessage.modelUsage[modelKey]; const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
if (!modelData || typeof modelData !== 'object') { // Total used = input + output + cache tokens
return null; const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
}
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens); // Use configured context window budget from environment (default 160000)
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens); // This is the user's budget limit, not the model's context window
const totalUsed = inputTokens + outputTokens; const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
// Token calc logged via token-budget WS event
return { return {
used: totalUsed, used: totalUsed,
total: contextWindow, total: contextWindow
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
}; };
} }
@@ -528,17 +491,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
}; };
try { try {
const resolvedModel = await providerModelsService.resolveResumeModel(
'claude',
sessionId,
options.model,
);
// Map CLI options to SDK format // Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK({ const sdkOptions = mapCliOptionsToSDK(options);
...options,
model: resolvedModel || options.model,
});
// Load MCP configuration // Load MCP configuration
const mcpServers = await loadMcpConfig(options.cwd); const mcpServers = await loadMcpConfig(options.cwd);
@@ -720,10 +674,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
ws.send(msg); ws.send(msg);
} }
// Extract and send token budget updates from assistant/result usage payloads // Extract and send token budget updates from result messages
const tokenBudgetData = extractTokenBudget(message); if (message.type === 'result') {
if (tokenBudgetData) { const models = Object.keys(message.modelUsage || {});
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); if (models.length > 0) {
// Model info available in result message
}
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
}
} }
} }
@@ -735,18 +695,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files // Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir); await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose // Send completion event
// terminal `complete` (aborted: true) was already sent by abort-session. ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (!wasAborted) {
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
}
notifyRunStopped({ notifyRunStopped({
userId: ws?.userId || null, userId: ws?.userId || null,
provider: 'claude', provider: 'claude',
sessionId: capturedSessionId || sessionId || null, sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary, sessionName: sessionSummary,
stopReason: wasAborted ? 'aborted' : 'completed' stopReason: 'completed'
}); });
// Complete // Complete
@@ -761,22 +717,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error // Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir); await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
// caused by interrupt() is expected noise, not a user-facing error.
return;
}
// Check if Claude CLI is installed for a clearer error message // Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude'); const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code' ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message; : error.message;
// Send error to WebSocket, then the terminal complete // Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
notifyRunFailed({ notifyRunFailed({
userId: ws?.userId || null, userId: ws?.userId || null,
provider: 'claude', provider: 'claude',
@@ -803,10 +751,6 @@ async function abortClaudeSDKSession(sessionId) {
try { try {
console.log(`Aborting SDK session: ${sessionId}`); console.log(`Aborting SDK session: ${sessionId}`);
// Mark before interrupting so the run loop knows not to emit its own
// terminal complete (the abort handler sends the aborted one).
abortedSessionIds.add(sessionId);
// Call interrupt() on the query instance // Call interrupt() on the query instance
await session.instance.interrupt(); await session.instance.interrupt();
@@ -822,8 +766,6 @@ async function abortClaudeSDKSession(sessionId) {
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error aborting session ${sessionId}:`, error); console.error(`Error aborting session ${sessionId}:`, error);
// The run keeps going; let it emit its own terminal complete.
abortedSessionIds.delete(sessionId);
return false; return false;
} }
} }

View File

@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`); console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']); sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`); console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try { try {
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
// Step 3: Start CloudCLI inside the sandbox // Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`); console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']); sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port // Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`); console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);

View File

@@ -3,8 +3,7 @@ import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { createNormalizedMessage } from './shared/utils.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution // Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -29,15 +28,10 @@ function isWorkspaceTrustPrompt(text = '') {
async function spawnCursor(command, options = {}, ws) { async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options; const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
let capturedSessionId = sessionId; // Track session ID throughout the process let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false; let hasRetriedWithTrust = false;
let settled = false; let settled = false;
// The unified lifecycle contract requires exactly one terminal `complete`
// per run. Cursor surfaces completion twice (the `result` JSON line and
// the process close), so the first emission wins.
let completeSent = false;
// Use tools settings passed from frontend, or defaults // Use tools settings passed from frontend, or defaults
const settings = toolsSettings || { const settings = toolsSettings || {
@@ -58,10 +52,9 @@ async function spawnCursor(command, options = {}, ws) {
// Provide a prompt (works for both new and resumed sessions) // Provide a prompt (works for both new and resumed sessions)
baseArgs.push('-p', command); baseArgs.push('-p', command);
// Model overrides are applied to both new and resumed sessions so a // Add model flag if specified (only meaningful for new sessions; harmless on resume)
// session-scoped change request can take effect on the next turn. if (!sessionId && model) {
if (resolvedModel) { baseArgs.push('--model', model);
baseArgs.push('--model', resolvedModel);
} }
// Request streaming JSON when we are providing a prompt // Request streaming JSON when we are providing a prompt
@@ -201,15 +194,15 @@ async function spawnCursor(command, options = {}, ws) {
break; break;
case 'result': { case 'result': {
// Session complete — terminal lifecycle event for this run // Session complete — send stream end + lifecycle complete with result payload
if (!completeSent) { const resultText = typeof response.result === 'string' ? response.result : '';
completeSent = true; ws.send(createNormalizedMessage({
ws.send(createCompleteMessage({ kind: 'complete',
provider: 'cursor', exitCode: response.subtype === 'success' ? 0 : 1,
sessionId: capturedSessionId || sessionId || null, resultText,
exitCode: response.subtype === 'success' ? 0 : 1, isError: response.subtype !== 'success',
})); sessionId: capturedSessionId || sessionId, provider: 'cursor',
} }));
break; break;
} }
@@ -275,12 +268,7 @@ async function spawnCursor(command, options = {}, ws) {
return; return;
} }
// Terminal complete — unless the `result` line already sent it, or the ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
// run was aborted (abort-session sent the aborted complete).
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
}
if (code === 0) { if (code === 0) {
notifyTerminalState({ code }); notifyTerminalState({ code });
@@ -306,10 +294,6 @@ async function spawnCursor(command, options = {}, ws) {
: error.message; : error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
}
notifyTerminalState({ error }); notifyTerminalState({ error });
settleOnce(() => reject(error)); settleOnce(() => reject(error));
@@ -327,9 +311,6 @@ function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId); const process = activeCursorProcesses.get(sessionId);
if (process) { if (process) {
console.log(`Aborting Cursor session: ${sessionId}`); console.log(`Aborting Cursor session: ${sessionId}`);
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM'); process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId); activeCursorProcesses.delete(sessionId);
return true; return true;

View File

@@ -9,8 +9,7 @@ import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js'; import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { createNormalizedMessage } from './shared/utils.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -121,17 +120,9 @@ async function buildGeminiProcessEnv() {
async function spawnGemini(command, options = {}, ws) { async function spawnGemini(command, options = {}, ws) {
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options; const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
const resolvedModel = await providerModelsService.resolveResumeModel(
'gemini',
sessionId,
options.model
);
let capturedSessionId = sessionId; // Track session ID throughout the process let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools let assistantBlocks = []; // Accumulate the full response blocks including tools
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
// Use tools settings passed from frontend, or defaults // Use tools settings passed from frontend, or defaults
const settings = toolsSettings || { const settings = toolsSettings || {
@@ -253,7 +244,7 @@ async function spawnGemini(command, options = {}, ws) {
} }
// Add model for all sessions (both new and resumed) // Add model for all sessions (both new and resumed)
let modelToUse = resolvedModel || 'gemini-2.5-flash'; let modelToUse = options.model || 'gemini-2.5-flash';
args.push('--model', modelToUse); args.push('--model', modelToUse);
args.push('--output-format', 'stream-json'); args.push('--output-format', 'stream-json');
@@ -489,12 +480,7 @@ async function spawnGemini(command, options = {}, ws) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks); sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
} }
// Terminal complete — skipped for aborted runs (abort-session ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// already sent the aborted complete on this run's behalf).
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
// Clean up temporary image files if any // Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) { if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
@@ -574,10 +560,6 @@ async function spawnGemini(command, options = {}, ws) {
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' })); ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
}
notifyTerminalState({ error }); notifyTerminalState({ error });
reject(error); reject(error);
@@ -602,9 +584,6 @@ function abortGeminiSession(sessionId) {
if (geminiProc) { if (geminiProc) {
try { try {
// The abort handler sends the terminal complete (aborted: true);
// flag the process so its close handler does not emit a second one.
geminiProc.aborted = true;
geminiProc.kill('SIGTERM'); geminiProc.kill('SIGTERM');
setTimeout(() => { setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) { if (activeGeminiProcesses.has(processKey)) {

View File

@@ -1,32 +1,5 @@
// Gemini Response Handler - JSON Stream processing // Gemini Response Handler - JSON Stream processing
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
function buildGeminiTokenBudget(tokens) {
if (!tokens || typeof tokens !== 'object') {
return null;
}
const parsedInputTokens = Number(tokens.input);
const parsedOutputTokens = Number(tokens.output);
const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0;
const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0;
const parsedUsed = Number(tokens.total);
const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens;
if (!Number.isFinite(used) || used <= 0) {
return null;
}
return {
used,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
class GeminiResponseHandler { class GeminiResponseHandler {
constructor(ws, options = {}) { constructor(ws, options = {}) {
@@ -87,17 +60,6 @@ class GeminiResponseHandler {
for (const msg of normalized) { for (const msg of normalized) {
this.ws.send(msg); this.ws.send(msg);
} }
const tokenBudget = buildGeminiTokenBudget(event.tokens);
if (tokenBudget) {
this.ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: sid,
provider: 'gemini',
}));
}
} }
forceFlush() { forceFlush() {

View File

@@ -10,9 +10,8 @@ import { spawn } from 'child_process';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import mime from 'mime-types'; import mime from 'mime-types';
import Database from 'better-sqlite3';
import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js'; import { AppError, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js'; import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
import { createWebSocketServer } from '@/modules/websocket/index.js'; import { createWebSocketServer } from '@/modules/websocket/index.js';
@@ -22,25 +21,30 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import { import {
queryClaudeSDK, queryClaudeSDK,
abortClaudeSDKSession, abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions,
resolveToolApproval, resolveToolApproval,
getPendingApprovalsForSession, getPendingApprovalsForSession,
reconnectSessionWriter,
} from './claude-sdk.js'; } from './claude-sdk.js';
import { import {
spawnCursor, spawnCursor,
abortCursorSession, abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions,
} from './cursor-cli.js'; } from './cursor-cli.js';
import { import {
queryCodex, queryCodex,
abortCodexSession, abortCodexSession,
isCodexSessionActive,
getActiveCodexSessions,
} from './openai-codex.js'; } from './openai-codex.js';
import { import {
spawnGemini, spawnGemini,
abortGeminiSession, abortGeminiSession,
isGeminiSessionActive,
getActiveGeminiSessions,
} from './gemini-cli.js'; } from './gemini-cli.js';
import {
spawnOpenCode,
abortOpenCodeSession,
} from './opencode-cli.js';
import sessionManager from './sessionManager.js'; import sessionManager from './sessionManager.js';
import { import {
stripAnsiSequences, stripAnsiSequences,
@@ -62,7 +66,7 @@ import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js'; import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js'; import providerRoutes from './modules/providers/provider.routes.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js'; import { initializeDatabase, projectsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js'; import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js'; import { IS_PLATFORM } from './constants/config.js';
@@ -73,17 +77,9 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname); const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
console.log('SERVER_PORT from env:', process.env.SERVER_PORT); console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
@@ -94,35 +90,28 @@ const wss = createWebSocketServer(server, {
authenticateWebSocket, authenticateWebSocket,
}, },
chat: { chat: {
spawnFns: { queryClaudeSDK,
claude: queryClaudeSDK, spawnCursor,
cursor: spawnCursor, queryCodex,
codex: queryCodex, spawnGemini,
gemini: spawnGemini, abortClaudeSDKSession,
opencode: spawnOpenCode, abortCursorSession,
}, abortCodexSession,
abortFns: { abortGeminiSession,
claude: abortClaudeSDKSession,
cursor: abortCursorSession,
codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession,
},
resolveToolApproval, resolveToolApproval,
isClaudeSDKSessionActive,
isCursorSessionActive,
isCodexSessionActive,
isGeminiSessionActive,
reconnectSessionWriter,
getPendingApprovalsForSession, getPendingApprovalsForSession,
getActiveClaudeSDKSessions,
getActiveCursorSessions,
getActiveCodexSessions,
getActiveGeminiSessions,
}, },
shell: { shell: {
resolveProviderSessionId: (sessionId, provider) => { getSessionById: (sessionId) => sessionManager.getSession(sessionId),
const dbSession = sessionsDb.getSessionById(sessionId);
const legacyGeminiSession =
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
if (dbSession) {
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
}
return legacyGeminiSession?.cliSessionId;
},
stripAnsiSequences, stripAnsiSequences,
normalizeDetectedUrl, normalizeDetectedUrl,
extractUrlsFromText, extractUrlsFromText,
@@ -892,27 +881,27 @@ const uploadFilesHandler = async (req, res) => {
} }
}), }),
limits: { limits: {
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES, fileSize: 50 * 1024 * 1024, // 50MB limit
files: MAX_FILE_UPLOAD_COUNT files: 20 // Max 20 files at once
} }
}); });
// Use multer middleware // Use multer middleware
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => { uploadMiddleware.array('files', 20)(req, res, async (err) => {
if (err) { if (err) {
console.error('Multer error:', err); console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` }); return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
} }
if (err.code === 'LIMIT_FILE_COUNT') { if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` }); return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
} }
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
try { try {
const { projectId } = req.params; const { projectId } = req.params;
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body; const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads) // Parse relative paths if provided (for folder uploads)
let filePaths = []; let filePaths = [];
@@ -936,11 +925,6 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' }); return res.status(400).json({ error: 'No files provided' });
} }
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
? parsedRequestedFileCount
: req.files.length;
// Resolve the project directory through the DB using the new projectId. // Resolve the project directory through the DB using the new projectId.
const projectRoot = await projectsDb.getProjectPathById(projectId); const projectRoot = await projectsDb.getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
@@ -1019,10 +1003,8 @@ const uploadFilesHandler = async (req, res) => {
res.json({ res.json({
success: true, success: true,
files: uploadedFiles, files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir, targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully` message: `Uploaded ${uploadedFiles.length} file(s) successfully`
}); });
} catch (error) { } catch (error) {
console.error('Error uploading files:', error); console.error('Error uploading files:', error);
@@ -1144,140 +1126,28 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.status(400).json({ error: 'Invalid sessionId' }); return res.status(400).json({ error: 'Invalid sessionId' });
} }
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
// are keyed by the provider-native session id, while the caller sends
// the app-facing id. Resolve the mapping once for all branches below.
const sessionRow = sessionsDb.getSessionById(safeSessionId);
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
// Handle Cursor sessions - they use SQLite and don't have token usage info // Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') { if (provider === 'cursor') {
return res.json({ return res.json({
used: 0, used: 0,
total: 0, total: 0,
inputTokens: 0, breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true, unsupported: true,
message: 'Token usage tracking not available for Cursor sessions' message: 'Token usage tracking not available for Cursor sessions'
}); });
} }
// Handle Gemini sessions - they are raw logs in our current setup
if (provider === 'gemini') { if (provider === 'gemini') {
const session = sessionsDb.getSessionById(safeSessionId);
const sessionFilePath = session?.jsonl_path;
if (!sessionFilePath) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking not available for this Gemini session'
});
}
let fileContent;
try {
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
}
throw error;
}
const lines = fileContent.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let totalTokens = 0;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (!entry.tokens || typeof entry.tokens !== 'object') {
continue;
}
inputTokens = Number(entry.tokens.input || 0);
outputTokens = Number(entry.tokens.output || 0);
totalTokens = Number(entry.tokens.total || inputTokens + outputTokens || 0);
break;
} catch {
continue;
}
}
return res.json({ return res.json({
used: totalTokens, used: 0,
inputTokens, total: 0,
outputTokens, breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
breakdown: { unsupported: true,
input: inputTokens, message: 'Token usage tracking not available for Gemini sessions'
output: outputTokens
}
}); });
} }
if (provider === 'opencode') {
const dbPath = getOpenCodeDatabasePath();
if (!fs.existsSync(dbPath)) {
return res.status(404).json({ error: 'OpenCode database not found' });
}
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const columns = db.prepare('PRAGMA table_info(session)').all();
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking is not available in this OpenCode database schema'
});
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(providerNativeSessionId);
if (!row) {
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
}
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
const outputTokens = Number(row.outputTokens || 0);
const totalUsed = Number(row.inputTokens || 0)
+ outputTokens
+ Number(row.reasoningTokens || 0)
+ Number(row.cacheReadTokens || 0)
+ Number(row.cacheWriteTokens || 0);
return res.json({
used: totalUsed,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
});
} finally {
db.close();
}
}
// Handle Codex sessions // Handle Codex sessions
if (provider === 'codex') { if (provider === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
@@ -1291,7 +1161,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
if (entry.isDirectory()) { if (entry.isDirectory()) {
const found = await findSessionFile(fullPath); const found = await findSessionFile(fullPath);
if (found) return found; if (found) return found;
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) { } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath; return fullPath;
} }
} }
@@ -1318,8 +1188,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
throw error; throw error;
} }
const lines = fileContent.trim().split('\n'); const lines = fileContent.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let totalTokens = 0; let totalTokens = 0;
let contextWindow = 200000; // Default for Codex/OpenAI let contextWindow = 200000; // Default for Codex/OpenAI
@@ -1332,9 +1200,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
const tokenInfo = entry.payload.info; const tokenInfo = entry.payload.info;
if (tokenInfo.total_token_usage) { if (tokenInfo.total_token_usage) {
inputTokens = tokenInfo.total_token_usage.input_tokens || 0; totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
outputTokens = tokenInfo.total_token_usage.output_tokens || 0;
totalTokens = tokenInfo.total_token_usage.total_tokens || inputTokens + outputTokens;
} }
if (tokenInfo.model_context_window) { if (tokenInfo.model_context_window) {
contextWindow = tokenInfo.model_context_window; contextWindow = tokenInfo.model_context_window;
@@ -1349,13 +1215,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.json({ return res.json({
used: totalTokens, used: totalTokens,
total: contextWindow, total: contextWindow
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
}); });
} }
@@ -1375,19 +1235,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
// Prefer the indexed transcript path (already produced by the trusted const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// session synchronizer); fall back to the conventional location
// derived from the provider-native session id.
let jsonlPath = sessionRow?.jsonl_path;
if (!jsonlPath) {
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
// Constrain the constructed path to projectDir (the id is // Constrain to projectDir
// caller-influenced in this fallback branch). const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); if (rel.startsWith('..') || path.isAbsolute(rel)) {
if (rel.startsWith('..') || path.isAbsolute(rel)) { return res.status(400).json({ error: 'Invalid path' });
return res.status(400).json({ error: 'Invalid path' });
}
} }
// Read and parse the JSONL file // Read and parse the JSONL file
@@ -1405,9 +1258,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0; let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0; let cacheCreationTokens = 0;
let cacheReadTokens = 0;
// Find the latest assistant message with usage data (scan from end) // Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) { for (let i = lines.length - 1; i >= 0; i--) {
@@ -1419,11 +1271,9 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const usage = entry.message.usage; const usage = entry.message.usage;
// Use token counts from latest assistant message only // Use token counts from latest assistant message only
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens); inputTokens = usage.input_tokens || 0;
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens); cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens); cacheReadTokens = usage.cache_read_input_tokens || 0;
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
break; // Stop after finding the latest assistant message break; // Stop after finding the latest assistant message
} }
@@ -1433,20 +1283,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
} }
} }
const totalUsed = inputTokens + outputTokens; // Calculate total context usage (excluding output_tokens, as per ccusage)
const cacheTokens = cacheReadTokens + cacheCreationTokens; const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({ res.json({
used: totalUsed, used: totalUsed,
total: contextWindow, total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: { breakdown: {
input: inputTokens, input: inputTokens,
output: outputTokens cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
} }
}); });
} catch (error) { } catch (error) {
@@ -1512,133 +1358,74 @@ function permToRwx(perm) {
return r + w + x; return r + w + x;
} }
// Directories that are almost never interesting for a project tree but can
// contain tens of thousands of files. Skipping them before recursion keeps
// traversal time bounded on large monorepos and high-latency filesystems
// (NFS / SMB).
const IGNORED_DIRS = new Set([
// JS / TS toolchains
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
// VCS
'.git', '.svn', '.hg',
// Python
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
// Rust / Go / Java / Ruby
'target', 'vendor',
// Build output / IDE
'.gradle', '.idea', 'coverage', '.nyc_output'
]);
const DEFAULT_FS_CONCURRENCY = 64;
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
? parsedFsConcurrency
: DEFAULT_FS_CONCURRENCY;
let activeFsOperations = 0;
const pendingFsOperations = [];
async function acquire() {
if (activeFsOperations < FS_CONCURRENCY) {
activeFsOperations += 1;
return;
}
await new Promise((resolve) => {
pendingFsOperations.push(resolve);
});
}
function release() {
const next = pendingFsOperations.shift();
if (next) {
next();
return;
}
activeFsOperations = Math.max(0, activeFsOperations - 1);
}
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) { async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
// Using fsPromises from import // Using fsPromises from import
let entries; const items = [];
try { try {
await acquire(); const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
try {
entries = await fsPromises.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) {
} finally { // Debug: log all entries including hidden files
release();
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
item.children = [];
}
}
items.push(item);
} }
} catch (error) { } catch (error) {
// Only log non-permission errors to avoid spam // Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') { if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error); console.error('Error reading directory:', error);
} }
return [];
} }
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
// serial stat() was the real bottleneck — issuing them concurrently lets
// the kernel pipeline the round-trips and the recursive calls overlap too.
const items = await Promise.all(filteredEntries.map(async (entry) => {
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
await acquire();
try {
const stats = await fsPromises.lstat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Mark symlinks so UI can distinguish them
if (stats.isSymbolicLink()) {
item.isSymlink = true;
}
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions =
((mode >> 6) & 7).toString() +
((mode >> 3) & 7).toString() +
(mode & 7).toString();
item.permissionsRwx =
permToRwx(ownerPerm) +
permToRwx(groupPerm) +
permToRwx(otherPerm);
} finally {
release();
}
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recurse. Let readdir's own EACCES bubble up through the catch in
// the recursive call rather than doing a separate access() probe
// (which doubled the round-trip count on SMB without adding info).
// The recursive call starts with a bounded readdir; holding a permit
// for the whole subtree can deadlock when sibling directories are
// waiting on their own children.
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
}
return item;
}));
return items.sort((a, b) => { return items.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1; return a.type === 'directory' ? -1 : 1;

View File

@@ -1,5 +1,4 @@
export { initializeDatabase } from '@/modules/database/init-db.js'; export { initializeDatabase } from '@/modules/database/init-db.js';
export { closeConnection, getConnection, getDatabasePath } from '@/modules/database/connection.js';
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js'; export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
export { appConfigDb } from '@/modules/database/repositories/app-config.js'; export { appConfigDb } from '@/modules/database/repositories/app-config.js';
export { credentialsDb } from '@/modules/database/repositories/credentials.js'; export { credentialsDb } from '@/modules/database/repositories/credentials.js';

View File

@@ -382,25 +382,6 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
} }
}; };
/**
* Adds the `provider_session_id` mapping column used by the session gateway.
*
* Rows that existed before this migration were always keyed directly by the
* provider-native session id, so backfilling `provider_session_id` with
* `session_id` keeps every legacy row resolvable through the new mapping.
*/
const addProviderSessionIdMapping = (db: Database): void => {
const sessionsTableInfo = getTableInfo(db, 'sessions');
const columnNames = sessionsTableInfo.map((column) => column.name);
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
db.exec(`
UPDATE sessions
SET provider_session_id = session_id
WHERE provider_session_id IS NULL
`);
};
const ensureProjectsForSessionPaths = (db: Database): void => { const ensureProjectsForSessionPaths = (db: Database): void => {
if (!tableExists(db, 'sessions')) { if (!tableExists(db, 'sessions')) {
return; return;
@@ -447,11 +428,9 @@ export const runMigrations = (db: Database) => {
migrateLegacyWorkspaceTableIntoProjects(db); migrateLegacyWorkspaceTableIntoProjects(db);
rebuildSessionsTableWithProjectSchema(db); rebuildSessionsTableWithProjectSchema(db);
migrateLegacySessionNames(db); migrateLegacySessionNames(db);
addProviderSessionIdMapping(db);
ensureProjectsForSessionPaths(db); ensureProjectsForSessionPaths(db);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');

View File

@@ -10,7 +10,6 @@ type NotificationPreferences = {
channels: { channels: {
inApp: boolean; inApp: boolean;
webPush: boolean; webPush: boolean;
sound: boolean;
}; };
events: { events: {
actionRequired: boolean; actionRequired: boolean;
@@ -23,7 +22,6 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: { channels: {
inApp: false, inApp: false,
webPush: false, webPush: false,
sound: true,
}, },
events: { events: {
actionRequired: true, actionRequired: true,
@@ -39,7 +37,6 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
channels: { channels: {
inApp: source.channels?.inApp === true, inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true, webPush: source.channels?.webPush === true,
sound: source.channels?.sound !== false,
}, },
events: { events: {
actionRequired: source.events?.actionRequired !== false, actionRequired: source.events?.actionRequired !== false,

View File

@@ -5,7 +5,6 @@ import { normalizeProjectPath } from '@/shared/utils.js';
type SessionRow = { type SessionRow = {
session_id: string; session_id: string;
provider: string; provider: string;
provider_session_id: string | null;
project_path: string | null; project_path: string | null;
jsonl_path: string | null; jsonl_path: string | null;
custom_name: string | null; custom_name: string | null;
@@ -14,8 +13,10 @@ type SessionRow = {
updated_at: string; updated_at: string;
}; };
const SESSION_ROW_COLUMNS = type SessionMetadataLookupRow = Pick<
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; SessionRow,
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
>;
function normalizeTimestamp(value?: string): string | null { function normalizeTimestamp(value?: string): string | null {
if (!value) return null; if (!value) return null;
@@ -34,16 +35,8 @@ function normalizeProjectPathForProvider(provider: string, projectPath: string):
} }
export const sessionsDb = { export const sessionsDb = {
/**
* Upserts one session row discovered on disk by a provider synchronizer.
*
* The given id is the provider-native session id. Rows are keyed by
* `provider_session_id` so a session that was first created by the app
* (with an app-allocated `session_id`) is updated in place once its
* transcript shows up on disk, instead of producing a duplicate row.
*/
createSession( createSession(
providerSessionId: string, sessionId: string,
provider: string, provider: string,
projectPath: string, projectPath: string,
customName?: string, customName?: string,
@@ -60,54 +53,19 @@ export const sessionsDb = {
// since it's a foreign key in the sessions table. // since it's a foreign key in the sessions table.
projectsDb.createProjectPath(normalizedProjectPath); projectsDb.createProjectPath(normalizedProjectPath);
const existing = db
.prepare(
`SELECT session_id FROM sessions
WHERE provider_session_id = ? AND provider = ?
LIMIT 1`
)
.get(providerSessionId, provider) as { session_id: string } | undefined;
if (existing) {
db.prepare(
`UPDATE sessions SET
provider = ?,
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
project_path = ?,
jsonl_path = ?,
isArchived = 0,
custom_name = COALESCE(?, custom_name)
WHERE session_id = ?`
).run(
provider,
updatedAtValue,
normalizedProjectPath,
jsonlPath ?? null,
customName ?? null,
existing.session_id
);
return existing.session_id;
}
// Sessions created outside the app (directly via the provider CLI) are
// keyed by the provider-native id for both columns. The ON CONFLICT path
// covers legacy rows that predate the provider_session_id mapping.
db.prepare( db.prepare(
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
ON CONFLICT(session_id) DO UPDATE SET ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider, provider = excluded.provider,
provider_session_id = excluded.provider_session_id,
updated_at = excluded.updated_at, updated_at = excluded.updated_at,
project_path = excluded.project_path, project_path = excluded.project_path,
jsonl_path = excluded.jsonl_path, jsonl_path = excluded.jsonl_path,
isArchived = 0, isArchived = 0,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
).run( ).run(
providerSessionId, sessionId,
provider, provider,
providerSessionId,
customName ?? null, customName ?? null,
normalizedProjectPath, normalizedProjectPath,
jsonlPath ?? null, jsonlPath ?? null,
@@ -115,77 +73,9 @@ export const sessionsDb = {
updatedAtValue updatedAtValue
); );
return providerSessionId;
},
/**
* Inserts one app-allocated session row before any provider run happens.
*
* The session gateway uses this when the frontend starts a brand-new chat:
* `session_id` is the stable app-facing id, while `provider_session_id`
* stays NULL until the provider runtime announces its own id and
* `assignProviderSessionId` records the mapping.
*/
createAppSession(sessionId: string, provider: string, projectPath: string): string {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
projectsDb.createProjectPath(normalizedProjectPath);
db.prepare(
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
).run(sessionId, provider, normalizedProjectPath);
return sessionId; return sessionId;
}, },
/**
* Records the provider-native session id for one app-allocated session.
*
* If the filesystem watcher indexed the provider transcript before this
* mapping was recorded (a duplicate row keyed by the provider id exists),
* the duplicate is merged into the app row: its transcript path and name
* are adopted and the duplicate row is removed. Runs in a transaction so
* the sidebar can never observe both rows at once.
*/
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
const db = getConnection();
const merge = db.transaction(() => {
const duplicate = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
WHERE (session_id = ? OR provider_session_id = ?)
AND session_id <> ?
LIMIT 1`
)
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
if (duplicate) {
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
db.prepare(
`UPDATE sessions SET
provider_session_id = ?,
jsonl_path = COALESCE(jsonl_path, ?),
custom_name = COALESCE(custom_name, ?),
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?`
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
return;
}
db.prepare(
`UPDATE sessions SET
provider_session_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?`
).run(providerSessionId, sessionId);
});
merge();
},
updateSessionCustomName(sessionId: string, customName: string): void { updateSessionCustomName(sessionId: string, customName: string): void {
const db = getConnection(); const db = getConnection();
db.prepare( db.prepare(
@@ -195,39 +85,17 @@ export const sessionsDb = {
).run(customName, sessionId); ).run(customName, sessionId);
}, },
getSessionById(sessionId: string): SessionRow | null { getSessionById(sessionId: string): SessionMetadataLookupRow | null {
const db = getConnection(); const db = getConnection();
const row = db const row = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions FROM sessions
WHERE session_id = ? WHERE session_id = ?
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 1` LIMIT 1`
) )
.get(sessionId) as SessionRow | undefined; .get(sessionId) as SessionMetadataLookupRow | undefined;
return row ?? null;
},
/**
* Resolves one session row through the provider-native id.
*
* The filesystem watcher only knows provider ids (they come from transcript
* file names), so it uses this lookup to translate disk artifacts back to
* the app-facing session row before broadcasting sidebar updates.
*/
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
const db = getConnection();
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE provider_session_id = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.get(providerSessionId) as SessionRow | undefined;
return row ?? null; return row ?? null;
}, },
@@ -236,7 +104,7 @@ export const sessionsDb = {
const db = getConnection(); const db = getConnection();
return db return db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions FROM sessions
WHERE isArchived = 0` WHERE isArchived = 0`
) )
@@ -251,7 +119,7 @@ export const sessionsDb = {
const db = getConnection(); const db = getConnection();
return db return db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions FROM sessions
WHERE isArchived = 1 WHERE isArchived = 1
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
@@ -264,7 +132,7 @@ export const sessionsDb = {
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db return db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions FROM sessions
WHERE project_path = ? WHERE project_path = ?
AND isArchived = 0` AND isArchived = 0`
@@ -281,7 +149,7 @@ export const sessionsDb = {
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db return db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions FROM sessions
WHERE project_path = ?` WHERE project_path = ?`
) )
@@ -293,7 +161,7 @@ export const sessionsDb = {
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db return db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions FROM sessions
WHERE project_path = ? WHERE project_path = ?
AND isArchived = 0 AND isArchived = 0

View File

@@ -83,12 +83,6 @@ export const SESSIONS_TABLE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude', provider TEXT NOT NULL DEFAULT 'claude',
-- The session id used by the provider CLI/SDK on disk (JSONL file name,
-- store.db folder, sqlite row id, ...). \`session_id\` is the stable
-- app-facing id that the frontend uses for the whole session lifetime;
-- \`provider_session_id\` is filled in once the provider announces its own
-- id mid-run, or equals \`session_id\` for sessions discovered on disk.
provider_session_id TEXT,
custom_name TEXT, custom_name TEXT,
project_path TEXT, project_path TEXT,
jsonl_path TEXT, jsonl_path TEXT,

View File

@@ -1,108 +0,0 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { closeConnection } from '@/modules/database/connection.js';
import { initializeDatabase } from '@/modules/database/init-db.js';
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
test('disk-discovered sessions are keyed by the provider id for both columns', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk');
const row = sessionsDb.getSessionById('provider-abc');
assert.equal(row?.session_id, 'provider-abc');
assert.equal(row?.provider_session_id, 'provider-abc');
const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc');
assert.equal(byProviderId?.session_id, 'provider-abc');
});
});
test('app sessions get the provider id assigned without creating a duplicate row', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
// A later synchronizer pass that discovers the transcript on disk must
// update the app row in place instead of inserting a provider-keyed row.
const returnedId = sessionsDb.createSession(
'provider-xyz',
'claude',
'/workspace/demo',
'Synced Name',
undefined,
undefined,
'/fake/path/provider-xyz.jsonl',
);
assert.equal(returnedId, 'app-id-1');
assert.equal(sessionsDb.getAllSessions().length, 1);
const row = sessionsDb.getSessionById('app-id-1');
assert.equal(row?.provider_session_id, 'provider-xyz');
assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
});
});
test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo');
// Simulate the race: the filesystem watcher indexed the provider
// transcript before the runtime announced its session id to the gateway.
sessionsDb.createSession(
'provider-race',
'codex',
'/workspace/demo',
'Watcher Name',
undefined,
undefined,
'/fake/provider-race.jsonl',
);
assert.equal(sessionsDb.getAllSessions().length, 2);
sessionsDb.assignProviderSessionId('app-id-2', 'provider-race');
const rows = sessionsDb.getAllSessions();
assert.equal(rows.length, 1);
assert.equal(rows[0]?.session_id, 'app-id-2');
assert.equal(rows[0]?.provider_session_id, 'provider-race');
// Transcript path and name from the duplicate are adopted.
assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl');
assert.equal(rows[0]?.custom_name, 'Watcher Name');
});
});
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
});
});

View File

@@ -67,17 +67,8 @@ function resolveRouteErrorMessage(error: unknown): string {
router.get( router.get(
'/', '/',
asyncHandler(async (req, res) => { asyncHandler(async (_req, res) => {
const skipSynchronization = const projects = await getProjectsWithSessions();
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
readQueryStringValue(req.query.skipSync).trim() === '1';
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
const projects = await getProjectsWithSessions({
skipSynchronization,
sessionsLimit,
sessionsOffset,
});
res.json(projects); res.json(projects);
}), }),
); );

View File

@@ -33,7 +33,6 @@ type ProjectApiView = {
cursorSessions: []; cursorSessions: [];
codexSessions: []; codexSessions: [];
geminiSessions: []; geminiSessions: [];
opencodeSessions: [];
sessionMeta: { sessionMeta: {
hasMore: false; hasMore: false;
total: 0; total: 0;
@@ -85,7 +84,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
cursorSessions: [], cursorSessions: [],
codexSessions: [], codexSessions: [],
geminiSessions: [], geminiSessions: [],
opencodeSessions: [],
sessionMeta: { sessionMeta: {
hasMore: false, hasMore: false,
total: 0, total: 0,

View File

@@ -14,7 +14,7 @@ type SessionSummary = {
lastActivity: string; lastActivity: string;
}; };
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>; type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
type SessionRepositoryRow = { type SessionRepositoryRow = {
provider: string; provider: string;
@@ -34,7 +34,6 @@ export type ProjectListItem = {
cursorSessions: SessionSummary[]; cursorSessions: SessionSummary[];
codexSessions: SessionSummary[]; codexSessions: SessionSummary[];
geminiSessions: SessionSummary[]; geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -75,7 +74,6 @@ export type ProjectSessionsPageApiView = {
cursorSessions: SessionSummary[]; cursorSessions: SessionSummary[];
codexSessions: SessionSummary[]; codexSessions: SessionSummary[];
geminiSessions: SessionSummary[]; geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -141,7 +139,6 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
cursor: [], cursor: [],
codex: [], codex: [],
gemini: [], gemini: [],
opencode: [],
}; };
for (const row of rows) { for (const row of rows) {
@@ -189,11 +186,10 @@ function readProjectSessionsPageByPath(
}; };
} }
// Broadcast progress to all connected WebSocket clients. // Broadcast progress to all connected WebSocket clients
// Uses the unified `kind` envelope like every other websocket frame.
function broadcastProgress(progress: ProgressUpdate) { function broadcastProgress(progress: ProgressUpdate) {
const message = JSON.stringify({ const message = JSON.stringify({
kind: 'loading_progress', type: 'loading_progress',
...progress, ...progress,
}); });
@@ -257,7 +253,6 @@ export async function getProjectsWithSessions(
cursorSessions: sessionsPage.sessionsByProvider.cursor, cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex, codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini, geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -314,7 +309,6 @@ export async function getArchivedProjectsWithSessions(
cursorSessions: sessionsPage.sessionsByProvider.cursor, cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex, codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini, geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -347,7 +341,6 @@ export async function getProjectSessionsPage(
cursorSessions: sessionsPage.sessionsByProvider.cursor, cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex, codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini, geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,

View File

@@ -37,7 +37,6 @@ Current provider ids in this repo are:
- `codex` - `codex`
- `cursor` - `cursor`
- `gemini` - `gemini`
- `opencode`
Those ids are mirrored in backend unions and frontend provider constants. If Those ids are mirrored in backend unions and frontend provider constants. If
adding a new provider, update every place that hardcodes this list. adding a new provider, update every place that hardcodes this list.
@@ -56,8 +55,7 @@ server/modules/providers/list/<provider>/
<provider>-session-synchronizer.provider.ts <provider>-session-synchronizer.provider.ts
``` ```
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and The existing provider folders are `claude`, `codex`, `cursor`, and `gemini`.
`opencode`.
## What Each Facet Does ## What Each Facet Does
@@ -83,7 +81,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
- Update `server/modules/providers/provider.routes.ts`. - Update `server/modules/providers/provider.routes.ts`.
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime. - Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring. - Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs. - Update `shared/modelConstants.js` if the provider appears in UI provider pickers.
- Update `src/components/chat/hooks/useChatProviderState.ts` and - Update `src/components/chat/hooks/useChatProviderState.ts` and
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
the provider should be selectable in chat. the provider should be selectable in chat.
@@ -124,7 +122,6 @@ Current MCP formats in this repo are:
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` | | Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` | | Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` | | Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
5. Implement skills. 5. Implement skills.
@@ -145,7 +142,6 @@ Current skill discovery roots are:
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. | | Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. | | Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. | | Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. |
Command forms currently used by the providers are: Command forms currently used by the providers are:
@@ -154,7 +150,6 @@ Command forms currently used by the providers are:
- Codex skills: `$skill-name` - Codex skills: `$skill-name`
- Cursor skills: `/skill-name` - Cursor skills: `/skill-name`
- Gemini skills: `/skill-name` - Gemini skills: `/skill-name`
- OpenCode skills: `/skill-name`
6. Implement sessions. 6. Implement sessions.
@@ -192,7 +187,6 @@ Current session sync roots are:
| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. | | Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. |
| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. | | Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. | | Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
| OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. |
8. Register the provider. 8. Register the provider.
@@ -209,11 +203,10 @@ If the provider can run live chat sessions, update the runtime entrypoints too:
If the provider is visible in the UI, update: If the provider is visible in the UI, update:
- provider model fallback files under `server/modules/providers/list/<provider>/` - `shared/modelConstants.js`
- `src/components/chat/hooks/useChatProviderState.ts` - `src/components/chat/hooks/useChatProviderState.ts`
- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` - `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx`
- `src/components/provider-auth/view/ProviderLoginModal.tsx` - `src/components/provider-auth/view/ProviderLoginModal.tsx`
- `src/components/mcp/constants.ts`
## Minimal Wrapper Template ## Minimal Wrapper Template
@@ -331,7 +324,6 @@ Useful tests in this repo:
- `server/modules/providers/tests/mcp.test.ts` - `server/modules/providers/tests/mcp.test.ts`
- `server/modules/providers/tests/skills.test.ts` - `server/modules/providers/tests/skills.test.ts`
- `server/modules/providers/tests/opencode-sessions.test.ts`
If you touch sessions or session synchronization, add or update focused tests If you touch sessions or session synchronization, add or update focused tests
alongside the implementation. alongside the implementation.

View File

@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
error?: string; error?: string;
}; };
const hasErrorCode = (error: unknown, code: string): boolean => (
error instanceof Error && 'code' in error && error.code === code
);
export class ClaudeProviderAuth implements IProviderAuth { export class ClaudeProviderAuth implements IProviderAuth {
/** /**
* Checks whether the Claude Code CLI is available on this host. * Checks whether the Claude Code CLI is available on this host.
@@ -81,12 +77,6 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks Claude credentials in the same priority order used by Claude Code. * Checks Claude credentials in the same priority order used by Claude Code.
*/ */
private async checkCredentials(): Promise<ClaudeCredentialsStatus> { private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
}
if (process.env.ANTHROPIC_API_KEY?.trim()) { if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
} }
@@ -120,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
return { return {
authenticated: false, authenticated: false,
email: null, email,
method: null, method: 'credentials_file',
error: 'Claude login has expired. Run claude /login again.', error: 'OAuth token has expired. Please re-authenticate with claude login',
}; };
} }
return { return { authenticated: false, email: null, method: null };
authenticated: false, } catch {
email: null, return { authenticated: false, email: null, method: null };
method: null,
error: missingCredentialsError,
};
} catch (error) {
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
if (hasErrorCode(error, 'ENOENT')) {
errorMessage = missingCredentialsError;
} else if (error instanceof SyntaxError) {
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
}
return {
authenticated: false,
email: null,
method: null,
error: errorMessage,
};
} }
} }
} }

View File

@@ -1,201 +0,0 @@
import { readFile } from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{
value: 'default',
label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
},
{
value: 'haiku',
label: 'Haiku',
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
},
],
DEFAULT: 'default',
};
type ClaudeInitEvent = {
sessionId?: string;
session_id?: string;
type?: string;
subtype?: string;
model?: string;
message?: {
content?: unknown;
model?: string;
};
};
const ANSI_PATTERN = new RegExp(
'[\\u001B\\u009B][[\\]()#;?]*(?:'
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
'g',
);
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
const eventSessionId = event.sessionId ?? event.session_id;
if (eventSessionId && eventSessionId !== sessionId) {
return null;
}
const contentModel = extractClaudeModelFromMessageContent(event.message?.content);
if (contentModel) {
return contentModel;
}
const directModel = event.model?.trim();
if (directModel) {
return directModel;
}
const messageModel = event.message?.model?.trim();
return messageModel || null;
};
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
const extractTaggedContent = (content: string, tagName: string): string | null => {
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
return match ? match[1] : null;
};
const extractClaudeModelFromTextContent = (content: string): string | null => {
const localCommandStdout = extractTaggedContent(content, 'local-command-stdout');
if (localCommandStdout !== null) {
const cleanedStdout = stripAnsi(localCommandStdout).replace(/\s+/g, ' ').trim();
const changedModel = /(?:set|changed|switched)\s+model\s+to\s+(.+?)\.?$/i.exec(cleanedStdout);
if (changedModel?.[1]?.trim()) {
return changedModel[1].trim();
}
}
const modelTag = extractTaggedContent(content, 'model')?.trim();
return modelTag || null;
};
const extractClaudeModelFromMessageContent = (content: unknown): string | null => {
if (typeof content === 'string') {
return extractClaudeModelFromTextContent(content);
}
if (!Array.isArray(content)) {
return null;
}
for (const part of content) {
if (!part || typeof part !== 'object' || !('text' in part) || typeof part.text !== 'string') {
continue;
}
const model = extractClaudeModelFromTextContent(part.text);
if (model) {
return model;
}
}
return null;
};
const readClaudeSessionModelFromJsonl = async (
sessionId: string,
jsonlPath: string,
): Promise<ProviderCurrentActiveModel | null> => {
const content = await readFile(jsonlPath, 'utf8');
const lines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
const event = JSON.parse(lines[index]) as ClaudeInitEvent;
const model = extractClaudeEventModel(event, sessionId);
if (model) {
return { model };
}
} catch {
// Skip malformed JSONL lines that can happen during concurrent writes.
}
}
return null;
};
export class ClaudeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
// claude creates a new jsonl file as a separate session for this request.
// As a result, it lists the workspace where this is invoked when it shouldn't.
//
// Disabled for now:
// const queryInstance = query({
// prompt: 'Get supported models',
// options: buildClaudeQueryOptions(),
// });
// const supportedModels = await queryInstance.supportedModels();
// queryInstance.close();
// return buildClaudeModelsDefinition(supportedModels);
return CLAUDE_FALLBACK_MODELS;
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
if (!sessionId?.trim()) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
try {
const jsonlPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
const activeModel = jsonlPath
? await readClaudeSessionModelFromJsonl(sessionId, jsonlPath)
: null;
if (activeModel?.model) {
return activeModel;
}
} catch {
// Fall through to the provider default when the session-backed lookup fails.
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('claude', input);
}
}

View File

@@ -111,10 +111,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
return null; return null;
} }
// App-created sessions are keyed by an app id, so disk-discovered provider const existingSession = sessionsDb.getSessionById(parsed.sessionId);
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name; const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return { return {

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
const PROVIDER = 'claude'; const PROVIDER = 'claude';
@@ -103,13 +103,10 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
async function getSessionMessages( async function getSessionMessages(
sessionId: string, sessionId: string,
providerSessionId: string,
limit: number | null, limit: number | null,
offset: number, offset: number,
): Promise<ClaudeHistoryMessagesResult> { ): Promise<ClaudeHistoryMessagesResult> {
try { try {
// The DB row is keyed by the app-facing session id, while the JSONL rows
// on disk carry the provider-native id — both ids are needed here.
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!jsonLPath) { if (!jsonLPath) {
@@ -136,7 +133,7 @@ async function getSessionMessages(
try { try {
const entry = JSON.parse(line) as AnyRecord; const entry = JSON.parse(line) as AnyRecord;
if (entry.sessionId === providerSessionId) { if (entry.sessionId === sessionId) {
messages.push(entry); messages.push(entry);
} }
} catch { } catch {
@@ -556,13 +553,12 @@ export class ClaudeSessionsProvider implements IProviderSessions {
options: FetchHistoryOptions = {}, options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> { ): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options; const { limit = null, offset = 0 } = options;
const providerSessionId = options.providerSessionId ?? sessionId;
let result: ClaudeHistoryResult; let result: ClaudeHistoryResult;
try { try {
// Load full history first so `total` reflects frontend-normalized messages, // Load full history first so `total` reflects frontend-normalized messages,
// not raw JSONL records. // not raw JSONL records.
result = await getSessionMessages(sessionId, providerSessionId, null, 0); result = await getSessionMessages(sessionId, null, 0);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
@@ -610,6 +606,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
} }
} }
const totalNormalized = normalized.length;
let total = 0; let total = 0;
for (const msg of normalized) { for (const msg of normalized) {
if (msg.kind !== 'tool_result') { if (msg.kind !== 'tool_result') {
@@ -618,10 +615,18 @@ export class ClaudeSessionsProvider implements IProviderSessions {
} }
const normalizedOffset = Math.max(0, offset); const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit); const normalizedLimit = limit === null ? null : Math.max(0, limit);
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
Math.max(0, totalNormalized - normalizedOffset),
);
const hasMore = normalizedLimit === null
? false
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
return { return {
messages: page, messages,
total, total,
hasMore, hasMore,
offset: normalizedOffset, offset: normalizedOffset,

View File

@@ -1,20 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js'; import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
import { ClaudeProviderModels } from '@/modules/providers/list/claude/claude-models.provider.js';
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js'; import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js'; import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js';
import type { import type {
IProviderAuth, IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer, IProviderSessionSynchronizer,
IProviderSkills, IProviderSkills,
IProviderSessions, IProviderSessions,
} from '@/shared/interfaces.js'; } from '@/shared/interfaces.js';
export class ClaudeProvider extends AbstractProvider { export class ClaudeProvider extends AbstractProvider {
readonly models: IProviderModels = new ClaudeProviderModels();
readonly mcp = new ClaudeMcpProvider(); readonly mcp = new ClaudeMcpProvider();
readonly auth: IProviderAuth = new ClaudeProviderAuth(); readonly auth: IProviderAuth = new ClaudeProviderAuth();
readonly skills: IProviderSkills = new ClaudeSkillsProvider(); readonly skills: IProviderSkills = new ClaudeSkillsProvider();

View File

@@ -1,125 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import TOML from '@iarna/toml';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
readObjectRecord,
readOptionalString,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gpt-5.5', label: 'gpt-5.5' },
{ value: 'gpt-5.4', label: 'gpt-5.4' },
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
{ value: 'gpt-5.2', label: 'gpt-5.2' },
],
DEFAULT: 'gpt-5.4',
};
type CodexCachedModel = {
slug?: string;
display_name?: string;
description?: string;
priority?: number;
visibility?: string;
supported_in_api?: boolean;
};
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
const isCodexCachedModel = (value: unknown): value is CodexCachedModel => {
const record = readObjectRecord(value);
return Boolean(record && readOptionalString(record.slug));
};
const readCodexPriority = (value: unknown): number => (
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
);
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
value: model.slug as string,
label: readOptionalString(model.display_name) ?? (model.slug as string),
description: readOptionalString(model.description),
});
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
const sortedModels = [...models]
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
const options: ProviderModelOption[] = [];
const seenValues = new Set<string>();
for (const model of sortedModels) {
const mappedModel = mapCodexModel(model);
if (seenValues.has(mappedModel.value)) {
continue;
}
seenValues.add(mappedModel.value);
options.push(mappedModel);
}
if (options.length === 0) {
return CODEX_FALLBACK_MODELS;
}
return {
OPTIONS: options,
DEFAULT: options[0]?.value ?? CODEX_FALLBACK_MODELS.DEFAULT,
};
};
export class CodexProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
try {
const raw = await readFile(CODEX_MODELS_CACHE_PATH, 'utf8');
const parsed = readObjectRecord(JSON.parse(raw));
const models = Array.isArray(parsed?.models)
? parsed.models.filter(isCodexCachedModel)
: [];
return buildCodexModelsDefinition(models);
} catch {
return CODEX_FALLBACK_MODELS;
}
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
try {
const raw = await readFile(CODEX_CONFIG_PATH, 'utf8');
const parsed = readObjectRecord(TOML.parse(raw));
const model = readOptionalString(parsed?.model);
if (!model) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
return {
model,
};
} catch {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('codex', input);
}
}

View File

@@ -43,12 +43,11 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
continue; continue;
} }
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) const existingSession = sessionsDb.getSessionById(parsed.sessionId);
?? sessionsDb.getSessionById(parsed.sessionId);
if (existingSession) { if (existingSession) {
// If session name is untitled and we now have a name, update it // If session name is untitled and we now have a name, update it
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') { if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName); sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
} }
} }
@@ -121,10 +120,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
return null; return null;
} }
// App-created sessions are keyed by an app id, so disk-discovered provider const existingSession = sessionsDb.getSessionById(parsed.sessionId);
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name; const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') { if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
return { return {

View File

@@ -4,7 +4,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex'; const PROVIDER = 'codex';
@@ -552,6 +552,7 @@ export class CodexSessionsProvider implements IProviderSessions {
} }
} }
const totalNormalized = normalized.length;
let total = 0; let total = 0;
for (const msg of normalized) { for (const msg of normalized) {
if (msg.kind !== 'tool_result') { if (msg.kind !== 'tool_result') {
@@ -560,10 +561,18 @@ export class CodexSessionsProvider implements IProviderSessions {
} }
const normalizedOffset = Math.max(0, offset); const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit); const normalizedLimit = limit === null ? null : Math.max(0, limit);
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
Math.max(0, totalNormalized - normalizedOffset),
);
const hasMore = normalizedLimit === null
? false
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
return { return {
messages: page, messages,
total, total,
hasMore, hasMore,
offset: normalizedOffset, offset: normalizedOffset,

View File

@@ -1,12 +1,52 @@
import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type { ProviderSkillSource } from '@/shared/types.js'; import type { ProviderSkillSource } from '@/shared/types.js';
import {
addUniqueProviderSkillSource, const hasGitMarker = async (dirPath: string): Promise<boolean> => {
findTopmostGitRoot, try {
} from '@/shared/utils.js'; const gitMarkerStats = await fs.stat(path.join(dirPath, '.git'));
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
} catch {
return false;
}
};
const findTopmostGitRoot = async (startPath: string): Promise<string | null> => {
let currentPath = path.resolve(startPath);
let topmostGitRoot: string | null = null;
while (true) {
if (await hasGitMarker(currentPath)) {
topmostGitRoot = currentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
return topmostGitRoot;
};
const addUniqueSource = (
sources: ProviderSkillSource[],
seenRootDirs: Set<string>,
source: ProviderSkillSource,
): void => {
const normalizedRootDir = path.resolve(source.rootDir);
if (seenRootDirs.has(normalizedRootDir)) {
return;
}
seenRootDirs.add(normalizedRootDir);
sources.push({ ...source, rootDir: normalizedRootDir });
};
export class CodexSkillsProvider extends SkillsProvider { export class CodexSkillsProvider extends SkillsProvider {
constructor() { constructor() {
@@ -18,7 +58,7 @@ export class CodexSkillsProvider extends SkillsProvider {
const seenRootDirs = new Set<string>(); const seenRootDirs = new Set<string>();
const repoRoot = await findTopmostGitRoot(workspacePath); const repoRoot = await findTopmostGitRoot(workspacePath);
addUniqueProviderSkillSource(sources, seenRootDirs, { addUniqueSource(sources, seenRootDirs, {
scope: 'repo', scope: 'repo',
rootDir: path.join(workspacePath, '.agents', 'skills'), rootDir: path.join(workspacePath, '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
@@ -27,29 +67,29 @@ export class CodexSkillsProvider extends SkillsProvider {
if (repoRoot) { if (repoRoot) {
// Codex checks repository skills at the launch folder, one folder above it, // Codex checks repository skills at the launch folder, one folder above it,
// and the topmost git root; these can collapse to the same directory. // and the topmost git root; these can collapse to the same directory.
addUniqueProviderSkillSource(sources, seenRootDirs, { addUniqueSource(sources, seenRootDirs, {
scope: 'repo', scope: 'repo',
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'), rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
addUniqueProviderSkillSource(sources, seenRootDirs, { addUniqueSource(sources, seenRootDirs, {
scope: 'repo', scope: 'repo',
rootDir: path.join(repoRoot, '.agents', 'skills'), rootDir: path.join(repoRoot, '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
} }
addUniqueProviderSkillSource(sources, seenRootDirs, { addUniqueSource(sources, seenRootDirs, {
scope: 'user', scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'), rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
addUniqueProviderSkillSource(sources, seenRootDirs, { addUniqueSource(sources, seenRootDirs, {
scope: 'admin', scope: 'admin',
rootDir: path.join('/etc', 'codex', 'skills'), rootDir: path.join('/etc', 'codex', 'skills'),
commandPrefix: '$', commandPrefix: '$',
}); });
addUniqueProviderSkillSource(sources, seenRootDirs, { addUniqueSource(sources, seenRootDirs, {
scope: 'system', scope: 'system',
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'), rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
commandPrefix: '$', commandPrefix: '$',

View File

@@ -1,20 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js'; import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
import { CodexProviderModels } from '@/modules/providers/list/codex/codex-models.provider.js';
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js'; import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js'; import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js'; import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js';
import type { import type {
IProviderAuth, IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer, IProviderSessionSynchronizer,
IProviderSkills, IProviderSkills,
IProviderSessions, IProviderSessions,
} from '@/shared/interfaces.js'; } from '@/shared/interfaces.js';
export class CodexProvider extends AbstractProvider { export class CodexProvider extends AbstractProvider {
readonly models: IProviderModels = new CodexProviderModels();
readonly mcp = new CodexMcpProvider(); readonly mcp = new CodexMcpProvider();
readonly auth: IProviderAuth = new CodexProviderAuth(); readonly auth: IProviderAuth = new CodexProviderAuth();
readonly skills: IProviderSkills = new CodexSkillsProvider(); readonly skills: IProviderSkills = new CodexSkillsProvider();

View File

@@ -1,820 +0,0 @@
import { access, readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
sanitizeLeafDirectoryName,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{
value: "auto",
label: "auto",
description: "Auto",
},
{
value: "composer-2-fast",
label: "composer-2-fast",
description: "Composer 2 Fast",
},
{
value: "composer-2",
label: "composer-2",
description: "Composer 2",
},
{
value: "gpt-5.3-codex-low",
label: "gpt-5.3-codex-low",
description: "Codex 5.3 Low",
},
{
value: "gpt-5.3-codex-low-fast",
label: "gpt-5.3-codex-low-fast",
description: "Codex 5.3 Low Fast",
},
{
value: "gpt-5.3-codex",
label: "gpt-5.3-codex",
description: "Codex 5.3",
},
{
value: "gpt-5.3-codex-fast",
label: "gpt-5.3-codex-fast",
description: "Codex 5.3 Fast",
},
{
value: "gpt-5.3-codex-high",
label: "gpt-5.3-codex-high",
description: "Codex 5.3 High",
},
{
value: "gpt-5.3-codex-high-fast",
label: "gpt-5.3-codex-high-fast",
description: "Codex 5.3 High Fast",
},
{
value: "gpt-5.3-codex-xhigh",
label: "gpt-5.3-codex-xhigh",
description: "Codex 5.3 Extra High",
},
{
value: "gpt-5.3-codex-xhigh-fast",
label: "gpt-5.3-codex-xhigh-fast",
description: "Codex 5.3 Extra High Fast",
},
{
value: "gpt-5.2",
label: "gpt-5.2",
description: "GPT-5.2",
},
{
value: "gpt-5.2-codex-low",
label: "gpt-5.2-codex-low",
description: "Codex 5.2 Low",
},
{
value: "gpt-5.2-codex-low-fast",
label: "gpt-5.2-codex-low-fast",
description: "Codex 5.2 Low Fast",
},
{
value: "gpt-5.2-codex",
label: "gpt-5.2-codex",
description: "Codex 5.2",
},
{
value: "gpt-5.2-codex-fast",
label: "gpt-5.2-codex-fast",
description: "Codex 5.2 Fast",
},
{
value: "gpt-5.2-codex-high",
label: "gpt-5.2-codex-high",
description: "Codex 5.2 High",
},
{
value: "gpt-5.2-codex-high-fast",
label: "gpt-5.2-codex-high-fast",
description: "Codex 5.2 High Fast",
},
{
value: "gpt-5.2-codex-xhigh",
label: "gpt-5.2-codex-xhigh",
description: "Codex 5.2 Extra High",
},
{
value: "gpt-5.2-codex-xhigh-fast",
label: "gpt-5.2-codex-xhigh-fast",
description: "Codex 5.2 Extra High Fast",
},
{
value: "gpt-5.1-codex-max-low",
label: "gpt-5.1-codex-max-low",
description: "Codex 5.1 Max Low",
},
{
value: "gpt-5.1-codex-max-low-fast",
label: "gpt-5.1-codex-max-low-fast",
description: "Codex 5.1 Max Low Fast",
},
{
value: "gpt-5.1-codex-max-medium",
label: "gpt-5.1-codex-max-medium",
description: "Codex 5.1 Max",
},
{
value: "gpt-5.1-codex-max-medium-fast",
label: "gpt-5.1-codex-max-medium-fast",
description: "Codex 5.1 Max Medium Fast",
},
{
value: "gpt-5.1-codex-max-high",
label: "gpt-5.1-codex-max-high",
description: "Codex 5.1 Max High",
},
{
value: "gpt-5.1-codex-max-high-fast",
label: "gpt-5.1-codex-max-high-fast",
description: "Codex 5.1 Max High Fast",
},
{
value: "gpt-5.1-codex-max-xhigh",
label: "gpt-5.1-codex-max-xhigh",
description: "Codex 5.1 Max Extra High",
},
{
value: "gpt-5.1-codex-max-xhigh-fast",
label: "gpt-5.1-codex-max-xhigh-fast",
description: "Codex 5.1 Max Extra High Fast",
},
{
value: "composer-2.5",
label: "composer-2.5",
description: "Composer 2.5",
},
{
value: "gpt-5.5-high",
label: "gpt-5.5-high",
description: "GPT-5.5 1M High",
},
{
value: "gpt-5.5-high-fast",
label: "gpt-5.5-high-fast",
description: "GPT-5.5 High Fast",
},
{
value: "claude-opus-4-7-thinking-high",
label: "claude-opus-4-7-thinking-high",
description: "Opus 4.7 1M High Thinking",
},
{
value: "gpt-5.4-high",
label: "gpt-5.4-high",
description: "GPT-5.4 1M High",
},
{
value: "gpt-5.4-high-fast",
label: "gpt-5.4-high-fast",
description: "GPT-5.4 High Fast",
},
{
value: "claude-4.6-opus-high-thinking",
label: "claude-4.6-opus-high-thinking",
description: "Opus 4.6 1M Thinking",
},
{
value: "claude-4.6-opus-high-thinking-fast",
label: "claude-4.6-opus-high-thinking-fast",
description: "Opus 4.6 1M Thinking Fast",
},
{
value: "composer-2.5-fast",
label: "composer-2.5-fast",
description: "Composer 2.5 Fast",
},
{
value: "gpt-5.5-none",
label: "gpt-5.5-none",
description: "GPT-5.5 1M None",
},
{
value: "gpt-5.5-none-fast",
label: "gpt-5.5-none-fast",
description: "GPT-5.5 None Fast",
},
{
value: "gpt-5.5-low",
label: "gpt-5.5-low",
description: "GPT-5.5 1M Low",
},
{
value: "gpt-5.5-low-fast",
label: "gpt-5.5-low-fast",
description: "GPT-5.5 Low Fast",
},
{
value: "gpt-5.5-medium",
label: "gpt-5.5-medium",
description: "GPT-5.5 1M",
},
{
value: "gpt-5.5-medium-fast",
label: "gpt-5.5-medium-fast",
description: "GPT-5.5 Fast",
},
{
value: "gpt-5.5-extra-high",
label: "gpt-5.5-extra-high",
description: "GPT-5.5 1M Extra High",
},
{
value: "gpt-5.5-extra-high-fast",
label: "gpt-5.5-extra-high-fast",
description: "GPT-5.5 Extra High Fast",
},
{
value: "claude-4.6-sonnet-medium",
label: "claude-4.6-sonnet-medium",
description: "Sonnet 4.6 1M",
},
{
value: "claude-4.6-sonnet-medium-thinking",
label: "claude-4.6-sonnet-medium-thinking",
description: "Sonnet 4.6 1M Thinking",
},
{
value: "claude-opus-4-7-low",
label: "claude-opus-4-7-low",
description: "Opus 4.7 1M Low",
},
{
value: "claude-opus-4-7-low-fast",
label: "claude-opus-4-7-low-fast",
description: "Opus 4.7 1M Low Fast",
},
{
value: "claude-opus-4-7-medium",
label: "claude-opus-4-7-medium",
description: "Opus 4.7 1M Medium",
},
{
value: "claude-opus-4-7-medium-fast",
label: "claude-opus-4-7-medium-fast",
description: "Opus 4.7 1M Medium Fast",
},
{
value: "claude-opus-4-7-high",
label: "claude-opus-4-7-high",
description: "Opus 4.7 1M High",
},
{
value: "claude-opus-4-7-high-fast",
label: "claude-opus-4-7-high-fast",
description: "Opus 4.7 1M High Fast",
},
{
value: "claude-opus-4-7-xhigh",
label: "claude-opus-4-7-xhigh",
description: "Opus 4.7 1M",
},
{
value: "claude-opus-4-7-xhigh-fast",
label: "claude-opus-4-7-xhigh-fast",
description: "Opus 4.7 1M Fast",
},
{
value: "claude-opus-4-7-max",
label: "claude-opus-4-7-max",
description: "Opus 4.7 1M Max",
},
{
value: "claude-opus-4-7-max-fast",
label: "claude-opus-4-7-max-fast",
description: "Opus 4.7 1M Max Fast",
},
{
value: "claude-opus-4-7-thinking-low",
label: "claude-opus-4-7-thinking-low",
description: "Opus 4.7 1M Low Thinking",
},
{
value: "claude-opus-4-7-thinking-low-fast",
label: "claude-opus-4-7-thinking-low-fast",
description: "Opus 4.7 1M Low Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-medium",
label: "claude-opus-4-7-thinking-medium",
description: "Opus 4.7 1M Medium Thinking",
},
{
value: "claude-opus-4-7-thinking-medium-fast",
label: "claude-opus-4-7-thinking-medium-fast",
description: "Opus 4.7 1M Medium Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-high-fast",
label: "claude-opus-4-7-thinking-high-fast",
description: "Opus 4.7 1M High Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-xhigh",
label: "claude-opus-4-7-thinking-xhigh",
description: "Opus 4.7 1M Thinking",
},
{
value: "claude-opus-4-7-thinking-xhigh-fast",
label: "claude-opus-4-7-thinking-xhigh-fast",
description: "Opus 4.7 1M Thinking Fast",
},
{
value: "claude-opus-4-7-thinking-max",
label: "claude-opus-4-7-thinking-max",
description: "Opus 4.7 1M Max Thinking",
},
{
value: "claude-opus-4-7-thinking-max-fast",
label: "claude-opus-4-7-thinking-max-fast",
description: "Opus 4.7 1M Max Thinking Fast",
},
{
value: "grok-build-0.1",
label: "grok-build-0.1",
description: "Grok Build 0.1 1M",
},
{
value: "gpt-5.4-low",
label: "gpt-5.4-low",
description: "GPT-5.4 1M Low",
},
{
value: "gpt-5.4-medium",
label: "gpt-5.4-medium",
description: "GPT-5.4 1M",
},
{
value: "gpt-5.4-medium-fast",
label: "gpt-5.4-medium-fast",
description: "GPT-5.4 Fast",
},
{
value: "gpt-5.4-xhigh",
label: "gpt-5.4-xhigh",
description: "GPT-5.4 1M Extra High",
},
{
value: "gpt-5.4-xhigh-fast",
label: "gpt-5.4-xhigh-fast",
description: "GPT-5.4 Extra High Fast",
},
{
value: "claude-4.6-opus-high",
label: "claude-4.6-opus-high",
description: "Opus 4.6 1M",
},
{
value: "claude-4.6-opus-max",
label: "claude-4.6-opus-max",
description: "Opus 4.6 1M Max",
},
{
value: "claude-4.6-opus-max-thinking",
label: "claude-4.6-opus-max-thinking",
description: "Opus 4.6 1M Max Thinking",
},
{
value: "claude-4.6-opus-max-thinking-fast",
label: "claude-4.6-opus-max-thinking-fast",
description: "Opus 4.6 1M Max Thinking Fast",
},
{
value: "claude-4.5-opus-high",
label: "claude-4.5-opus-high",
description: "Opus 4.5",
},
{
value: "claude-4.5-opus-high-thinking",
label: "claude-4.5-opus-high-thinking",
description: "Opus 4.5 Thinking",
},
{
value: "gpt-5.2-low",
label: "gpt-5.2-low",
description: "GPT-5.2 Low",
},
{
value: "gpt-5.2-low-fast",
label: "gpt-5.2-low-fast",
description: "GPT-5.2 Low Fast",
},
{
value: "gpt-5.2-fast",
label: "gpt-5.2-fast",
description: "GPT-5.2 Fast",
},
{
value: "gpt-5.2-high",
label: "gpt-5.2-high",
description: "GPT-5.2 High",
},
{
value: "gpt-5.2-high-fast",
label: "gpt-5.2-high-fast",
description: "GPT-5.2 High Fast",
},
{
value: "gpt-5.2-xhigh",
label: "gpt-5.2-xhigh",
description: "GPT-5.2 Extra High",
},
{
value: "gpt-5.2-xhigh-fast",
label: "gpt-5.2-xhigh-fast",
description: "GPT-5.2 Extra High Fast",
},
{
value: "gemini-3.1-pro",
label: "gemini-3.1-pro",
description: "Gemini 3.1 Pro",
},
{
value: "gpt-5.4-mini-none",
label: "gpt-5.4-mini-none",
description: "GPT-5.4 Mini None",
},
{
value: "gpt-5.4-mini-low",
label: "gpt-5.4-mini-low",
description: "GPT-5.4 Mini Low",
},
{
value: "gpt-5.4-mini-medium",
label: "gpt-5.4-mini-medium",
description: "GPT-5.4 Mini",
},
{
value: "gpt-5.4-mini-high",
label: "gpt-5.4-mini-high",
description: "GPT-5.4 Mini High",
},
{
value: "gpt-5.4-mini-xhigh",
label: "gpt-5.4-mini-xhigh",
description: "GPT-5.4 Mini Extra High",
},
{
value: "gpt-5.4-nano-none",
label: "gpt-5.4-nano-none",
description: "GPT-5.4 Nano None",
},
{
value: "gpt-5.4-nano-low",
label: "gpt-5.4-nano-low",
description: "GPT-5.4 Nano Low",
},
{
value: "gpt-5.4-nano-medium",
label: "gpt-5.4-nano-medium",
description: "GPT-5.4 Nano",
},
{
value: "gpt-5.4-nano-high",
label: "gpt-5.4-nano-high",
description: "GPT-5.4 Nano High",
},
{
value: "gpt-5.4-nano-xhigh",
label: "gpt-5.4-nano-xhigh",
description: "GPT-5.4 Nano Extra High",
},
{
value: "grok-4.3",
label: "grok-4.3",
description: "Grok 4.3 1M",
},
{
value: "claude-4.5-sonnet",
label: "claude-4.5-sonnet",
description: "Sonnet 4.5",
},
{
value: "claude-4.5-sonnet-thinking",
label: "claude-4.5-sonnet-thinking",
description: "Sonnet 4.5 Thinking",
},
{
value: "gpt-5.1-low",
label: "gpt-5.1-low",
description: "GPT-5.1 Low",
},
{
value: "gpt-5.1",
label: "gpt-5.1",
description: "GPT-5.1",
},
{
value: "gpt-5.1-high",
label: "gpt-5.1-high",
description: "GPT-5.1 High",
},
{
value: "gemini-3-flash",
label: "gemini-3-flash",
description: "Gemini 3 Flash",
},
{
value: "gemini-3.5-flash",
label: "gemini-3.5-flash",
description: "Gemini 3.5 Flash",
},
{
value: "gpt-5.1-codex-mini-low",
label: "gpt-5.1-codex-mini-low",
description: "Codex 5.1 Mini Low",
},
{
value: "gpt-5.1-codex-mini",
label: "gpt-5.1-codex-mini",
description: "Codex 5.1 Mini",
},
{
value: "gpt-5.1-codex-mini-high",
label: "gpt-5.1-codex-mini-high",
description: "Codex 5.1 Mini High",
},
{
value: "claude-4-sonnet",
label: "claude-4-sonnet",
description: "Sonnet 4",
},
{
value: "claude-4-sonnet-thinking",
label: "claude-4-sonnet-thinking",
description: "Sonnet 4 Thinking",
},
{
value: "gpt-5-mini",
label: "gpt-5-mini",
description: "GPT-5 Mini",
},
{
value: "kimi-k2.5",
label: "kimi-k2.5",
description: "Kimi K2.5",
},
],
DEFAULT: "composer-2.5-fast",
};
type CursorModelRow = {
name: string;
description: string;
current: boolean;
default: boolean;
};
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const ANSI_PATTERN = new RegExp(
// eslint-disable-next-line no-control-regex
'[\\u001B\\u009B][[\\]()#;?]*(?:'
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
'g',
);
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
const parseModelLine = (line: string): CursorModelRow | null => {
const trimmed = line.trim();
if (
!trimmed
|| trimmed === 'Available models'
|| trimmed.startsWith('Loading models')
|| trimmed.startsWith('Tip:')
) {
return null;
}
const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/);
if (!match) {
return null;
}
const name = match[1].trim();
let description = match[2].trim();
const current = /\(current\)/i.test(description);
const defaultModel = /\(default\)/i.test(description);
description = description.replace(/\s*\((current|default)\)/gi, '').replace(/\s{2,}/g, ' ').trim();
return {
name,
description,
current,
default: defaultModel,
};
};
const parseModelsOutput = (text: string): CursorModelRow[] => {
const models: CursorModelRow[] = [];
for (const line of stripAnsi(text).split(/\r?\n/)) {
const parsed = parseModelLine(line);
if (parsed) {
models.push(parsed);
}
}
return models;
};
const runCursorListModels = (): Promise<string> => new Promise((resolve, reject) => {
const cursorProcess = spawnFunction('cursor-agent', ['--list-models'], {
env: { ...process.env },
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
cursorProcess.kill('SIGTERM');
if (!settled) {
settled = true;
reject(new Error('cursor-agent --list-models timed out'));
}
}, CURSOR_MODELS_TIMEOUT_MS);
const finish = (error: Error | null, output: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (error) {
reject(error);
return;
}
resolve(output);
};
cursorProcess.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
cursorProcess.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
cursorProcess.on('error', (error) => {
finish(error instanceof Error ? error : new Error(String(error)), '');
});
cursorProcess.on('close', (code) => {
if (code !== 0) {
finish(new Error(stderr.trim() || `cursor-agent --list-models exited with code ${code}`), '');
return;
}
finish(null, stdout);
});
});
const buildCursorModelsDefinition = (models: CursorModelRow[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = [];
const seenValues = new Set<string>();
for (const model of models) {
if (seenValues.has(model.name)) {
continue;
}
seenValues.add(model.name);
options.push({
value: model.name,
label: model.name,
description: model.description || undefined,
});
}
if (options.length === 0) {
return CURSOR_FALLBACK_MODELS;
}
const defaultValue = models.find((model) => model.default)?.name
?? models.find((model) => model.current)?.name
?? options[0]?.value
?? CURSOR_FALLBACK_MODELS.DEFAULT;
return {
OPTIONS: options,
DEFAULT: defaultValue,
};
};
const resolveCursorSessionStorePath = async (sessionId: string): Promise<string | null> => {
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
try {
const workspaceEntries = await readdir(CURSOR_CHATS_ROOT, { withFileTypes: true });
for (const workspaceEntry of workspaceEntries) {
if (!workspaceEntry.isDirectory()) {
continue;
}
const storeDbPath = path.join(CURSOR_CHATS_ROOT, workspaceEntry.name, safeSessionId, 'store.db');
try {
await access(storeDbPath);
return storeDbPath;
} catch {
// Keep scanning sibling workspaces until the matching session directory is found.
}
}
} catch {
return null;
}
return null;
};
export class CursorProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
try {
const stdout = await runCursorListModels();
const models = parseModelsOutput(stdout);
return buildCursorModelsDefinition(models);
} catch {
return CURSOR_FALLBACK_MODELS;
}
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
if (!sessionId?.trim()) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
try {
const storeDbPath = await resolveCursorSessionStorePath(sessionId);
if (!storeDbPath) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
const { default: Database } = await import('better-sqlite3');
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const row = db.prepare(`SELECT value FROM meta WHERE key='0' LIMIT 1;`).get() as {
value?: Buffer | string;
} | undefined;
const metadataText = Buffer.isBuffer(row?.value)
? row.value.toString('utf8')
: typeof row?.value === 'string' && row.value.trim()
? Buffer.from(row.value.trim(), 'hex').toString('utf8')
: '';
if (!metadataText) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
const metadata = JSON.parse(metadataText) as { lastUsedModel?: string };
if (typeof metadata.lastUsedModel === 'string' && metadata.lastUsedModel.trim()) {
return {
model: metadata.lastUsedModel.trim(),
};
}
} finally {
db.close();
}
} catch {
// Fall through to the provider default when Cursor metadata cannot be read.
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('cursor', input);
}
}

View File

@@ -4,13 +4,7 @@ import path from 'node:path';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
createNormalizedMessage,
generateMessageId,
readObjectRecord,
sanitizeLeafDirectoryName,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'cursor'; const PROVIDER = 'cursor';
@@ -192,6 +186,24 @@ function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown
return normalized; return normalized;
} }
function sanitizeCursorSessionId(sessionId: string): string {
const normalized = sessionId.trim();
if (!normalized) {
throw new Error('Cursor session id is required.');
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid cursor session id "${sessionId}".`);
}
return normalized;
}
export class CursorSessionsProvider implements IProviderSessions { export class CursorSessionsProvider implements IProviderSessions {
/** /**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation * Loads Cursor's SQLite blob DAG and returns message blobs in conversation
@@ -202,7 +214,7 @@ export class CursorSessionsProvider implements IProviderSessions {
const { default: Database } = await import('better-sqlite3'); const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id'); const safeSessionId = sanitizeCursorSessionId(sessionId);
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId); const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db'); const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
const resolvedBaseChatsPath = path.resolve(baseChatsPath); const resolvedBaseChatsPath = path.resolve(baseChatsPath);
@@ -364,32 +376,42 @@ export class CursorSessionsProvider implements IProviderSessions {
/** /**
* Fetches and paginates Cursor session history from its project-scoped store.db. * Fetches and paginates Cursor session history from its project-scoped store.db.
*
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
* the most recent page, matching every other provider.
*/ */
async fetchHistory( async fetchHistory(
sessionId: string, sessionId: string,
options: FetchHistoryOptions = {}, options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> { ): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options; const { projectPath = '', limit = null, offset = 0 } = options;
// The store.db folder on disk is named after the provider-native id, not
// the app-facing session id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
try { try {
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath); const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result'); const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
const total = renderableMessages.length; const total = renderableMessages.length;
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
if (limit !== null) {
const start = offset;
const page = limit === 0
? []
: renderableMessages.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total,
hasMore,
offset,
limit,
};
}
return { return {
messages: page, messages: renderableMessages,
total, total,
hasMore, hasMore: false,
offset, offset: 0,
limit, limit: null,
}; };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);

View File

@@ -1,20 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js'; import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
import { CursorProviderModels } from '@/modules/providers/list/cursor/cursor-models.provider.js';
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js'; import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js';
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js'; import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js'; import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js';
import type { import type {
IProviderAuth, IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer, IProviderSessionSynchronizer,
IProviderSkills, IProviderSkills,
IProviderSessions, IProviderSessions,
} from '@/shared/interfaces.js'; } from '@/shared/interfaces.js';
export class CursorProvider extends AbstractProvider { export class CursorProvider extends AbstractProvider {
readonly models: IProviderModels = new CursorProviderModels();
readonly mcp = new CursorMcpProvider(); readonly mcp = new CursorMcpProvider();
readonly auth: IProviderAuth = new CursorProviderAuth(); readonly auth: IProviderAuth = new CursorProviderAuth();
readonly skills: IProviderSkills = new CursorSkillsProvider(); readonly skills: IProviderSkills = new CursorSkillsProvider();

View File

@@ -1,39 +0,0 @@
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
],
DEFAULT: 'gemini-3-flash-preview',
};
export class GeminiProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
return GEMINI_FALLBACK_MODELS;
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('gemini', input);
}
}

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js'; import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini'; const PROVIDER = 'gemini';
@@ -88,15 +88,22 @@ function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
const record = tokens as AnyRecord; const record = tokens as AnyRecord;
const input = Number(record.input || 0); const input = Number(record.input || 0);
const output = Number(record.output || 0); const output = Number(record.output || 0);
const total = Number(record.total || input + output || 0); const cached = Number(record.cached || 0);
const thoughts = Number(record.thoughts || 0);
const tool = Number(record.tool || 0);
const totalFromFields = input + output + cached + thoughts + tool;
const total = Number(record.total || totalFromFields || 0);
return { return {
used: total, used: total,
inputTokens: input, total: total,
outputTokens: output,
breakdown: { breakdown: {
input, input,
output, output,
cached,
thoughts,
tool,
}, },
}; };
} }
@@ -518,9 +525,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
const start = Math.max(0, offset); const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit); const pageLimit = limit === null ? null : Math.max(0, limit);
// Tail pagination via the shared contract: offset 0 returns the most const messages = pageLimit === null
// recent page, matching every other provider. ? normalized.slice(start)
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start); : normalized.slice(start, start + pageLimit);
let total = 0; let total = 0;
for (const msg of normalized) { for (const msg of normalized) {
if (msg.kind !== 'tool_result') { if (msg.kind !== 'tool_result') {
@@ -529,9 +536,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
} }
return { return {
messages: page, messages,
total, total,
hasMore, hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
offset: start, offset: start,
limit: pageLimit, limit: pageLimit,
tokenUsage: result.tokenUsage, tokenUsage: result.tokenUsage,

View File

@@ -1,20 +1,17 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js'; import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js'; import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js'; import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js'; import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
import type { import type {
IProviderAuth, IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer, IProviderSessionSynchronizer,
IProviderSkills, IProviderSkills,
IProviderSessions, IProviderSessions,
} from '@/shared/interfaces.js'; } from '@/shared/interfaces.js';
export class GeminiProvider extends AbstractProvider { export class GeminiProvider extends AbstractProvider {
readonly models: IProviderModels = new GeminiProviderModels();
readonly mcp = new GeminiMcpProvider(); readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuth = new GeminiProviderAuth(); readonly auth: IProviderAuth = new GeminiProviderAuth();
readonly skills: IProviderSkills = new GeminiSkillsProvider(); readonly skills: IProviderSkills = new GeminiSkillsProvider();

View File

@@ -1,111 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type OpenCodeCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
const OPENCODE_ENV_CREDENTIAL_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY',
'OPENROUTER_API_KEY',
];
export class OpenCodeProviderAuth implements IProviderAuth {
/**
* Checks whether the OpenCode CLI is available to the server process.
*/
private checkInstalled(): boolean {
try {
const result = spawn.sync('opencode', ['--version'], { stdio: 'ignore', timeout: 5000 });
return !result.error && result.status === 0;
} catch {
return false;
}
}
/**
* Returns OpenCode CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
const credentials = await this.checkCredentials();
return {
installed,
provider: 'opencode',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads OpenCode's auth store or falls back to provider API key environment variables.
*/
private async checkCredentials(): Promise<OpenCodeCredentialsStatus> {
try {
const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = readObjectRecord(JSON.parse(content)) ?? {};
for (const [providerId, providerAuth] of Object.entries(auth)) {
const providerRecord = readObjectRecord(providerAuth);
if (!providerRecord) {
continue;
}
const hasCredential = Object.values(providerRecord).some(
(value) => readOptionalString(value) !== undefined || Boolean(readObjectRecord(value)),
);
if (hasCredential) {
return {
authenticated: true,
email: `${providerId} credentials`,
method: 'credentials_file',
};
}
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
return {
authenticated: false,
email: null,
method: null,
error: error instanceof Error ? error.message : 'Failed to read OpenCode auth',
};
}
}
const envCredential = OPENCODE_ENV_CREDENTIAL_KEYS.find((key) => process.env[key]?.trim());
if (envCredential) {
return {
authenticated: true,
email: envCredential,
method: 'environment',
};
}
return {
authenticated: false,
email: null,
method: null,
error: 'OpenCode not configured',
};
}
}

View File

@@ -1,228 +0,0 @@
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
type OpenCodeConfigPath = {
filePath: string;
exists: boolean;
};
const fileExists = async (filePath: string): Promise<boolean> => {
try {
await access(filePath);
return true;
} catch {
return false;
}
};
/**
* Removes JSONC comments without touching comment-like text inside strings.
*/
const stripJsonComments = (content: string): string => {
let output = '';
let inString = false;
let quote = '';
let escaped = false;
for (let index = 0; index < content.length; index += 1) {
const char = content[index];
const next = content[index + 1];
if (inString) {
output += char;
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === quote) {
inString = false;
quote = '';
}
continue;
}
if (char === '"' || char === '\'') {
inString = true;
quote = char;
output += char;
continue;
}
if (char === '/' && next === '/') {
while (index < content.length && content[index] !== '\n') {
index += 1;
}
output += '\n';
continue;
}
if (char === '/' && next === '*') {
index += 2;
while (index < content.length && !(content[index] === '*' && content[index + 1] === '/')) {
index += 1;
}
index += 1;
continue;
}
output += char;
}
return output;
};
const stripTrailingCommas = (content: string): string =>
content.replace(/,\s*([}\]])/g, '$1');
const readOpenCodeConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(content))) as unknown;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
const writeOpenCodeConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
};
const resolveOpenCodeConfigPath = async (scope: McpScope, workspacePath: string): Promise<OpenCodeConfigPath> => {
const root = scope === 'user'
? path.join(os.homedir(), '.config', 'opencode')
: workspacePath;
const jsonPath = path.join(root, 'opencode.json');
const jsoncPath = path.join(root, 'opencode.jsonc');
if (await fileExists(jsonPath)) {
return { filePath: jsonPath, exists: true };
}
if (await fileExists(jsoncPath)) {
return { filePath: jsoncPath, exists: true };
}
return { filePath: jsonPath, exists: false };
};
export class OpenCodeMcpProvider extends McpProvider {
constructor() {
super('opencode', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
const config = await readOpenCodeConfig(filePath);
return readObjectRecord(config.mcp) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
const config = await readOpenCodeConfig(filePath);
config.mcp = servers;
await writeOpenCodeConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
type: 'local',
command: [input.command, ...(input.args ?? [])],
enabled: true,
environment: input.env ?? {},
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: 'remote',
url: input.url,
enabled: true,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
const config = readObjectRecord(rawConfig);
if (!config) {
return null;
}
if (config.type === 'local' || config.command !== undefined) {
const commandParts = typeof config.command === 'string'
? [config.command, ...(readStringArray(config.args) ?? [])]
: readStringArray(config.command);
const command = commandParts?.[0];
if (!command) {
return null;
}
return {
provider: 'opencode',
name,
scope,
transport: 'stdio',
command,
args: commandParts.slice(1),
env: readStringRecord(config.environment) ?? readStringRecord(config.env),
};
}
if (config.type === 'remote' || typeof config.url === 'string') {
const url = readOptionalString(config.url);
if (!url) {
return null;
}
return {
provider: 'opencode',
name,
scope,
transport: 'http',
url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -1,339 +0,0 @@
import { spawn } from 'node:child_process';
import Database from 'better-sqlite3';
import crossSpawn from 'cross-spawn';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
getOpenCodeDatabasePath,
readObjectRecord,
readOptionalString,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{
value: 'anthropic/claude-sonnet-4-5',
label: 'Claude Sonnet 4.5',
description: 'anthropic - anthropic/claude-sonnet-4-5',
},
{
value: 'anthropic/claude-opus-4-1',
label: 'Claude Opus 4.1',
description: 'anthropic - anthropic/claude-opus-4-1',
},
{
value: 'anthropic/claude-haiku-4-5',
label: 'Claude Haiku 4.5',
description: 'anthropic - anthropic/claude-haiku-4-5',
},
{
value: 'openai/gpt-5.1',
label: 'GPT-5.1',
description: 'openai - openai/gpt-5.1',
},
{
value: 'openai/gpt-5.1-codex',
label: 'GPT-5.1 Codex',
description: 'openai - openai/gpt-5.1-codex',
},
{
value: 'openai/gpt-5.4-mini',
label: 'GPT-5.4 Mini',
description: 'openai - openai/gpt-5.4-mini',
},
{
value: 'google/gemini-2.5-pro',
label: 'Gemini 2.5 Pro',
description: 'google - google/gemini-2.5-pro',
},
{
value: 'google/gemini-2.5-flash',
label: 'Gemini 2.5 Flash',
description: 'google - google/gemini-2.5-flash',
},
],
DEFAULT: 'anthropic/claude-sonnet-4-5',
};
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const DATE_TOKEN = /^\d{8}$/;
const SIMPLE_NUMBER_TOKEN = /^\d$/;
const VERSION_TOKEN = /^[a-z]\d+$/i;
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/;
export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
const ids: string[] = [];
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('{') || line.startsWith('[')) {
continue;
}
if (MODEL_ID_LINE.test(line)) {
ids.push(line);
}
}
return [...new Set(ids)];
};
const formatDateToken = (token: string): string => (
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
);
const formatModelToken = (token: string, nextToken?: string): string => {
const lower = token.toLowerCase();
if (VERSION_TOKEN.test(token)) {
return token.toUpperCase();
}
if (SHORT_ACRONYM_TOKEN.test(lower) && nextToken && NUMERIC_TOKEN.test(nextToken)) {
return token.toUpperCase();
}
return lower.charAt(0).toUpperCase() + lower.slice(1);
};
const formatOpenCodeModelSlug = (slug: string): string => {
const labelParts: string[] = [];
const dateParts: string[] = [];
const tokens = slug.split('-').filter(Boolean);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const nextToken = tokens[index + 1];
if (DATE_TOKEN.test(token)) {
dateParts.push(formatDateToken(token));
continue;
}
if (SIMPLE_NUMBER_TOKEN.test(token) && nextToken && SIMPLE_NUMBER_TOKEN.test(nextToken)) {
labelParts.push(`${token}.${nextToken}`);
index += 1;
continue;
}
labelParts.push(formatModelToken(token, nextToken));
}
const label = (labelParts.join(' ').trim() || slug).replace(/^GPT\s+/, 'GPT-');
if (dateParts.length === 0) {
return label;
}
return `${label} (${dateParts.join(', ')})`;
};
const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: string } => {
const separatorIndex = id.indexOf('/');
if (separatorIndex < 0) {
return {
upstreamProvider: '',
slug: id,
};
}
return {
upstreamProvider: id.slice(0, separatorIndex),
slug: id.slice(separatorIndex + 1),
};
};
const labelForOpenCodeModelId = (id: string): string => {
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
if (fallbackLabel) {
return fallbackLabel;
}
const { slug } = readOpenCodeModelParts(id);
return formatOpenCodeModelSlug(slug);
};
const descriptionForOpenCodeModelId = (id: string): string => {
const { upstreamProvider } = readOpenCodeModelParts(id);
return upstreamProvider ? `${upstreamProvider} - ${id}` : id;
};
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
}));
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
?? options[0]?.value
?? OPENCODE_FALLBACK_MODELS.DEFAULT;
return {
OPTIONS: options,
DEFAULT: defaultValue,
};
};
const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
if (typeof rawModel === 'string') {
const trimmed = rawModel.trim();
if (!trimmed) {
return null;
}
try {
return parseOpenCodeSessionModelValue(JSON.parse(trimmed));
} catch {
return trimmed;
}
}
const record = readObjectRecord(rawModel);
if (!record) {
return null;
}
return readOptionalString(record.id)
?? readOptionalString(record.model)
?? readOptionalString(record.name)
?? readOptionalString(record.value)
?? null;
};
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
const openCodeProcess = spawnFunction('opencode', ['models'], {
cwd: process.cwd(),
env: { ...process.env },
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
openCodeProcess.kill('SIGTERM');
if (!settled) {
settled = true;
reject(new Error('opencode models timed out'));
}
}, OPEN_CODE_MODELS_TIMEOUT_MS);
const finish = (error: Error | null, output: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (error) {
reject(error);
return;
}
resolve(output);
};
openCodeProcess.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
openCodeProcess.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
openCodeProcess.on('error', (error) => {
finish(error instanceof Error ? error : new Error(String(error)), '');
});
openCodeProcess.on('close', (code) => {
if (code !== 0) {
finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), '');
return;
}
finish(null, stdout);
});
});
export class OpenCodeProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
try {
const stdout = await runOpenCodeModelsCommand();
const ids = parseOpenCodeModelsStdout(stdout);
if (ids.length === 0) {
return OPENCODE_FALLBACK_MODELS;
}
return buildOpenCodeDefinitionFromIds(ids);
} catch {
return OPENCODE_FALLBACK_MODELS;
}
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
if (!sessionId?.trim()) {
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
try {
const dbPath = getOpenCodeDatabasePath();
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db.prepare(`
SELECT
s.id AS sessionId,
s.model AS model,
s.agent AS agent,
s.directory AS directory,
s.time_updated AS timeUpdated,
s.time_created AS timeCreated
FROM session s
WHERE s.id = ?
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC
LIMIT 1
`).get(sessionId) as {
sessionId?: string;
model?: unknown;
agent?: string | null;
directory?: string | null;
timeUpdated?: number | null;
timeCreated?: number | null;
} | undefined;
const model = parseOpenCodeSessionModelValue(row?.model);
if (model) {
return {
model,
};
}
} finally {
db.close();
}
} catch {
// Fall through to the provider default when OpenCode session lookup fails.
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('opencode', input);
}
}

View File

@@ -1,160 +0,0 @@
import fsSync from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import {
getOpenCodeDatabasePath,
normalizeProviderTimestamp,
normalizeSessionName,
readJsonRecord,
readOptionalString,
} from '@/shared/utils.js';
type OpenCodeSessionRow = {
id: string;
directory: string | null;
title: string | null;
time_created: number | null;
time_updated: number | null;
worktree: string | null;
};
type SynchronizeRowsResult = {
processed: number;
firstSessionId: string | null;
};
/**
* Session indexer for OpenCode's SQLite-backed session store.
*/
export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'opencode' as const;
/**
* Scans OpenCode's shared opencode.db and upserts active sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const result = this.synchronizeRows(since);
return result.processed;
}
/**
* Handles watcher changes for opencode.db.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (path.basename(filePath) !== 'opencode.db') {
return null;
}
const result = this.synchronizeRows(undefined, 1);
return result.firstSessionId;
}
private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult {
const dbPath = getOpenCodeDatabasePath();
if (!fsSync.existsSync(dbPath)) {
return { processed: 0, firstSessionId: null };
}
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const sinceMillis = since?.getTime() ?? null;
const limitClause = limit ? 'LIMIT ?' : '';
const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis];
const rows = db.prepare(`
SELECT
s.id AS id,
s.directory AS directory,
s.title AS title,
s.time_created AS time_created,
s.time_updated AS time_updated,
p.worktree AS worktree
FROM session s
LEFT JOIN project p ON p.id = s.project_id
WHERE s.time_archived IS NULL
AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?)
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC
${limitClause}
`).all(...params) as OpenCodeSessionRow[];
let processed = 0;
let firstSessionId: string | null = null;
for (const row of rows) {
const indexedSessionId = this.upsertSession(db, row);
if (!indexedSessionId) {
continue;
}
if (!firstSessionId) {
firstSessionId = indexedSessionId;
}
processed += 1;
}
return { processed, firstSessionId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message);
return { processed: 0, firstSessionId: null };
} finally {
db.close();
}
}
private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null {
const sessionId = readOptionalString(row.id);
const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree);
if (!sessionId || !projectPath) {
return null;
}
const fallbackTitle = 'Untitled OpenCode Session';
// App-created sessions are keyed by an app id, so disk-discovered provider
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
// OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed.
sessionsDb.createSession(
sessionId,
this.provider,
projectPath,
normalizeSessionName(nextName, fallbackTitle),
normalizeProviderTimestamp(row.time_created),
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
null,
);
return sessionId;
}
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
try {
const row = db.prepare(`
SELECT p.data AS data
FROM message m
INNER JOIN part p
ON p.session_id = m.session_id
AND p.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') = 'user'
AND json_extract(p.data, '$.type') = 'text'
ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0)
LIMIT 1
`).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data);
return readOptionalString(data?.text);
} catch {
return undefined;
}
}
}

View File

@@ -1,503 +0,0 @@
import fsSync from 'node:fs';
import Database from 'better-sqlite3';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
createNormalizedMessage,
generateMessageId,
getOpenCodeDatabasePath,
normalizeProviderTimestamp,
readObjectRecord,
readJsonRecord,
readOptionalString,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
type OpenCodeHistoryRow = {
message_id: string;
message_time_created: number | null;
message_data: string | null;
part_id: string | null;
part_time_created: number | null;
part_data: string | null;
};
type OpenCodeTokenTotals = {
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
};
const openOpenCodeDatabase = (): Database.Database | null => {
const dbPath = getOpenCodeDatabasePath();
if (!fsSync.existsSync(dbPath)) {
return null;
}
return new Database(dbPath, { readonly: true, fileMustExist: true });
};
const formatToolContent = (value: unknown): string => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
/**
* OpenCode can persist the first prompt as a JSON string literal inside a text
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
* string literals so normal assistant/user prose remains untouched.
*/
const unwrapJsonStringLiteral = (value: string): string => {
const trimmed = value.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
return value;
}
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : value;
} catch {
return value;
}
};
const extractText = (value: unknown): string => {
if (typeof value === 'string') {
return unwrapJsonStringLiteral(value);
}
const record = readObjectRecord(value);
const text = readOptionalString(record?.text)
?? readOptionalString(record?.content)
?? '';
return unwrapJsonStringLiteral(text);
};
const hasUserRole = (value: unknown): boolean => {
const record = readObjectRecord(value);
return readOptionalString(record?.role) === 'user';
};
const isUserTextEcho = (raw: AnyRecord): boolean => {
return readOptionalString(raw.role) === 'user'
|| hasUserRole(raw.message)
|| hasUserRole(raw.part);
};
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
if (!totals) {
return undefined;
}
const inputTokens = totals.inputTokens;
const displayInputTokens = inputTokens + totals.cacheReadTokens;
const outputTokens = totals.outputTokens;
const used = inputTokens
+ outputTokens
+ totals.reasoningTokens
+ totals.cacheReadTokens
+ totals.cacheWriteTokens;
if (used <= 0) {
return undefined;
}
return {
used,
inputTokens: displayInputTokens,
outputTokens,
breakdown: {
input: displayInputTokens,
output: outputTokens,
},
};
};
const readOpenCodeSessionColumnTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const columns = db.prepare('PRAGMA table_info(session)').all() as { name: string }[];
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return undefined;
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(sessionId) as OpenCodeTokenTotals | undefined;
if (!row) {
return undefined;
}
return buildTokenUsage({
inputTokens: Number(row.inputTokens ?? 0),
outputTokens: Number(row.outputTokens ?? 0),
reasoningTokens: Number(row.reasoningTokens ?? 0),
cacheReadTokens: Number(row.cacheReadTokens ?? 0),
cacheWriteTokens: Number(row.cacheWriteTokens ?? 0),
});
};
/**
* OpenCode stores per-message token counts on assistant `message.data` objects
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
* matches current `opencode.db` layouts that only persist message JSON.
*/
const aggregateOpenCodeSessionTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const sessionColumnUsage = readOpenCodeSessionColumnTokenUsage(db, sessionId);
if (sessionColumnUsage) {
return sessionColumnUsage;
}
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
let inputTokens = 0;
let outputTokens = 0;
let reasoningTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
for (const row of rows) {
const info = readJsonRecord(row.data);
if (readOptionalString(info?.role) !== 'assistant') {
continue;
}
const tokens = readObjectRecord(info?.tokens);
if (!tokens) {
continue;
}
inputTokens += Number(tokens.input ?? 0);
outputTokens += Number(tokens.output ?? 0);
reasoningTokens += Number(tokens.reasoning ?? 0);
const cache = readObjectRecord(tokens.cache);
cacheReadTokens += Number(cache?.read ?? 0);
cacheWriteTokens += Number(cache?.write ?? 0);
}
return buildTokenUsage({
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
});
};
export class OpenCodeSessionsProvider implements IProviderSessions {
/**
* Normalizes live `opencode run --format json` events into frontend messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
const baseId = readOptionalString(raw.id)
?? readOptionalString(raw.messageID)
?? generateMessageId('opencode');
if (type === 'text') {
// The client already renders an optimistic user bubble, so provider user
// echoes must not be streamed back as assistant text.
if (isUserTextEcho(raw)) {
return [];
}
const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_delta',
content,
})];
}
if (type === 'reasoning') {
const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
})];
}
if (type === 'tool_use') {
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
const toolMessage = createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: raw.input ?? raw.arguments ?? {},
toolId,
});
if (raw.output !== undefined || raw.error !== undefined) {
toolMessage.toolResult = {
content: formatToolContent(raw.output ?? raw.error),
isError: raw.error !== undefined,
};
}
return [toolMessage];
}
if (type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
})];
}
if (type === 'step_finish') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_end',
})];
}
return [];
}
/**
* Loads OpenCode history from the shared SQLite session database.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
// OpenCode's shared sqlite database keys messages by the provider-native
// session id, not the app-facing id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
const db = openOpenCodeDatabase();
if (!db) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
try {
const rows = db.prepare(`
SELECT
m.id AS message_id,
m.time_created AS message_time_created,
m.data AS message_data,
p.id AS part_id,
p.time_created AS part_time_created,
p.data AS part_data
FROM message m
LEFT JOIN part p
ON p.session_id = m.session_id
AND p.message_id = m.id
WHERE m.session_id = ?
ORDER BY
COALESCE(m.time_created, 0),
m.id,
COALESCE(p.time_created, 0),
p.id
`).all(providerSessionId) as OpenCodeHistoryRow[];
const normalized = this.normalizeHistoryRows(rows, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const total = normalized.length;
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
return {
messages: page,
total,
hasMore,
offset: normalizedOffset,
limit: normalizedLimit,
tokenUsage,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
} finally {
db.close();
}
}
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
const normalized: NormalizedMessage[] = [];
const emittedMessageErrors = new Set<string>();
for (const row of rows) {
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
const messageInfo = readJsonRecord(row.message_data);
const messageRole = readOptionalString(messageInfo?.role);
if (
messageInfo
&& messageRole === 'assistant'
&& messageInfo.error != null
&& !emittedMessageErrors.has(row.message_id)
) {
emittedMessageErrors.add(row.message_id);
normalized.push(createNormalizedMessage({
id: `${baseId}_error`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: formatToolContent(messageInfo.error),
}));
}
if (!row.part_id) {
continue;
}
const partData = readJsonRecord(row.part_data) ?? {};
const partType = readOptionalString(partData.type);
if (!partType) {
continue;
}
if (partType === 'text') {
const content = extractText(partData);
if (content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'text',
role: messageRole === 'user' ? 'user' : 'assistant',
content,
}));
}
continue;
}
if (partType === 'reasoning') {
const content = extractText(partData);
if (content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
}));
}
continue;
}
if (partType === 'tool') {
const state = readObjectRecord(partData.state) ?? {};
const status = readOptionalString(state.status);
const toolMessage = createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(partData.tool) ?? 'Tool',
toolInput: state.input ?? partData.input ?? {},
toolId: readOptionalString(partData.callID) ?? row.part_id,
});
if (status === 'completed' || status === 'error') {
toolMessage.toolResult = {
content: formatToolContent(state.output ?? state.error),
isError: status === 'error',
};
}
normalized.push(toolMessage);
continue;
}
if (partType === 'step-finish') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_end',
}));
continue;
}
if (partType === 'patch' || partType === 'agent') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: partType === 'patch' ? 'Patch' : 'Agent',
toolInput: partData,
toolId: row.part_id,
}));
}
}
return normalized;
}
}

View File

@@ -1,78 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type { ProviderSkillSource } from '@/shared/types.js';
import {
addUniqueProviderSkillSource,
findTopmostGitRoot,
} from '@/shared/utils.js';
const OPENCODE_PROJECT_SKILL_DIRS = [
['.opencode', 'skills'],
['.claude', 'skills'],
['.agents', 'skills'],
];
const OPENCODE_USER_SKILL_DIRS = [
['.config', 'opencode', 'skills'],
['.claude', 'skills'],
['.agents', 'skills'],
];
export class OpenCodeSkillsProvider extends SkillsProvider {
constructor() {
super('opencode');
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
const sources: ProviderSkillSource[] = [];
const seenRootDirs = new Set<string>();
const repoRoot = await findTopmostGitRoot(workspacePath);
for (const projectRoot of this.getProjectSearchRoots(workspacePath, repoRoot)) {
for (const skillDir of OPENCODE_PROJECT_SKILL_DIRS) {
// OpenCode intentionally reads Claude and Agents skill folders so users
// can reuse the same skill libraries across compatible coding agents.
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'project',
rootDir: path.join(projectRoot, ...skillDir),
commandPrefix: '/',
});
}
}
for (const skillDir of OPENCODE_USER_SKILL_DIRS) {
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'user',
rootDir: path.join(os.homedir(), ...skillDir),
commandPrefix: '/',
});
}
return sources;
}
private getProjectSearchRoots(workspacePath: string, repoRoot: string | null): string[] {
const roots: string[] = [];
const normalizedWorkspacePath = path.resolve(workspacePath);
const normalizedRepoRoot = repoRoot ? path.resolve(repoRoot) : null;
let currentPath = normalizedWorkspacePath;
while (true) {
roots.push(currentPath);
if (!normalizedRepoRoot || currentPath === normalizedRepoRoot) {
break;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
return roots;
}
}

View File

@@ -1,27 +0,0 @@
import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js';
import { OpenCodeProviderModels } from '@/modules/providers/list/opencode/opencode-models.provider.js';
import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/opencode-skills.provider.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import type {
IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';
export class OpenCodeProvider extends AbstractProvider {
readonly models: IProviderModels = new OpenCodeProviderModels();
readonly mcp = new OpenCodeMcpProvider();
readonly auth: IProviderAuth = new OpenCodeProviderAuth();
readonly skills: IProviderSkills = new OpenCodeSkillsProvider();
readonly sessions: IProviderSessions = new OpenCodeSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new OpenCodeSessionSynchronizer();
constructor() {
super('opencode');
}
}

View File

@@ -2,7 +2,6 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'; import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js'; import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js'; import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js'; import type { LLMProvider } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js'; import { AppError } from '@/shared/utils.js';
@@ -12,7 +11,6 @@ const providers: Record<LLMProvider, IProvider> = {
codex: new CodexProvider(), codex: new CodexProvider(),
cursor: new CursorProvider(), cursor: new CursorProvider(),
gemini: new GeminiProvider(), gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(),
}; };
/** /**

View File

@@ -1,19 +1,11 @@
import express, { type Request, type Response } from 'express'; import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js'; import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
import { sessionsService } from '@/modules/providers/services/sessions.service.js'; import { sessionsService } from '@/modules/providers/services/sessions.service.js';
import type { import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
LLMProvider,
McpScope,
McpTransport,
ProviderChangeActiveModelInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
const router = express.Router(); const router = express.Router();
@@ -181,13 +173,7 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
const parseProvider = (value: unknown): LLMProvider => { const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value); const normalized = normalizeProviderParam(value);
if ( if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
normalized === 'claude'
|| normalized === 'codex'
|| normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode'
) {
return normalized; return normalized;
} }
@@ -253,29 +239,6 @@ const parseSessionSearchLimit = (value: unknown): number => {
return Math.max(1, Math.min(parsed, 100)); return Math.max(1, Math.min(parsed, 100));
}; };
const parseChangeActiveModelPayload = (payload: unknown): ProviderChangeActiveModelInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const model = readOptionalQueryString(body.model);
if (!model) {
throw new AppError('model is required.', {
code: 'MODEL_REQUIRED',
statusCode: 400,
});
}
return {
sessionId: '',
model,
};
};
router.get( router.get(
'/:provider/auth/status', '/:provider/auth/status',
asyncHandler(async (req: Request, res: Response) => { asyncHandler(async (req: Request, res: Response) => {
@@ -285,30 +248,6 @@ router.get(
}), }),
); );
router.get(
'/:provider/models',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false;
const result = await providerModelsService.getProviderModels(provider, { bypassCache });
res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache }));
}),
);
router.post(
'/:provider/sessions/:sessionId/active-model',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const sessionId = parseSessionId(req.params.sessionId);
const payload = parseChangeActiveModelPayload(req.body);
const result = await providerModelsService.changeActiveModel(provider, {
...payload,
sessionId,
});
res.json(createApiSuccessResponse(result));
}),
);
// ----------------- Skills routes ----------------- // ----------------- Skills routes -----------------
router.get( router.get(
'/:provider/skills', '/:provider/skills',
@@ -383,51 +322,7 @@ router.post(
}), }),
); );
router.get(
'/capabilities',
asyncHandler(async (_req: Request, res: Response) => {
res.json(createApiSuccessResponse({
providers: providerCapabilitiesService.listAllProviderCapabilities(),
}));
}),
);
router.get(
'/:provider/capabilities',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
res.json(createApiSuccessResponse(
providerCapabilitiesService.getProviderCapabilities(provider),
));
}),
);
// ----------------- Session routes ----------------- // ----------------- Session routes -----------------
/**
* Session gateway entry point: allocates the stable app-facing session id for
* a brand-new chat. The frontend must call this before the first `chat.send`
* so the session id in the URL, the store, and the websocket all agree from
* the very first message — there is no client-visible session-id handoff.
*/
router.post(
'/sessions',
asyncHandler(async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const provider = parseProvider(body.provider);
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
const result = sessionsService.createAppSession(provider, projectPath);
res.status(201).json(createApiSuccessResponse(result));
}),
);
router.get(
'/sessions/running',
asyncHandler(async (_req: Request, res: Response) => {
const sessions = sessionsService.listRunningSessions();
res.json(createApiSuccessResponse({ sessions }));
}),
);
router.get( router.get(
'/sessions/archived', '/sessions/archived',
asyncHandler(async (_req: Request, res: Response) => { asyncHandler(async (_req: Request, res: Response) => {
@@ -504,7 +399,7 @@ router.get(
limit, limit,
offset, offset,
}); });
res.json(createApiSuccessResponse(result)); res.json(result);
}), }),
); );

View File

@@ -1,7 +1,18 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js'; import { AppError } from '@/shared/utils.js';
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
if (providerId === 'cursor' && os.platform() === 'win32') {
return false;
}
return true;
}
export const providerMcpService = { export const providerMcpService = {
/** /**
@@ -64,7 +75,7 @@ export const providerMcpService = {
const scope = input.scope ?? 'project'; const scope = input.scope ?? 'project';
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = []; const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders(); const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
for (const provider of providers) { for (const provider of providers) {
try { try {
await provider.mcp.upsertServer({ ...input, scope }); await provider.mcp.upsertServer({ ...input, scope });

View File

@@ -1,91 +0,0 @@
import type { LLMProvider } from '@/shared/types.js';
/**
* Static, backend-owned description of what one provider integration supports.
*
* The frontend renders its composer UI (permission mode picker, image upload,
* abort button, ...) purely from this shape, which is what keeps the frontend
* free of per-provider conditionals. New provider features should be exposed
* here instead of branching on the provider id in React components.
*/
type ProviderCapabilities = {
provider: LLMProvider;
/** Permission modes the provider runtime understands, in cycle order. */
permissionModes: string[];
defaultPermissionMode: string;
/** Whether image attachments can be included in a chat.send. */
supportsImages: boolean;
/** Whether an in-flight run can be cancelled via chat.abort. */
supportsAbort: boolean;
/** Whether interactive tool permission prompts can reach the UI. */
supportsPermissionRequests: boolean;
/** Whether the token-usage endpoint has data for this provider. */
supportsTokenUsage: boolean;
};
/**
* The capability matrix mirrors what each runtime actually implements today:
* - permission modes match the option sets accepted by each CLI/SDK.
* - only the Claude SDK integration surfaces interactive permission requests.
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
*/
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
claude: {
provider: 'claude',
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: true,
},
cursor: {
provider: 'cursor',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
},
codex: {
provider: 'codex',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
gemini: {
provider: 'gemini',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
opencode: {
provider: 'opencode',
permissionModes: ['default'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
};
/**
* Application service exposing the provider capability matrix.
*/
export const providerCapabilitiesService = {
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
return PROVIDER_CAPABILITIES[provider];
},
listAllProviderCapabilities(): ProviderCapabilities[] {
return Object.values(PROVIDER_CAPABILITIES);
},
};

View File

@@ -1,358 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { IProvider } from '@/shared/interfaces.js';
import type {
LLMProvider,
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
ProviderModelsResult,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
cachePath?: string;
activeModelChangesPath?: string;
now?: () => number;
};
type ProviderModelsOptions = {
bypassCache?: boolean;
};
type ProviderModelsCacheEntry = {
updatedAt: number;
expiresAt: number;
models: ProviderModelsDefinition;
};
type ProviderModelsCacheFile = {
version: number;
entries: Record<string, ProviderModelsCacheEntry>;
};
const getProviderModelsCachePath = (): string => path.join(
os.homedir(),
'.cloudcli',
'provider-models-cache.json',
);
const toProviderModelsCacheInfo = (
entry: ProviderModelsCacheEntry,
source: ProviderModelsCacheInfo['source'],
): ProviderModelsCacheInfo => ({
updatedAt: new Date(entry.updatedAt).toISOString(),
expiresAt: new Date(entry.expiresAt).toISOString(),
source,
});
const isProviderModelOption = (
value: unknown,
): value is ProviderModelsDefinition['OPTIONS'][number] => (
Boolean(value)
&& typeof value === 'object'
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
&& (
typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
|| typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
)
);
const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
Boolean(value)
&& typeof value === 'object'
&& Array.isArray((value as ProviderModelsDefinition).OPTIONS)
&& (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
&& typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
);
const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
Boolean(value)
&& typeof value === 'object'
&& typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
&& typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
&& isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
);
const readProviderModelsCacheFile = async (
cachePath: string,
): Promise<ProviderModelsCacheFile | null> => {
try {
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
return null;
}
const entries = Object.fromEntries(
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
isProviderModelsCacheEntry(entry[1]),
),
);
return {
version: PROVIDER_MODELS_CACHE_VERSION,
entries,
};
} catch {
return null;
}
};
const writeProviderModelsCacheFile = async (
cachePath: string,
entries: Map<LLMProvider, ProviderModelsCacheEntry>,
now: number,
): Promise<void> => {
const serializableEntries = Object.fromEntries(
[...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
);
const payload: ProviderModelsCacheFile = {
version: PROVIDER_MODELS_CACHE_VERSION,
entries: serializableEntries,
};
await mkdir(path.dirname(cachePath), { recursive: true });
await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
};
/**
* Provider model lookup service.
*
* Routes and other service callers use this layer instead of resolving provider
* classes directly so the provider-registry dependency stays centralized in one
* place.
*/
export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
const activeModelChangesPath = dependencies.activeModelChangesPath;
const now = dependencies.now ?? (() => Date.now());
const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
let persistedCacheLoaded = false;
let persistedCacheLoadPromise: Promise<void> | null = null;
const pruneExpiredMemoryEntry = (
provider: LLMProvider,
currentTime: number,
source: ProviderModelsCacheInfo['source'],
): ProviderModelsResult | null => {
const cachedEntry = memoryCache.get(provider);
if (!cachedEntry) {
return null;
}
if (cachedEntry.expiresAt > currentTime) {
return {
models: cachedEntry.models,
cache: toProviderModelsCacheInfo(cachedEntry, source),
};
}
memoryCache.delete(provider);
return null;
};
const loadPersistedCache = async (): Promise<void> => {
if (persistedCacheLoaded) {
return;
}
if (!persistedCacheLoadPromise) {
persistedCacheLoadPromise = (async () => {
const cacheFile = await readProviderModelsCacheFile(cachePath);
const currentTime = now();
for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
if (entry.expiresAt > currentTime) {
memoryCache.set(provider as LLMProvider, entry);
}
}
persistedCacheLoaded = true;
})().finally(() => {
persistedCacheLoadPromise = null;
});
}
await persistedCacheLoadPromise;
};
const persistCache = async (): Promise<void> => {
try {
await writeProviderModelsCacheFile(cachePath, memoryCache, now());
} catch (error) {
console.warn('Unable to persist provider models cache:', error);
}
};
const setCacheEntry = async (
provider: LLMProvider,
models: ProviderModelsDefinition,
): Promise<ProviderModelsCacheEntry> => {
const currentTime = now();
const entry: ProviderModelsCacheEntry = {
updatedAt: currentTime,
expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
models,
};
memoryCache.set(provider, entry);
await persistCache();
return entry;
};
const loadAndCacheModels = (
provider: LLMProvider,
): Promise<ProviderModelsResult> => {
const request = resolveProvider(provider).models.getSupportedModels()
.then(async (models) => {
const entry = await setCacheEntry(provider, models);
return {
models,
cache: toProviderModelsCacheInfo(entry, 'fresh'),
};
})
.finally(() => {
pendingRequests.delete(provider);
});
pendingRequests.set(provider, request);
return request;
};
const loadDirectModels = (
provider: LLMProvider,
): Promise<ProviderModelsResult> => {
const request = resolveProvider(provider).models.getSupportedModels()
.then((models) => {
const currentTime = now();
return {
models,
cache: {
updatedAt: new Date(currentTime).toISOString(),
expiresAt: new Date(currentTime).toISOString(),
source: 'fresh' as const,
},
};
})
.finally(() => {
pendingRequests.delete(provider);
});
pendingRequests.set(provider, request);
return request;
};
const getProviderModels = async (
provider: LLMProvider,
options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => {
if (UNCACHED_PROVIDERS.has(provider)) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadDirectModels(provider);
}
if (options.bypassCache) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadAndCacheModels(provider);
}
const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
if (cachedModels) {
return cachedModels;
}
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
await loadPersistedCache();
const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
if (persistedModels) {
return persistedModels;
}
const postLoadPendingRequest = pendingRequests.get(provider);
if (postLoadPendingRequest) {
return postLoadPendingRequest;
}
return loadAndCacheModels(provider);
};
const getCurrentActiveModel = async (
provider: LLMProvider,
sessionId?: string,
): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
const changeActiveModel = async (
provider: LLMProvider,
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> => resolveProvider(provider).models.changeActiveModel(input);
const getChangedActiveModel = async (
provider: LLMProvider,
sessionId: string,
): Promise<ProviderSessionActiveModelChange> => readProviderSessionActiveModelChange(provider, sessionId, {
filePath: activeModelChangesPath,
});
const resolveResumeModel = async (
provider: LLMProvider,
sessionId: string | undefined,
requestedModel?: string | null,
): Promise<string | undefined> => {
const normalizedRequestedModel = typeof requestedModel === 'string' ? requestedModel.trim() : '';
if (!sessionId?.trim()) {
return normalizedRequestedModel || undefined;
}
const changedModel = await getChangedActiveModel(provider, sessionId);
if (changedModel.supported && changedModel.changed && changedModel.model?.trim()) {
return changedModel.model.trim();
}
return normalizedRequestedModel || undefined;
};
const clearCache = (): void => {
memoryCache.clear();
pendingRequests.clear();
persistedCacheLoaded = false;
persistedCacheLoadPromise = null;
};
return {
getProviderModels,
getCurrentActiveModel,
getChangedActiveModel,
changeActiveModel,
resolveResumeModel,
clearCache,
};
};
export const providerModelsService = createProviderModelsService();

View File

@@ -22,7 +22,6 @@ export const sessionSynchronizerService = {
codex: 0, codex: 0,
cursor: 0, cursor: 0,
gemini: 0, gemini: 0,
opencode: 0,
}; };
const failures: string[] = []; const failures: string[] = [];

View File

@@ -4,11 +4,10 @@ import { promises as fsPromises } from 'node:fs';
import chokidar, { type FSWatcher } from 'chokidar'; import chokidar, { type FSWatcher } from 'chokidar';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { LLMProvider } from '@/shared/types.js'; import type { LLMProvider } from '@/shared/types.js';
import { generateDisplayName } from '@/modules/projects/index.js'; import { getProjectsWithSessions } from '@/modules/projects/index.js';
type WatcherEventType = 'add' | 'change'; type WatcherEventType = 'add' | 'change';
@@ -35,10 +34,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'gemini', provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'tmp'), rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
}, },
{
provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
},
]; ];
const WATCHER_IGNORED_PATTERNS = [ const WATCHER_IGNORED_PATTERNS = [
@@ -59,11 +54,6 @@ const watchers: FSWatcher[] = [];
type PendingWatcherUpdate = { type PendingWatcherUpdate = {
providers: Set<LLMProvider>; providers: Set<LLMProvider>;
changeTypes: Set<WatcherEventType>; changeTypes: Set<WatcherEventType>;
/**
* Provider-native session ids reported by the synchronizers. They are
* translated back to app-facing session rows at flush time, because the
* transcript file names on disk only ever contain provider ids.
*/
updatedSessionIds: Set<string>; updatedSessionIds: Set<string>;
}; };
@@ -77,10 +67,6 @@ let watcherRescheduleAfterRefresh = false;
* Filters watcher events to provider-specific session artifact file types. * Filters watcher events to provider-specific session artifact file types.
*/ */
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean { function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
if (provider === 'opencode') {
return path.basename(filePath) === 'opencode.db';
}
if (provider === 'gemini') { if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl'); return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
} }
@@ -137,50 +123,6 @@ function queuePendingWatcherUpdate(
schedulePendingWatcherFlush(); schedulePendingWatcherFlush();
} }
/**
* Builds one `session_upserted` delta event for a provider-native session id.
*
* The event carries everything a sidebar needs to upsert the session in place
* (session summary plus owning-project metadata), so clients never need a full
* project-list refetch when a transcript file changes on disk. Returns `null`
* when the id cannot be resolved to an indexed session row.
*/
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
?? sessionsDb.getSessionById(updatedProviderSessionId);
if (!row || row.isArchived) {
return null;
}
const projectPath = row.project_path;
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
const displayName = project?.custom_project_name?.trim()
? project.custom_project_name
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
return JSON.stringify({
kind: 'session_upserted',
sessionId: row.session_id,
provider: row.provider,
session: {
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
},
project: project
? {
projectId: project.project_id,
path: project.project_path,
fullPath: project.project_path,
displayName,
isStarred: Boolean(project.isStarred),
}
: null,
timestamp: new Date().toISOString(),
});
}
async function flushPendingWatcherUpdate(): Promise<void> { async function flushPendingWatcherUpdate(): Promise<void> {
clearPendingWatcherFlushTimer(); clearPendingWatcherFlushTimer();
@@ -199,29 +141,33 @@ async function flushPendingWatcherUpdate(): Promise<void> {
watcherRefreshInFlight = true; watcherRefreshInFlight = true;
try { try {
// Per-session deltas instead of full project snapshots: an upsert of one const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
// session can never clobber unrelated client state, so the frontend needs const changeTypes = Array.from(queuedUpdate.changeTypes);
// no "suppress updates while a run is active" protection logic. const watchProviders = Array.from(queuedUpdate.providers);
const events: string[] = []; const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
const event = await buildSessionUpsertedEvent(updatedSessionId);
if (event) {
events.push(event);
}
}
if (events.length > 0) { // Backward-compatible fields stay populated with the first queued values.
connectedClients.forEach(client => { const updateMessage = JSON.stringify({
if (client.readyState === WS_OPEN_STATE) { type: 'projects_updated',
for (const event of events) { projects: updatedProjects,
client.send(event); timestamp: new Date().toISOString(),
} changeType: changeTypes[0] ?? 'change',
} updatedSessionId: updatedSessionIds[0] ?? undefined,
}); watchProvider: watchProviders[0] ?? undefined,
} changeTypes,
updatedSessionIds,
watchProviders,
batched: true,
});
connectedClients.forEach(client => {
if (client.readyState === WS_OPEN_STATE) {
client.send(updateMessage);
}
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message }); console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
} finally { } finally {
watcherRefreshInFlight = false; watcherRefreshInFlight = false;

View File

@@ -1,9 +1,7 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { import type {
FetchHistoryOptions, FetchHistoryOptions,
@@ -13,12 +11,6 @@ import type {
} from '@/shared/types.js'; } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js'; import { AppError } from '@/shared/utils.js';
type CreateAppSessionResult = {
sessionId: string;
provider: LLMProvider;
projectPath: string;
};
type ArchivedSessionListItem = { type ArchivedSessionListItem = {
sessionId: string; sessionId: string;
provider: LLMProvider; provider: LLMProvider;
@@ -85,21 +77,6 @@ export const sessionsService = {
return providerRegistry.listProviders().map((provider) => provider.id); return providerRegistry.listProviders().map((provider) => provider.id);
}, },
/**
* Returns app-facing ids for provider runs that are currently processing.
*
* This is intentionally status-only: callers that only need sidebar activity
* indicators should not attach to chat streams or request replayed messages.
*/
listRunningSessions(): Array<{
sessionId: string;
provider: LLMProvider;
startedAt: number;
lastSeq: number;
}> {
return chatRunRegistry.listRunningRuns();
},
/** /**
* Normalizes one provider-native event into frontend session message events. * Normalizes one provider-native event into frontend session message events.
*/ */
@@ -112,43 +89,12 @@ export const sessionsService = {
}, },
/** /**
* Allocates a stable app-facing session id before any provider run happens. * Fetches persisted history by session id.
*
* This is the entry point of the session gateway: the frontend calls this
* (via `POST /api/providers/sessions`) when the user starts a brand-new
* chat, navigates to the returned id immediately, and the id never changes
* for the lifetime of the conversation. The provider-native id is mapped to
* this row later, when the provider runtime announces it mid-run.
*/
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
const normalizedProjectPath = projectPath.trim();
if (!normalizedProjectPath) {
throw new AppError('projectPath is required.', {
code: 'PROJECT_PATH_REQUIRED',
statusCode: 400,
});
}
const sessionId = randomUUID();
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
return {
sessionId,
provider,
projectPath: normalizedProjectPath,
};
},
/**
* Fetches persisted history by app session id.
* *
* Provider and provider-specific lookup hints are resolved from the indexed * Provider and provider-specific lookup hints are resolved from the indexed
* session metadata in the database. The provider adapter receives the * session metadata in the database.
* provider-native session id (the one written into transcripts on disk),
* and every returned message is remapped back to the app session id so
* provider ids never reach the frontend.
*/ */
async fetchHistory( fetchHistory(
sessionId: string, sessionId: string,
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {}, options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
): Promise<FetchHistoryResult> { ): Promise<FetchHistoryResult> {
@@ -160,33 +106,12 @@ export const sessionsService = {
}); });
} }
// App-created sessions that never produced a provider transcript yet
// (e.g. first message still streaming) simply have no history.
if (!session.provider_session_id) {
return {
messages: [],
total: 0,
hasMore: false,
offset: options.offset ?? 0,
limit: options.limit ?? null,
};
}
const provider = session.provider as LLMProvider; const provider = session.provider as LLMProvider;
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, { return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
limit: options.limit ?? null, limit: options.limit ?? null,
offset: options.offset ?? 0, offset: options.offset ?? 0,
projectPath: session.project_path ?? '', projectPath: session.project_path ?? '',
providerSessionId: session.provider_session_id,
}); });
return {
...result,
messages: result.messages.map((message) => ({
...message,
sessionId,
})),
};
}, },
/** /**

View File

@@ -2,7 +2,6 @@ import type {
IProvider, IProvider,
IProviderAuth, IProviderAuth,
IProviderMcp, IProviderMcp,
IProviderModels,
IProviderSessionSynchronizer, IProviderSessionSynchronizer,
IProviderSkills, IProviderSkills,
IProviderSessions, IProviderSessions,
@@ -18,7 +17,6 @@ import type { LLMProvider } from '@/shared/types.js';
*/ */
export abstract class AbstractProvider implements IProvider { export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider; readonly id: LLMProvider;
abstract readonly models: IProviderModels;
abstract readonly mcp: IProviderMcp; abstract readonly mcp: IProviderMcp;
abstract readonly auth: IProviderAuth; abstract readonly auth: IProviderAuth;
abstract readonly skills: IProviderSkills; abstract readonly skills: IProviderSkills;

View File

@@ -169,93 +169,6 @@ test('providerMcpService handles codex MCP TOML config and capability validation
} }
}); });
/**
* This test covers OpenCode MCP support for user/project config files, JSONC-compatible
* reads, and validation for unsupported scope/transport combinations.
*/
test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
await fs.writeFile(
path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
`{
// Existing comments should not block OpenCode MCP reads.
"mcp": {}
}\n`,
'utf8',
);
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: { API_KEY: 'x' },
});
await providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-project-http',
scope: 'project',
transport: 'http',
url: 'https://opencode.example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
const userServers = userConfig.mcp as Record<string, unknown>;
const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
assert.equal(userStdio.type, 'local');
assert.deepEqual(userStdio.command, ['node', 'server.js']);
assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
const projectServers = projectConfig.mcp as Record<string, unknown>;
const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
assert.equal(projectHttp.type, 'remote');
assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
await assert.rejects(
providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-local',
scope: 'local',
transport: 'stdio',
command: 'node',
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
error.statusCode === 400,
);
await assert.rejects(
providerMcpService.upsertProviderMcpServer('opencode', {
name: 'opencode-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/** /**
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence. * This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
*/ */
@@ -341,7 +254,8 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath, workspacePath,
}); });
assert.equal(globalResult.length, 5); const expectCursorGlobal = process.platform !== 'win32';
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
assert.ok(globalResult.every((entry) => entry.created === true)); assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -353,11 +267,10 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']); assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json')); if (expectCursorGlobal) {
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']); const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json')); }
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
await assert.rejects( await assert.rejects(
providerMcpService.addMcpServerToAllProviders({ providerMcpService.addMcpServerToAllProviders({

View File

@@ -1,73 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOpenCodeDefinitionFromIds,
parseOpenCodeModelsStdout,
} from '@/modules/providers/list/opencode/opencode-models.provider.js';
test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
const ids = parseOpenCodeModelsStdout(`
opencode/big-pickle
not a model
anthropic/claude-opus-4-7-fast
anthropic/claude-opus-4-7-fast
openai/gpt-5.5-pro
`);
assert.deepEqual(ids, [
'opencode/big-pickle',
'anthropic/claude-opus-4-7-fast',
'openai/gpt-5.5-pro',
]);
});
test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => {
const definition = buildOpenCodeDefinitionFromIds([
'opencode/deepseek-v4-flash-free',
'opencode/nemotron-3-super-free',
'anthropic/claude-3-5-sonnet-20241022',
'anthropic/claude-opus-4-7-fast',
'openai/gpt-5.4-mini-fast',
'openai/gpt-5.5-pro',
'newprovider/alpha-v12-special-20261231',
]);
assert.deepEqual(definition.OPTIONS, [
{
value: 'opencode/deepseek-v4-flash-free',
label: 'Deepseek V4 Flash Free',
description: 'opencode - opencode/deepseek-v4-flash-free',
},
{
value: 'opencode/nemotron-3-super-free',
label: 'Nemotron 3 Super Free',
description: 'opencode - opencode/nemotron-3-super-free',
},
{
value: 'anthropic/claude-3-5-sonnet-20241022',
label: 'Claude 3.5 Sonnet (2024-10-22)',
description: 'anthropic - anthropic/claude-3-5-sonnet-20241022',
},
{
value: 'anthropic/claude-opus-4-7-fast',
label: 'Claude Opus 4.7 Fast',
description: 'anthropic - anthropic/claude-opus-4-7-fast',
},
{
value: 'openai/gpt-5.4-mini-fast',
label: 'GPT-5.4 Mini Fast',
description: 'openai - openai/gpt-5.4-mini-fast',
},
{
value: 'openai/gpt-5.5-pro',
label: 'GPT-5.5 Pro',
description: 'openai - openai/gpt-5.5-pro',
},
{
value: 'newprovider/alpha-v12-special-20261231',
label: 'Alpha V12 Special (2026-12-31)',
description: 'newprovider - newprovider/alpha-v12-special-20261231',
},
]);
});

View File

@@ -1,334 +0,0 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import Database from 'better-sqlite3';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'opencode-provider-db-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): Promise<void> => {
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
await mkdir(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'opencode.db'));
try {
db.exec(`
CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL,
vcs TEXT,
name TEXT,
icon_url TEXT,
icon_color TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_initialized INTEGER,
sandboxes TEXT NOT NULL,
commands TEXT,
icon_url_override TEXT
);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER,
workspace_id TEXT,
path TEXT,
agent TEXT,
model TEXT,
cost REAL NOT NULL DEFAULT 0,
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE
);
CREATE INDEX part_session_idx ON part (session_id);
CREATE INDEX session_project_idx ON session (project_id);
CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
CREATE INDEX part_message_id_id_idx ON part (message_id, id);
`);
db.prepare(
'INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)',
).run(
'project-1',
workspacePath,
1_700_000_000_000,
1_700_000_001_000,
'[]',
);
db.prepare(`
INSERT INTO session (
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'open-session-1',
'project-1',
'open-session-1',
workspacePath,
'OpenCode indexed title',
'0.0.0',
1_700_000_000_000,
1_700_000_004_000,
null,
10,
20,
7,
3,
2,
);
const userMessageData = JSON.stringify({
role: 'user',
time: { created: 1_700_000_001_000 },
agent: 'test',
model: { providerID: 'anthropic', modelID: 'claude' },
});
const assistantMessageData = JSON.stringify({
role: 'assistant',
time: { created: 1_700_000_002_000, completed: 1_700_000_003_000 },
parentID: 'message-user',
modelID: 'anthropic/claude-sonnet-4-5',
providerID: 'anthropic',
mode: 'default',
agent: 'test',
path: { cwd: '.', root: '.' },
cost: 0.01,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 3, write: 2 },
},
});
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
).run('message-user', 'open-session-1', 1_700_000_001_000, 1_700_000_001_500, userMessageData);
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
).run('message-assistant', 'open-session-1', 1_700_000_002_000, 1_700_000_003_000, assistantMessageData);
const insertPart = db.prepare(`
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertPart.run(
'part-user-text',
'message-user',
'open-session-1',
1_700_000_001_000,
1_700_000_001_000,
JSON.stringify({
type: 'text',
text: JSON.stringify('Build the OpenCode integration.'),
}),
);
insertPart.run(
'part-reasoning',
'message-assistant',
'open-session-1',
1_700_000_002_000,
1_700_000_002_000,
JSON.stringify({
type: 'reasoning',
text: 'I will inspect the provider shape first.',
time: { start: 0, end: 1 },
}),
);
insertPart.run(
'part-assistant-text',
'message-assistant',
'open-session-1',
1_700_000_002_500,
1_700_000_002_500,
JSON.stringify({
type: 'text',
text: 'The provider is wired.',
}),
);
insertPart.run(
'part-tool',
'message-assistant',
'open-session-1',
1_700_000_003_000,
1_700_000_003_000,
JSON.stringify({
type: 'tool',
tool: 'bash',
callID: 'tool-call-1',
state: {
status: 'completed',
input: { command: 'npm test' },
output: 'ok',
title: 'bash',
metadata: {},
time: { start: 0, end: 1 },
},
}),
);
} finally {
db.close();
}
};
test('OpenCode session synchronizer indexes sqlite sessions without deletable transcript paths', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
const synchronizer = new OpenCodeSessionSynchronizer();
const processed = synchronizer.synchronize();
return Promise.resolve(processed).then((count) => {
assert.equal(count, 1);
const indexed = sessionsDb.getSessionById('open-session-1');
assert.equal(indexed?.provider, 'opencode');
assert.equal(indexed?.project_path, workspacePath);
assert.equal(indexed?.custom_name, 'OpenCode indexed title');
assert.equal(indexed?.jsonl_path, null);
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({
type: 'text',
sessionID: 'open-session-live',
text: JSON.stringify('hello bro'),
}, null);
assert.equal(normalized.length, 1);
assert.equal(normalized[0]?.kind, 'stream_delta');
assert.equal(normalized[0]?.content, 'hello bro');
const userEcho = provider.normalizeMessage({
type: 'text',
sessionID: 'open-session-live',
role: 'user',
text: 'hello bro',
}, null);
assert.deepEqual(userEcho, []);
});
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
const provider = new OpenCodeSessionsProvider();
const history = await provider.fetchHistory('open-session-1');
assert.equal(history.total, 4);
assert.equal(history.messages[0]?.kind, 'text');
assert.equal(history.messages[0]?.role, 'user');
assert.equal(history.messages[0]?.content, 'Build the OpenCode integration.');
assert.equal(history.messages[1]?.kind, 'thinking');
assert.equal(history.messages[2]?.content, 'The provider is wired.');
assert.equal(history.messages[3]?.kind, 'tool_use');
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
assert.deepEqual(history.tokenUsage, {
used: 42,
inputTokens: 13,
outputTokens: 20,
breakdown: {
input: 13,
output: 20,
},
});
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
assert.equal(paged.messages.length, 2);
assert.equal(paged.hasMore, true);
assert.equal(paged.messages[0]?.content, 'The provider is wired.');
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -1,349 +0,0 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
createProviderModelsService,
PROVIDER_MODELS_CACHE_TTL_MS,
} from '@/modules/providers/services/provider-models.service.js';
import type {
ProviderChangeActiveModelInput,
LLMProvider,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import { writeProviderSessionActiveModelChange } from '@/shared/utils.js';
const createModels = (value: string): ProviderModelsDefinition => ({
OPTIONS: [{ value, label: value }],
DEFAULT: value,
});
const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({
model,
});
const createSessionActiveModelChange = (
provider: LLMProvider,
input: ProviderChangeActiveModelInput,
): ProviderSessionActiveModelChange => ({
provider,
sessionId: input.sessionId,
supported: true,
changed: true,
model: input.model,
});
const createEphemeralCachePath = (): string => path.join(
os.tmpdir(),
`provider-model-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
test('provider models service delegates to the resolved provider model adapter', async () => {
const calls: LLMProvider[] = [];
const service = createProviderModelsService({
cachePath: createEphemeralCachePath(),
resolveProvider: (provider) => {
calls.push(provider);
return {
models: {
getSupportedModels: async () => createModels(`${provider}-models`),
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
};
},
});
const models = await service.getProviderModels('codex', { bypassCache: true });
assert.deepEqual(calls, ['codex']);
assert.equal(models.models.DEFAULT, 'codex-models');
assert.equal(models.cache.source, 'fresh');
});
test('provider models service returns each provider adapter result without rewriting it', async () => {
const expectedModels: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'cursor-a', label: 'Cursor A' },
{ value: 'cursor-b', label: 'Cursor B' },
],
DEFAULT: 'cursor-b',
};
const service = createProviderModelsService({
cachePath: createEphemeralCachePath(),
resolveProvider: () => ({
models: {
getSupportedModels: async () => expectedModels,
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
},
}),
});
const models = await service.getProviderModels('cursor', { bypassCache: true });
assert.deepEqual(models.models, expectedModels);
});
test('provider models are cached for the three-day ttl', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-'));
let currentTime = 1_000;
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
now: () => currentTime,
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('codex');
const cached = await service.getProviderModels('codex');
assert.equal(loadCount, 1);
assert.equal(cached.models.DEFAULT, first.models.DEFAULT);
assert.equal(cached.cache.source, 'memory');
currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1;
await service.getProviderModels('codex');
assert.equal(loadCount, 1);
currentTime += 2;
const refreshed = await service.getProviderModels('codex');
assert.equal(loadCount, 2);
assert.equal(refreshed.models.DEFAULT, 'codex-2');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('claude provider models are always loaded directly from the provider', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('claude');
const second = await service.getProviderModels('claude');
assert.equal(loadCount, 2);
assert.equal(first.models.DEFAULT, 'claude-1');
assert.equal(second.models.DEFAULT, 'claude-2');
assert.equal(second.cache.source, 'fresh');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('provider model cache is persisted across service instances', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
const cachePath = path.join(tempRoot, 'models-cache.json');
try {
const writer = createProviderModelsService({
cachePath,
resolveProvider: () => ({
models: {
getSupportedModels: async () => createModels('gemini-cached'),
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
},
}),
});
await writer.getProviderModels('gemini');
const reader = createProviderModelsService({
cachePath,
resolveProvider: () => ({
models: {
getSupportedModels: async () => {
throw new Error('loader should not be called for persisted cache hits');
},
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
},
}),
});
const models = await reader.getProviderModels('gemini');
assert.equal(models.models.DEFAULT, 'gemini-cached');
assert.equal(models.cache.source, 'disk');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('concurrent provider model requests share one load operation', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-pending-'));
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
resolveProvider: () => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return createModels('claude-cached');
},
getCurrentActiveModel: async () => createCurrentActiveModel('claude-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('claude', input),
},
}),
});
const [first, second] = await Promise.all([
service.getProviderModels('claude'),
service.getProviderModels('claude'),
]);
assert.equal(loadCount, 1);
assert.equal(first.models.DEFAULT, 'claude-cached');
assert.equal(second.models.DEFAULT, 'claude-cached');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-'));
let currentTime = 1_000;
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
now: () => currentTime,
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active-${loadCount}`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('claude');
currentTime += 50;
const refreshed = await service.getProviderModels('claude', { bypassCache: true });
assert.equal(first.models.DEFAULT, 'claude-1');
assert.equal(refreshed.models.DEFAULT, 'claude-2');
assert.equal(refreshed.cache.source, 'fresh');
assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt);
assert.equal(loadCount, 2);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('provider models service delegates current active model lookups to the provider adapter', async () => {
const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
const service = createProviderModelsService({
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => createModels(`${provider}-models`),
getCurrentActiveModel: async (sessionId) => {
calls.push({ provider, sessionId });
return createCurrentActiveModel(`${provider}-${sessionId}`);
},
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const activeModel = await service.getCurrentActiveModel('opencode', 'session-123');
assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
assert.equal(activeModel.model, 'opencode-session-123');
});
test('provider models service delegates active model change requests to the provider adapter', async () => {
const calls: Array<{ provider: LLMProvider; input: ProviderChangeActiveModelInput }> = [];
const service = createProviderModelsService({
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => createModels(`${provider}-models`),
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => {
calls.push({ provider, input });
return createSessionActiveModelChange(provider, input);
},
},
}),
});
const changedModel = await service.changeActiveModel('claude', {
sessionId: 'session-123',
model: 'opus',
});
assert.deepEqual(calls, [{
provider: 'claude',
input: {
sessionId: 'session-123',
model: 'opus',
},
}]);
assert.equal(changedModel.changed, true);
assert.equal(changedModel.model, 'opus');
});
test('resolveResumeModel prefers a stored changed model over the requested one', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-change-'));
const activeModelChangesPath = path.join(tempRoot, 'session-model-changes.json');
try {
const service = createProviderModelsService({
activeModelChangesPath,
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => createModels(`${provider}-models`),
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
await writeProviderSessionActiveModelChange('cursor', {
sessionId: 'session-456',
model: 'composer-2',
}, {
filePath: activeModelChangesPath,
});
const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
assert.equal(model, 'composer-2');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -377,72 +377,6 @@ test('providerSkillsService lists codex repository, user, and system skills', {
} }
}); });
/**
* This test covers OpenCode skill lookup across cwd-to-git-root project folders
* plus the global OpenCode/Claude/Agents compatibility locations.
*/
test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
const repoRoot = path.join(tempRoot, 'repo');
const workspacePath = path.join(repoRoot, 'packages', 'app');
await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await writeSkill(
path.join(workspacePath, '.opencode', 'skills'),
'opencode-cwd-dir',
'opencode-cwd',
'OpenCode cwd skill',
);
await writeSkill(
path.join(repoRoot, 'packages', '.claude', 'skills'),
'opencode-claude-parent-dir',
'opencode-claude-parent',
'OpenCode Claude parent skill',
);
await writeSkill(
path.join(repoRoot, '.agents', 'skills'),
'opencode-agents-root-dir',
'opencode-agents-root',
'OpenCode Agents root skill',
);
await writeSkill(
path.join(tempRoot, '.config', 'opencode', 'skills'),
'opencode-user-dir',
'opencode-user',
'OpenCode user skill',
);
await writeSkill(
path.join(tempRoot, '.claude', 'skills'),
'opencode-claude-user-dir',
'opencode-claude-user',
'OpenCode Claude user skill',
);
await writeSkill(
path.join(tempRoot, '.agents', 'skills'),
'opencode-agents-user-dir',
'opencode-agents-user',
'OpenCode Agents user skill',
);
const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
const byName = new Map(skills.map((skill) => [skill.name, skill]));
assert.equal(byName.get('opencode-cwd')?.scope, 'project');
assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
assert.equal(byName.get('opencode-user')?.scope, 'user');
assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/** /**
* This test covers Gemini and Cursor skill directory rules, including shared * This test covers Gemini and Cursor skill directory rules, including shared
* `.agents/skills` project support. * `.agents/skills` project support.

View File

@@ -33,12 +33,10 @@ Benefits:
|---|---| |---|---|
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname | | `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` | | `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) | | `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` |
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection | | `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket | | `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers | | `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant | | `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
## High-Level Architecture ## High-Level Architecture
@@ -54,12 +52,12 @@ flowchart LR
D -->|other| H[close()] D -->|other| H[close()]
E --> I[connectedClients Set] E --> I[connectedClients Set]
E --> J[chatRunRegistry + ChatSessionWriter] E --> J[WebSocketWriter]
F --> K[ptySessionsMap] F --> K[ptySessionsMap]
G --> L[Upstream Plugin ws://127.0.0.1:port/ws] G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
I --> M[projects.service loading_progress] I --> M[projects.service broadcastProgress]
I --> N[sessions-watcher.service session_upserted] I --> N[sessions-watcher.service projects_updated]
``` ```
## Connection Handshake + Routing ## Connection Handshake + Routing
@@ -107,41 +105,37 @@ sequenceDiagram
When a chat socket connects: When a chat socket connects:
1. Add socket to `connectedClients`. 1. Add socket to `connectedClients`.
2. Parse each incoming message with `parseIncomingJsonObject`. 2. Build `WebSocketWriter` (captures `userId` from authenticated request).
3. Dispatch by `data.type` (four message types, none provider-specific). 3. Parse each incoming message with `parseIncomingJsonObject`.
4. On close, remove socket from `connectedClients`. 4. Dispatch by `data.type`.
5. On close, remove socket from `connectedClients`.
### Session identity model
The frontend only ever knows the **app session id** (allocated by
`POST /api/providers/sessions` or discovered via the session index). The
provider-native id (JSONL file name, CLI resume id) stays inside the backend:
1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB.
2. The provider runtime receives the provider-native id for resume.
3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them.
### Chat Message Dispatch ### Chat Message Dispatch
```mermaid ```mermaid
flowchart TD flowchart TD
A[Incoming WS message] --> B[parseIncomingJsonObject] A[Incoming WS message] --> B[parseIncomingJsonObject]
B -->|invalid| C[send kind:protocol_error] B -->|invalid| C[send {type:error}]
B -->|ok| D{data.type} B -->|ok| D{data.type}
D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider] D -->|claude-command| E[queryClaudeSDK]
D -->|chat.abort| F[abortFns provider + synthetic complete] D -->|cursor-command| F[spawnCursor]
D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq] D -->|codex-command| G[queryCodex]
D -->|chat.permission-response| H[resolveToolApproval] D -->|gemini-command| H[spawnGemini]
D -->|other| I[send kind:protocol_error] D -->|cursor-resume| I[spawnCursor resume]
D -->|abort-session| J[abort by provider]
D -->|claude-permission-response| K[resolveToolApproval]
D -->|cursor-abort| L[abortCursorSession]
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
D -->|get-active-sessions| O[getActive*Sessions]
``` ```
### Chat Notes ### Chat Notes
1. **Unified envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol. 1. `abort-session` returns a normalized `complete` message with `aborted: true`.
2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes. 2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST. 3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`).
## `/shell` Terminal Flow ## `/shell` Terminal Flow
@@ -229,9 +223,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`.
That shared set is consumed by: That shared set is consumed by:
1. `modules/projects/services/projects-with-sessions-fetch.service.ts` 1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
Broadcasts `kind: loading_progress` while project snapshots are being built. Broadcasts `loading_progress` while project snapshots are being built.
2. `modules/providers/services/sessions-watcher.service.ts` 2. `modules/providers/services/sessions-watcher.service.ts`
Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots). Broadcasts `projects_updated` when provider session artifacts change.
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals. This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
@@ -258,7 +252,7 @@ Current explicit close codes in this module:
Other errors: Other errors:
1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`. 1. Chat handler catches and emits `{ type: "error", error }`.
2. Shell handler catches and writes terminal-visible error output. 2. Shell handler catches and writes terminal-visible error output.
3. Unknown websocket paths are closed immediately. 3. Unknown websocket paths are closed immediately.

View File

@@ -1,3 +1,2 @@
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js'; export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
export { createWebSocketServer } from './services/websocket-server.service.js'; export { createWebSocketServer } from './services/websocket-server.service.js';
export { chatRunRegistry } from './services/chat-run-registry.service.js';

View File

@@ -1,273 +0,0 @@
import { sessionsDb } from '@/modules/database/index.js';
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
import type {
LLMProvider,
NormalizedMessage,
RealtimeClientConnection,
} from '@/shared/types.js';
type ChatRunStatus = 'running' | 'completed';
/**
* One live (or recently finished) provider run for a single app session.
*
* State notes — why each mutable field is essential:
* - `providerSessionId`: the provider-native id captured mid-run. The abort
* handler needs it to address the provider runtime, and the DB mapping is
* written from it so history/resume work after the run.
* - `status`: drives `chat_subscribed.isProcessing`, prevents double sends
* into the same session, and guards the synthetic-complete fallback in the
* chat handler (only emitted when a runtime died without completing).
* - `lastSeq` / `events`: the per-run event log. Every live event gets a
* monotonically increasing `seq` and is buffered so a reconnecting client
* can replay exactly the events it missed via `chat.subscribe`.
*/
type ChatRun = {
appSessionId: string;
provider: LLMProvider;
providerSessionId: string | null;
status: ChatRunStatus;
lastSeq: number;
events: NormalizedMessage[];
writer: ChatSessionWriter;
startedAt: number;
completedAt: number | null;
};
/**
* How long a completed run stays available for replay. Covers the window
* between a run finishing and the client refreshing history over REST (for
* example when the browser tab was asleep while the run completed).
*/
const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000;
/**
* Upper bound on buffered events per run so a very long tool-heavy run cannot
* grow memory unbounded. When exceeded, the oldest events are dropped —
* a reconnecting client whose `lastSeq` predates the buffer falls back to a
* REST history refresh, which is always the authoritative source.
*/
const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
/**
* Active and recently-completed runs keyed by app session id.
*
* This map is the single in-memory source of truth for "is something running
* for this session" — the chat websocket handler, abort path, and subscribe
* path all consult it instead of asking each provider runtime individually.
*/
const runs = new Map<string, ChatRun>();
function evictRunLater(appSessionId: string): void {
const timer = setTimeout(() => {
const run = runs.get(appSessionId);
if (run && run.status === 'completed') {
runs.delete(appSessionId);
}
}, COMPLETED_RUN_RETENTION_MS);
// Never keep the process alive just to evict a buffered run.
timer.unref?.();
}
/**
* Decorates one outbound live event for a run and records it in the event log.
*
* Responsibilities:
* 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable
* app session id — provider-native ids never leave the backend.
* 2. Assign the next `seq` so clients can detect/replay gaps.
* 3. Buffer the event for `chat.subscribe` replay.
* 4. Flip the run to `completed` when the terminal `complete` event passes by.
*/
function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null {
// Exactly-one-complete contract: when a run is aborted the chat handler
// emits the terminal `complete` immediately, but the killed runtime may
// still emit its own `complete` from its exit handler moments later.
// Whichever arrives first wins; the duplicate is dropped here.
if (message.kind === 'complete' && run.status === 'completed') {
return null;
}
run.lastSeq += 1;
const outbound: NormalizedMessage = {
...message,
sessionId: run.appSessionId,
seq: run.lastSeq,
};
if (message.kind === 'complete') {
// The provider may report its own id here; the frontend only ever knows
// the app id, so the "actual" id is by definition the app id as well.
outbound.actualSessionId = run.appSessionId;
run.status = 'completed';
run.completedAt = Date.now();
evictRunLater(run.appSessionId);
}
run.events.push(outbound);
if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) {
run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN);
}
return outbound;
}
/**
* Records the provider-native session id for a run and persists the
* app-id-to-provider-id mapping so history fetches and future resumes can
* address the provider transcript.
*
* Called from the gateway writer when the runtime either calls
* `setSessionId(...)` or emits its `session_created` event — whichever
* happens first wins; later calls with the same id are no-ops.
*/
function recordProviderSessionId(run: ChatRun, providerSessionId: string): void {
if (!providerSessionId || run.providerSessionId === providerSessionId) {
return;
}
run.providerSessionId = providerSessionId;
try {
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {
appSessionId: run.appSessionId,
providerSessionId,
error: message,
});
}
}
/**
* Registry of live provider runs keyed by the stable app session id.
*
* The registry is what makes the websocket protocol provider-independent:
* every run gets a `ChatSessionWriter` that remaps provider-native session
* ids to the app id, assigns `seq` numbers, and buffers events for replay —
* regardless of which provider runtime produced them.
*/
export const chatRunRegistry = {
/**
* Starts tracking a run and returns it, or `null` when a run is already in
* progress for the session (callers must reject the duplicate send).
*/
startRun(input: {
appSessionId: string;
provider: LLMProvider;
providerSessionId: string | null;
connection: RealtimeClientConnection;
userId: string | number | null;
}): ChatRun | null {
const existing = runs.get(input.appSessionId);
if (existing && existing.status === 'running') {
return null;
}
const run: ChatRun = {
appSessionId: input.appSessionId,
provider: input.provider,
providerSessionId: input.providerSessionId,
status: 'running',
lastSeq: 0,
events: [],
writer: null as unknown as ChatSessionWriter,
startedAt: Date.now(),
completedAt: null,
};
run.writer = new ChatSessionWriter({
connection: input.connection,
userId: input.userId,
provider: input.provider,
providerSessionId: input.providerSessionId,
onProviderSessionId: (providerSessionId) => {
recordProviderSessionId(run, providerSessionId);
},
decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message),
});
runs.set(input.appSessionId, run);
return run;
},
getRun(appSessionId: string): ChatRun | undefined {
return runs.get(appSessionId);
},
isProcessing(appSessionId: string): boolean {
return runs.get(appSessionId)?.status === 'running';
},
listRunningRuns(): Array<{
sessionId: string;
provider: LLMProvider;
startedAt: number;
lastSeq: number;
}> {
return Array.from(runs.values())
.filter((run) => run.status === 'running')
.map((run) => ({
sessionId: run.appSessionId,
provider: run.provider,
startedAt: run.startedAt,
lastSeq: run.lastSeq,
}));
},
/**
* Re-attaches a run's outbound stream to a (new) websocket connection.
*
* This is the generic replacement for the Claude-only writer reconnect:
* after a page refresh the new socket subscribes and immediately starts
* receiving the still-running stream, for every provider.
*/
attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean {
const run = runs.get(appSessionId);
if (!run) {
return false;
}
run.writer.updateWebSocket(connection);
return true;
},
/**
* Returns buffered events with `seq` greater than `afterSeq` for replay.
*
* An empty array with `run.lastSeq > afterSeq` not covered by the buffer
* means the buffer was truncated; the client should refresh over REST.
*/
replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] {
const run = runs.get(appSessionId);
if (!run) {
return [];
}
return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq);
},
/**
* Emits a synthetic terminal `complete` if (and only if) the run is still
* marked running. Used when a provider runtime throws or resolves without
* having produced its own terminal event, and by the abort path.
*/
completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void {
const run = runs.get(appSessionId);
if (!run || run.status !== 'running') {
return;
}
run.writer.sendComplete(opts);
},
/**
* Test-only escape hatch: clears every tracked run.
*/
clearAll(): void {
runs.clear();
},
};

View File

@@ -1,145 +0,0 @@
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import type {
LLMProvider,
NormalizedMessage,
RealtimeClientConnection,
} from '@/shared/types.js';
import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js';
type ChatSessionWriterOptions = {
connection: RealtimeClientConnection;
userId: string | number | null;
provider: LLMProvider;
/** Provider-native id when resuming an existing session, otherwise null. */
providerSessionId: string | null;
/**
* Invoked the moment the provider runtime reveals its native session id
* (either via `setSessionId` or a `session_created` event). The registry
* persists the app-id-to-provider-id mapping from this callback.
*/
onProviderSessionId: (providerSessionId: string) => void;
/**
* Remaps/sequences/buffers one outbound live event. Implemented by the chat
* run registry; the writer never forwards a provider event untouched.
* Returns `null` when the event must be dropped (duplicate terminal
* `complete` after an abort already completed the run).
*/
decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null;
};
/**
* Gateway writer handed to provider runtimes instead of a raw websocket writer.
*
* It exposes the exact same surface as `WebSocketWriter` (`send`,
* `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`,
* `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`,
* `cursor-cli.js`, ...) need zero changes — but everything that flows through
* it is translated from the provider's world into the app's protocol:
*
* - `session_created` events are swallowed and turned into a provider-id
* mapping; the frontend never learns provider-native ids.
* - every other event gets `sessionId` remapped to the app session id and a
* per-run `seq` assigned before being forwarded.
* - `setSessionId(...)` calls (used by runtimes to label captured ids) are
* intercepted and recorded as the provider-id mapping as well.
*/
export class ChatSessionWriter {
ws: RealtimeClientConnection;
userId: string | number | null;
/**
* Some runtimes feature-detect their writer with this flag; keep it so the
* gateway writer is a drop-in replacement for `WebSocketWriter`.
*/
isWebSocketWriter = true;
private readonly options: ChatSessionWriterOptions;
/**
* The provider-native session id as the runtime knows it. Kept locally
* (besides the registry) because runtimes read it back via `getSessionId()`
* to label their own outgoing events — those labels are remapped on send
* anyway, but the runtime-visible value must stay provider-native.
*/
private providerSessionId: string | null;
constructor(options: ChatSessionWriterOptions) {
this.options = options;
this.ws = options.connection;
this.userId = options.userId;
this.providerSessionId = options.providerSessionId;
}
send(data: unknown): void {
const record = readObjectRecord(data);
if (!record || typeof record.kind !== 'string') {
// Provider runtimes only emit kind-based normalized messages. Anything
// else indicates a programming error; drop it rather than leaking an
// un-remapped payload to the client.
console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data);
return;
}
const message = record as NormalizedMessage;
if (message.kind === 'session_created') {
const announcedId =
typeof message.newSessionId === 'string' && message.newSessionId
? message.newSessionId
: message.sessionId;
if (announcedId) {
this.captureProviderSessionId(announcedId);
}
// Swallowed on purpose: the frontend already has the stable app session
// id, so there is no client-side handoff to perform anymore.
return;
}
const outbound = this.options.decorateOutboundEvent(message);
if (outbound) {
this.forward(outbound);
}
}
/**
* Emits the synthetic terminal `complete` for runs that ended without one
* (runtime crash before completing, or user abort).
*/
sendComplete(opts: { exitCode: number; aborted?: boolean }): void {
const message = createCompleteMessage({
provider: this.options.provider,
sessionId: this.providerSessionId,
exitCode: opts.exitCode,
aborted: opts.aborted,
});
const outbound = this.options.decorateOutboundEvent(message);
if (outbound) {
this.forward(outbound);
}
}
updateWebSocket(newConnection: RealtimeClientConnection): void {
this.ws = newConnection;
}
setSessionId(sessionId: string): void {
this.captureProviderSessionId(sessionId);
}
getSessionId(): string | null {
return this.providerSessionId;
}
private captureProviderSessionId(providerSessionId: string): void {
if (!providerSessionId || this.providerSessionId === providerSessionId) {
return;
}
this.providerSessionId = providerSessionId;
this.options.onProviderSessionId(providerSessionId);
}
private forward(message: NormalizedMessage): void {
if (this.ws.readyState === WS_OPEN_STATE) {
this.ws.send(JSON.stringify(message));
}
}
}

View File

@@ -1,35 +1,38 @@
import type { WebSocket } from 'ws'; import type { WebSocket } from 'ws';
import { sessionsDb } from '@/modules/database/index.js'; import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
import type { import type {
AnyRecord, AnyRecord,
AuthenticatedWebSocketRequest, AuthenticatedWebSocketRequest,
LLMProvider, LLMProvider,
} from '@/shared/types.js'; } from '@/shared/types.js';
import { parseIncomingJsonObject } from '@/shared/utils.js'; import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
/** type ChatIncomingMessage = AnyRecord & {
* One provider runtime entry point. All five runtimes share this signature, type?: string;
* which lets the chat handler dispatch through a provider-keyed map instead command?: string;
* of provider-specific branches. options?: AnyRecord;
*/ provider?: string;
type ProviderSpawnFn = ( sessionId?: string;
command: string, requestId?: string;
options: AnyRecord, allow?: unknown;
writer: unknown updatedInput?: unknown;
) => Promise<unknown>; message?: unknown;
rememberEntry?: unknown;
};
const DEFAULT_PROVIDER: LLMProvider = 'claude';
type ChatWebSocketDependencies = { type ChatWebSocketDependencies = {
/** Provider runtimes keyed by provider id. */ queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
spawnFns: Record<LLMProvider, ProviderSpawnFn>; spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
/** queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
* Abort functions keyed by provider id. They are addressed with the spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
* provider-native session id (that is how runtimes key their process maps). abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
* The Claude abort is async; the rest are sync — both shapes are accepted. abortCursorSession: (sessionId: string) => boolean;
*/ abortCodexSession: (sessionId: string) => boolean;
abortFns: Record<LLMProvider, (providerSessionId: string) => boolean | Promise<boolean>>; abortGeminiSession: (sessionId: string) => boolean;
resolveToolApproval: ( resolveToolApproval: (
requestId: string, requestId: string,
payload: { payload: {
@@ -39,10 +42,29 @@ type ChatWebSocketDependencies = {
rememberEntry?: unknown; rememberEntry?: unknown;
} }
) => void; ) => void;
/** Claude-only today: pending tool approvals included in `chat_subscribed`. */ isClaudeSDKSessionActive: (sessionId: string) => boolean;
getPendingApprovalsForSession: (providerSessionId: string) => unknown[]; isCursorSessionActive: (sessionId: string) => boolean;
isCodexSessionActive: (sessionId: string) => boolean;
isGeminiSessionActive: (sessionId: string) => boolean;
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
getPendingApprovalsForSession: (sessionId: string) => unknown[];
getActiveClaudeSDKSessions: () => unknown;
getActiveCursorSessions: () => unknown;
getActiveCodexSessions: () => unknown;
getActiveGeminiSessions: () => unknown;
}; };
/**
* Normalizes potentially invalid provider names coming from websocket payloads.
*/
function readProvider(value: unknown): LLMProvider {
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
return value;
}
return DEFAULT_PROVIDER;
}
/** /**
* Extracts the authenticated request user id in the formats currently produced * Extracts the authenticated request user id in the formats currently produced
* by platform and OSS auth code paths. * by platform and OSS auth code paths.
@@ -66,258 +88,8 @@ function readRequestUserId(
return null; return null;
} }
function sendJson(ws: WebSocket, payload: unknown): void {
if (ws.readyState === WS_OPEN_STATE) {
ws.send(JSON.stringify(payload));
}
}
/**
* Reports a protocol-level failure to the requesting client.
*
* Protocol errors deliberately use their own `kind` (instead of the provider
* `error` message kind) so the frontend can distinguish "your request was
* invalid" from "the model run produced an error" without inspecting text.
*/
function sendProtocolError(
ws: WebSocket,
code: string,
error: string,
sessionId?: string
): void {
sendJson(ws, {
kind: 'protocol_error',
code,
error,
sessionId: sessionId ?? null,
timestamp: new Date().toISOString(),
});
}
function readRequiredSessionId(data: AnyRecord): string | null {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : '';
return sessionId.length > 0 ? sessionId : null;
}
/**
* Handles `chat.send`: resolves the session row (provider, project path, and
* provider-native id all come from the database — never from the client),
* registers the run, and dispatches to the provider runtime.
*/
async function handleChatSend(
ws: WebSocket,
userId: string | number | null,
data: AnyRecord,
dependencies: ChatWebSocketDependencies
): Promise<void> {
const sessionId = readRequiredSessionId(data);
if (!sessionId) {
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.');
return;
}
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
sendProtocolError(
ws,
'SESSION_NOT_FOUND',
`Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`,
sessionId
);
return;
}
const provider = session.provider as LLMProvider;
const spawnFn = dependencies.spawnFns[provider];
if (!spawnFn) {
sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId);
return;
}
const run = chatRunRegistry.startRun({
appSessionId: sessionId,
provider,
providerSessionId: session.provider_session_id,
connection: ws,
userId,
});
if (!run) {
sendProtocolError(
ws,
'RUN_IN_PROGRESS',
`Session "${sessionId}" already has a run in progress.`,
sessionId
);
return;
}
const clientOptions = (data.options ?? {}) as AnyRecord;
const command = typeof data.content === 'string' ? data.content : '';
// The provider runtimes receive the provider-native session id (that is the
// id their CLI/SDK understands for resume). Brand-new sessions have no
// provider id yet, so the runtime starts fresh and announces one, which the
// gateway writer captures and maps back to the app session id.
const runtimeOptions: AnyRecord = {
...clientOptions,
sessionId: session.provider_session_id ?? undefined,
resume: Boolean(session.provider_session_id),
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
projectPath: session.project_path ?? clientOptions.projectPath,
};
try {
await spawnFn(command, runtimeOptions, run.writer);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message });
} finally {
// Safety net: a runtime that crashed (or resolved) without emitting its
// terminal `complete` would otherwise leave the session stuck in
// "processing" forever on every connected client.
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
}
}
/**
* Handles `chat.abort`: cancels the run for one app session and emits the
* terminal `complete` on its behalf (runtimes skip their own complete for
* aborted runs, and the registry drops any duplicate).
*/
async function handleChatAbort(
ws: WebSocket,
data: AnyRecord,
dependencies: ChatWebSocketDependencies
): Promise<void> {
const sessionId = readRequiredSessionId(data);
if (!sessionId) {
sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.');
return;
}
const run = chatRunRegistry.getRun(sessionId);
if (!run || run.status !== 'running') {
sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId);
return;
}
const abortFn = dependencies.abortFns[run.provider];
let success = false;
if (abortFn && run.providerSessionId) {
success = Boolean(await abortFn(run.providerSessionId));
}
chatRunRegistry.completeRun(sessionId, {
exitCode: success ? 0 : 1,
aborted: true,
});
}
/**
* Handles `chat.subscribe`: for each requested session, reports whether a run
* is processing, re-attaches the live stream to this socket, replays missed
* events (seq > lastSeq), and includes pending permission requests.
*
* This single message replaces the old `check-session-status`,
* `get-pending-permissions`, and Claude-only writer reconnect flows.
*/
function handleChatSubscribe(
ws: WebSocket,
data: AnyRecord,
dependencies: ChatWebSocketDependencies
): void {
const targets = Array.isArray(data.sessions) ? data.sessions : [];
for (const target of targets) {
if (!target || typeof target !== 'object') {
continue;
}
const sessionId = typeof (target as AnyRecord).sessionId === 'string'
? ((target as AnyRecord).sessionId as string).trim()
: '';
if (!sessionId) {
continue;
}
const lastSeqRaw = (target as AnyRecord).lastSeq;
const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw)
? Math.max(0, Math.floor(lastSeqRaw))
: 0;
const run = chatRunRegistry.getRun(sessionId);
const isProcessing = chatRunRegistry.isProcessing(sessionId);
// Future live events for this run should land on the socket that asked —
// this is what makes mid-stream page refreshes work for all providers.
if (isProcessing) {
chatRunRegistry.attachConnection(sessionId, ws);
}
// Pending approvals are tracked under the provider-native id inside the
// Claude runtime; remap their sessionId so the client only sees app ids.
const pendingPermissions = (run?.providerSessionId
? dependencies.getPendingApprovalsForSession(run.providerSessionId)
: []
).map((approval) =>
approval && typeof approval === 'object'
? { ...(approval as AnyRecord), sessionId }
: approval,
);
sendJson(ws, {
kind: 'chat_subscribed',
sessionId,
isProcessing,
lastSeq: run?.lastSeq ?? 0,
pendingPermissions,
timestamp: new Date().toISOString(),
});
// Replay only for RUNNING runs, strictly after the ack. Completed runs
// are fully persisted to the provider transcript and served over REST —
// replaying them (e.g. after a page reload where the client's lastSeq is
// 0) would duplicate messages the history fetch already returned.
if (isProcessing) {
for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) {
sendJson(ws, event);
}
}
}
}
/**
* Handles `chat.permission-response`: forwards a tool-approval decision to the
* pending approval resolver (Claude is the only provider with interactive
* approvals today, but the message is intentionally provider-neutral).
*/
function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void {
if (typeof data.requestId !== 'string' || data.requestId.length === 0) {
return;
}
dependencies.resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: typeof data.message === 'string' ? data.message : undefined,
rememberEntry: data.rememberEntry,
});
}
/** /**
* Handles authenticated chat websocket messages used by the main chat panel. * Handles authenticated chat websocket messages used by the main chat panel.
*
* Inbound protocol (client to server):
* - `chat.send` { sessionId, content, options? }
* - `chat.abort` { sessionId }
* - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] }
* - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? }
*
* Outbound protocol (server to client): every frame is `kind`-based — either
* a provider `NormalizedMessage` (with `seq`) or a gateway event
* (`chat_subscribed`, `session_upserted`, `loading_progress`,
* `protocol_error`).
*/ */
export function handleChatConnection( export function handleChatConnection(
ws: WebSocket, ws: WebSocket,
@@ -327,7 +99,7 @@ export function handleChatConnection(
console.log('[INFO] Chat WebSocket connected'); console.log('[INFO] Chat WebSocket connected');
connectedClients.add(ws); connectedClients.add(ws);
const userId = readRequestUserId(request); const writer = new WebSocketWriter(ws, readRequestUserId(request));
ws.on('message', async (rawMessage) => { ws.on('message', async (rawMessage) => {
try { try {
@@ -336,30 +108,159 @@ export function handleChatConnection(
throw new Error('Invalid websocket payload'); throw new Error('Invalid websocket payload');
} }
const data = parsed as AnyRecord; const data = parsed as ChatIncomingMessage;
const messageType = typeof data.type === 'string' ? data.type : ''; const messageType = data.type;
if (!messageType) {
throw new Error('Message type is required');
}
switch (messageType) { if (messageType === 'claude-command') {
case 'chat.send': await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
await handleChatSend(ws, userId, data, dependencies); return;
return; }
case 'chat.abort':
await handleChatAbort(ws, data, dependencies); if (messageType === 'cursor-command') {
return; await dependencies.spawnCursor(data.command ?? '', data.options, writer);
case 'chat.subscribe': return;
handleChatSubscribe(ws, data, dependencies); }
return;
case 'chat.permission-response': if (messageType === 'codex-command') {
handlePermissionResponse(data, dependencies); await dependencies.queryCodex(data.command ?? '', data.options, writer);
return; return;
default: }
sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`);
return; if (messageType === 'gemini-command') {
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
return;
}
if (messageType === 'cursor-resume') {
await dependencies.spawnCursor(
'',
{
sessionId: data.sessionId,
resume: true,
cwd: data.options?.cwd,
},
writer
);
return;
}
if (messageType === 'abort-session') {
const provider = readProvider(data.provider);
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
let success = false;
if (provider === 'cursor') {
success = dependencies.abortCursorSession(sessionId);
} else if (provider === 'codex') {
success = dependencies.abortCodexSession(sessionId);
} else if (provider === 'gemini') {
success = dependencies.abortGeminiSession(sessionId);
} else {
success = await dependencies.abortClaudeSDKSession(sessionId);
}
writer.send(
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider,
})
);
return;
}
if (messageType === 'claude-permission-response') {
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
dependencies.resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: typeof data.message === 'string' ? data.message : undefined,
rememberEntry: data.rememberEntry,
});
}
return;
}
if (messageType === 'cursor-abort') {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
const success = dependencies.abortCursorSession(sessionId);
writer.send(
createNormalizedMessage({
kind: 'complete',
exitCode: success ? 0 : 1,
aborted: true,
success,
sessionId,
provider: 'cursor',
})
);
return;
}
if (messageType === 'check-session-status') {
const provider = readProvider(data.provider);
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
let isActive = false;
if (provider === 'cursor') {
isActive = dependencies.isCursorSessionActive(sessionId);
} else if (provider === 'codex') {
isActive = dependencies.isCodexSessionActive(sessionId);
} else if (provider === 'gemini') {
isActive = dependencies.isGeminiSessionActive(sessionId);
} else {
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
if (isActive) {
dependencies.reconnectSessionWriter(sessionId, ws);
}
}
writer.send({
type: 'session-status',
sessionId,
provider,
isProcessing: isActive,
});
return;
}
if (messageType === 'get-pending-permissions') {
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
const pending = dependencies.getPendingApprovalsForSession(sessionId);
writer.send({
type: 'pending-permissions-response',
sessionId,
data: pending,
});
}
return;
}
if (messageType === 'get-active-sessions') {
writer.send({
type: 'active-sessions',
sessions: {
claude: dependencies.getActiveClaudeSDKSessions(),
cursor: dependencies.getActiveCursorSessions(),
codex: dependencies.getActiveCodexSessions(),
gemini: dependencies.getActiveGeminiSessions(),
},
});
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error('[ERROR] Chat WebSocket error:', message); console.error('[ERROR] Chat WebSocket error:', message);
sendProtocolError(ws, 'INTERNAL_ERROR', message); writer.send({
type: 'error',
error: message,
});
} }
}); });

View File

@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`); console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
}); });
upstream.on('message', (data, isBinary) => { upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) { if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data, { binary: isBinary }); clientWs.send(data);
} }
}); });
clientWs.on('message', (data, isBinary) => { clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) { if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data, { binary: isBinary }); upstream.send(data);
} }
}); });

View File

@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
provider?: string; provider?: string;
initialCommand?: string; initialCommand?: string;
isPlainShell?: boolean; isPlainShell?: boolean;
forceRestart?: boolean;
}; };
type PtySessionEntry = { type PtySessionEntry = {
@@ -35,10 +34,7 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
type ShellWebSocketDependencies = { type ShellWebSocketDependencies = {
resolveProviderSessionId: ( getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined;
sessionId: string,
provider: string,
) => string | null | undefined;
stripAnsiSequences: (content: string) => string; stripAnsiSequences: (content: string) => string;
normalizeDetectedUrl: (url: string) => string | null; normalizeDetectedUrl: (url: string) => string | null;
extractUrlsFromText: (content: string) => string[]; extractUrlsFromText: (content: string) => string[];
@@ -79,36 +75,6 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
return payload as ShellIncomingMessage; return payload as ShellIncomingMessage;
} }
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
function resolveResumeSessionId(
message: ShellIncomingMessage,
dependencies: ShellWebSocketDependencies
): string {
const hasSession = readBoolean(message.hasSession);
const sessionId = readString(message.sessionId);
const provider = readString(message.provider, 'claude');
if (!hasSession || !sessionId) {
return '';
}
let resumeSessionId: string | null | undefined;
try {
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
} catch (error) {
console.error('Failed to resolve provider session ID:', error);
resumeSessionId = undefined;
}
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
return '';
}
return resolvedSessionId;
}
/** /**
* Resolves provider command line for plain shell and agent-backed shell modes. * Resolves provider command line for plain shell and agent-backed shell modes.
*/ */
@@ -117,9 +83,10 @@ function buildShellCommand(
dependencies: ShellWebSocketDependencies dependencies: ShellWebSocketDependencies
): string { ): string {
const hasSession = readBoolean(message.hasSession); const hasSession = readBoolean(message.hasSession);
const sessionId = readString(message.sessionId);
const initialCommand = readString(message.initialCommand); const initialCommand = readString(message.initialCommand);
const provider = readString(message.provider, 'claude'); const provider = readString(message.provider, 'claude');
const resumeSessionId = resolveResumeSessionId(message, dependencies); const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
const isPlainShell = const isPlainShell =
readBoolean(message.isPlainShell) || readBoolean(message.isPlainShell) ||
(!!initialCommand && !hasSession) || (!!initialCommand && !hasSession) ||
@@ -130,43 +97,51 @@ function buildShellCommand(
} }
if (provider === 'cursor') { if (provider === 'cursor') {
if (resumeSessionId) { if (hasSession && sessionId) {
return `cursor-agent --resume="${resumeSessionId}"`; return `cursor-agent --resume="${sessionId}"`;
} }
return 'cursor-agent'; return 'cursor-agent';
} }
if (provider === 'codex') { if (provider === 'codex') {
if (resumeSessionId) { if (hasSession && sessionId) {
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} }
return `codex resume "${resumeSessionId}" || codex`; return `codex resume "${sessionId}" || codex`;
} }
return 'codex'; return 'codex';
} }
if (provider === 'gemini') { if (provider === 'gemini') {
const command = initialCommand || 'gemini'; const command = initialCommand || 'gemini';
if (resumeSessionId) { let resumeId = sessionId;
return `${command} --resume "${resumeSessionId}"`; if (hasSession && sessionId) {
try {
const existingSession = dependencies.getSessionById(sessionId);
if (existingSession && existingSession.cliSessionId) {
resumeId = existingSession.cliSessionId;
if (!safeSessionIdPattern.test(resumeId)) {
resumeId = '';
}
}
} catch (error) {
console.error('Failed to get Gemini CLI session ID:', error);
}
}
if (hasSession && resumeId) {
return `${command} --resume "${resumeId}"`;
} }
return command; return command;
} }
if (provider === 'opencode') {
if (resumeSessionId) {
return `opencode --session "${resumeSessionId}"`;
}
return initialCommand || 'opencode';
}
const command = initialCommand || 'claude'; const command = initialCommand || 'claude';
if (resumeSessionId) { if (hasSession && sessionId) {
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
} }
return `claude --resume "${resumeSessionId}" || claude`; return `claude --resume "${sessionId}" || claude`;
} }
return command; return command;
} }
@@ -198,7 +173,6 @@ export function handleShellConnection(
const hasSession = readBoolean(data.hasSession); const hasSession = readBoolean(data.hasSession);
const provider = readString(data.provider, 'claude'); const provider = readString(data.provider, 'claude');
const initialCommand = readString(data.initialCommand); const initialCommand = readString(data.initialCommand);
const forceRestart = readBoolean(data.forceRestart);
const isPlainShell = const isPlainShell =
readBoolean(data.isPlainShell) || readBoolean(data.isPlainShell) ||
(!!initialCommand && !hasSession) || (!!initialCommand && !hasSession) ||
@@ -219,7 +193,7 @@ export function handleShellConnection(
: ''; : '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`; ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand || forceRestart) { if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey); const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) { if (oldSession) {
if (oldSession.timeoutId) { if (oldSession.timeoutId) {
@@ -230,8 +204,7 @@ export function handleShellConnection(
} }
} }
const existingSession = const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) { if (existingSession) {
shellProcess = existingSession.pty; shellProcess = existingSession.pty;
if (existingSession.timeoutId) { if (existingSession.timeoutId) {
@@ -278,7 +251,6 @@ export function handleShellConnection(
} }
const shellCommand = buildShellCommand(data, dependencies); const shellCommand = buildShellCommand(data, dependencies);
const resumeSessionId = resolveResumeSessionId(data, dependencies);
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const shellArgs = const shellArgs =
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
@@ -389,10 +361,6 @@ export function handleShellConnection(
} }
const session = ptySessionsMap.get(ptySessionKey); const session = ptySessionsMap.get(ptySessionKey);
if (session && session.pty !== shellProcess) {
return;
}
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send( session.ws.send(
JSON.stringify({ JSON.stringify({
@@ -421,11 +389,9 @@ export function handleShellConnection(
? 'Codex' ? 'Codex'
: provider === 'gemini' : provider === 'gemini'
? 'Gemini' ? 'Gemini'
: provider === 'opencode'
? 'OpenCode'
: 'Claude'; : 'Claude';
welcomeMsg = hasSession && resumeSessionId welcomeMsg = hasSession
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n` ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
} }
@@ -476,10 +442,6 @@ export function handleShellConnection(
session.ws = null; session.ws = null;
session.timeoutId = setTimeout(() => { session.timeoutId = setTimeout(() => {
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
return;
}
session.pty.kill(); session.pty.kill();
ptySessionsMap.delete(ptySessionKey as string); ptySessionsMap.delete(ptySessionKey as string);
}, PTY_SESSION_TIMEOUT); }, PTY_SESSION_TIMEOUT);

View File

@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
dependencies: WebSocketAuthDependencies dependencies: WebSocketAuthDependencies
): boolean { ): boolean {
const request = info.req as AuthenticatedWebSocketRequest; const request = info.req as AuthenticatedWebSocketRequest;
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost'); console.log('WebSocket connection attempt to:', request.url);
const loggedUrl = new URL(upgradeUrl);
if (loggedUrl.searchParams.has('token')) {
loggedUrl.searchParams.set('token', 'REDACTED');
}
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
// Platform mode: use the first DB user and skip token checks. // Platform mode: use the first DB user and skip token checks.
if (dependencies.isPlatform) { if (dependencies.isPlatform) {
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
} }
// OSS mode: read JWT from query string first, then Authorization header. // OSS mode: read JWT from query string first, then Authorization header.
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const token = const token =
upgradeUrl.searchParams.get('token') ?? upgradeUrl.searchParams.get('token') ??
request.headers.authorization?.split(' ')[1] ?? request.headers.authorization?.split(' ')[1] ??

View File

@@ -31,24 +31,6 @@ export function createWebSocketServer(
}); });
wss.on('connection', (ws, request) => { wss.on('connection', (ws, request) => {
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
// are silently torn down even when the UI is active, causing repeated
// reconnect cycles. ws library heartbeat is opt-in.
const HEARTBEAT_INTERVAL_MS = 30_000;
const heartbeat = setInterval(() => {
if (ws.readyState === ws.OPEN) {
try {
ws.ping();
} catch {
// socket may have been closed concurrently — interval will be cleared below
}
}
}, HEARTBEAT_INTERVAL_MS);
const stopHeartbeat = () => clearInterval(heartbeat);
ws.on('close', stopHeartbeat);
ws.on('error', stopHeartbeat);
const incomingRequest = request as AuthenticatedWebSocketRequest; const incomingRequest = request as AuthenticatedWebSocketRequest;
const url = incomingRequest.url ?? '/'; const url = incomingRequest.url ?? '/';
const pathname = new URL(url, 'http://localhost').pathname; const pathname = new URL(url, 'http://localhost').pathname;

View File

@@ -1,239 +0,0 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
import type { NormalizedMessage } from '@/shared/types.js';
/**
* Minimal stand-in for a websocket connection: collects every JSON frame the
* gateway writer forwards so assertions can inspect the outbound protocol.
*/
class FakeConnection {
readyState = 1; // WS_OPEN_STATE
frames: NormalizedMessage[] = [];
send(data: string): void {
this.frames.push(JSON.parse(data) as NormalizedMessage);
}
}
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
chatRunRegistry.clearAll();
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
test('live events are remapped to the app session id and sequenced', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo');
const connection = new FakeConnection();
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-1',
provider: 'claude',
providerSessionId: null,
connection,
userId: 'user-1',
});
assert.ok(run);
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' });
run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' });
assert.equal(connection.frames.length, 2);
assert.equal(connection.frames[0]?.sessionId, 'app-run-1');
assert.equal(connection.frames[0]?.seq, 1);
assert.equal(connection.frames[1]?.sessionId, 'app-run-1');
assert.equal(connection.frames[1]?.seq, 2);
});
});
test('session_created is swallowed and persisted as the provider-id mapping', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
const connection = new FakeConnection();
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-2',
provider: 'cursor',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(run);
run.writer.send({
kind: 'session_created',
provider: 'cursor',
sessionId: 'cursor-native-7',
newSessionId: 'cursor-native-7',
});
// Never forwarded to the client...
assert.equal(connection.frames.length, 0);
// ...but recorded in the registry and persisted in the database.
assert.equal(run.providerSessionId, 'cursor-native-7');
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
});
});
test('complete marks the run finished and duplicate completes are dropped', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo');
const connection = new FakeConnection();
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-3',
provider: 'codex',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(run);
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 });
// Late duplicate from a killed runtime's exit handler.
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 });
const completes = connection.frames.filter((frame) => frame.kind === 'complete');
assert.equal(completes.length, 1);
assert.equal(completes[0]?.actualSessionId, 'app-run-3');
assert.equal(chatRunRegistry.isProcessing('app-run-3'), false);
// completeRun is also a no-op once the run already completed.
chatRunRegistry.completeRun('app-run-3', { exitCode: 1 });
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
});
});
test('listRunningRuns returns only currently running app sessions', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
const connection = new FakeConnection();
const completedRun = chatRunRegistry.startRun({
appSessionId: 'app-run-7',
provider: 'claude',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(completedRun);
const runningRun = chatRunRegistry.startRun({
appSessionId: 'app-run-8',
provider: 'codex',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(runningRun);
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
const runningSessions = chatRunRegistry.listRunningRuns();
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
assert.equal(runningSessions[0]?.provider, 'codex');
});
});
test('replayEvents returns only events after the requested seq', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');
const connection = new FakeConnection();
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-4',
provider: 'claude',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(run);
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' });
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' });
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' });
const replayed = chatRunRegistry.replayEvents('app-run-4', 1);
assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']);
assert.deepEqual(replayed.map((event) => event.seq), [2, 3]);
});
});
test('attachConnection reroutes the live stream to a new socket', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
const firstConnection = new FakeConnection();
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-5',
provider: 'gemini',
providerSessionId: null,
connection: firstConnection,
userId: null,
});
assert.ok(run);
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
const secondConnection = new FakeConnection();
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
});
});
test('startRun rejects a second concurrent run for the same session', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo');
const connection = new FakeConnection();
const first = chatRunRegistry.startRun({
appSessionId: 'app-run-6',
provider: 'opencode',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(first);
const second = chatRunRegistry.startRun({
appSessionId: 'app-run-6',
provider: 'opencode',
providerSessionId: null,
connection,
userId: null,
});
assert.equal(second, null);
// After the run finishes a new one is allowed again.
chatRunRegistry.completeRun('app-run-6', { exitCode: 0 });
const third = chatRunRegistry.startRun({
appSessionId: 'app-run-6',
provider: 'opencode',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(third);
});
});

View File

@@ -17,40 +17,11 @@ import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js'; import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { createNormalizedMessage } from './shared/utils.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions // Track active sessions
const activeCodexSessions = new Map(); const activeCodexSessions = new Map();
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function extractCodexTokenBudget(event) {
const info = event?.info || event?.payload?.info || event?.usage?.info;
const usage = info?.total_token_usage || event?.usage?.total_token_usage || event?.usage;
if (!usage || typeof usage !== 'object') {
return null;
}
const inputTokens = readUsageNumber(usage.input_tokens);
const outputTokens = readUsageNumber(usage.output_tokens);
const used = readUsageNumber(usage.total_tokens) || inputTokens + outputTokens;
return {
used,
total: readUsageNumber(info?.model_context_window || event?.usage?.model_context_window) || 200000,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
/** /**
* Transform Codex SDK event to WebSocket message format * Transform Codex SDK event to WebSocket message format
* @param {object} event - SDK event * @param {object} event - SDK event
@@ -231,12 +202,6 @@ export async function queryCodex(command, options = {}, ws) {
permissionMode = 'default' permissionMode = 'default'
} = options; } = options;
const resolvedModel = await providerModelsService.resolveResumeModel(
'codex',
sessionId,
model,
);
const workingDirectory = cwd || projectPath || process.cwd(); const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
@@ -257,7 +222,7 @@ export async function queryCodex(command, options = {}, ws) {
skipGitRepoCheck: true, skipGitRepoCheck: true,
sandboxMode, sandboxMode,
approvalPolicy, approvalPolicy,
model: resolvedModel model
}; };
// Start or resume thread // Start or resume thread
@@ -344,34 +309,27 @@ export async function queryCodex(command, options = {}, ws) {
} }
// Extract and send token usage if available (normalized to match Claude format) // Extract and send token usage if available (normalized to match Claude format)
if (event.type === 'turn.completed') { if (event.type === 'turn.completed' && event.usage) {
const tokenBudget = extractCodexTokenBudget(event); const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
if (tokenBudget) { sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
}
} }
} }
// Send the terminal completion event — skipped for aborted runs, whose // Send completion event
// terminal `complete` (aborted: true) was already sent by abort-session. if (!terminalFailure) {
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null; sendMessage(ws, createNormalizedMessage({
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted; kind: 'complete',
if (!runAborted) { actualSessionId: capturedSessionId || thread.id || sessionId || null,
sendMessage(ws, createCompleteMessage({ sessionId: capturedSessionId || sessionId || null,
provider: 'codex'
}));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex', provider: 'codex',
sessionId: capturedSessionId || sessionId || null, sessionId: capturedSessionId || sessionId || null,
actualSessionId: capturedSessionId || thread.id || sessionId || null, sessionName: sessionSummary,
exitCode: terminalFailure ? 1 : 0, stopReason: 'completed'
})); });
if (!terminalFailure) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: 'completed'
});
}
} }
} catch (error) { } catch (error) {
@@ -391,11 +349,6 @@ export async function queryCodex(command, options = {}, ws) {
: error.message; : error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' })); sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createCompleteMessage({
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
exitCode: 1,
}));
if (!terminalFailure) { if (!terminalFailure) {
notifyRunFailed({ notifyRunFailed({
userId: ws?.userId || null, userId: ws?.userId || null,

View File

@@ -1,345 +0,0 @@
import { spawn } from 'child_process';
import fsSync from 'node:fs';
import crossSpawn from 'cross-spawn';
import Database from 'better-sqlite3';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const activeOpenCodeProcesses = new Map();
function readOpenCodeSessionId(event) {
if (!event || typeof event !== 'object') {
return null;
}
return event.sessionID || event.sessionId || null;
}
function readOpenCodeTokenUsage(sessionId) {
const dbPath = getOpenCodeDatabasePath();
if (!sessionId || !fsSync.existsSync(dbPath)) {
return null;
}
let db = null;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const columns = db.prepare('PRAGMA table_info(session)').all();
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return null;
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(sessionId);
if (!row) {
return null;
}
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
const outputTokens = Number(row.outputTokens || 0);
const used = Number(row.inputTokens || 0)
+ outputTokens
+ Number(row.reasoningTokens || 0)
+ Number(row.cacheReadTokens || 0)
+ Number(row.cacheWriteTokens || 0);
if (used <= 0) {
return null;
}
return {
used,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
} catch {
return null;
} finally {
if (db) {
db.close();
}
}
}
async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null;
let sessionCreatedSent = false;
let stdoutLineBuffer = '';
let terminalNotificationSent = false;
let opencodeProcess = null;
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) {
return;
}
terminalNotificationSent = true;
const finalSessionId = capturedSessionId || sessionId || processKey;
if (code === 0 && !error) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'opencode',
sessionId: finalSessionId,
sessionName: sessionSummary,
stopReason: 'completed',
});
return;
}
notifyRunFailed({
userId: ws?.userId || null,
provider: 'opencode',
sessionId: finalSessionId,
sessionName: sessionSummary,
error: error || `OpenCode CLI exited with code ${code}`,
});
};
const registerSession = (nextSessionId) => {
if (!nextSessionId || capturedSessionId === nextSessionId) {
return;
}
capturedSessionId = nextSessionId;
if (processKey !== capturedSessionId && opencodeProcess) {
activeOpenCodeProcesses.delete(processKey);
activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess);
}
if (opencodeProcess) {
opencodeProcess.sessionId = capturedSessionId;
}
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: 'opencode',
}));
}
};
const processOpenCodeOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
let response;
try {
response = JSON.parse(line);
} catch {
ws.send(createNormalizedMessage({
kind: 'stream_delta',
content: line,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
return;
}
try {
registerSession(readOpenCodeSessionId(response));
const normalized = sessionsService.normalizeMessage(
'opencode',
response,
capturedSessionId || sessionId || null,
);
for (const msg of normalized) {
ws.send(msg);
}
} catch (error) {
const errorContent = error instanceof Error ? error.message : String(error);
console.error('[OpenCode] Failed to process JSON output:', errorContent);
ws.send(createNormalizedMessage({
kind: 'error',
content: errorContent,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
}
};
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
const args = ['run', '--format', 'json'];
if (sessionId) {
args.push('--session', sessionId);
}
if (resolvedModel) {
args.push('--model', resolvedModel);
}
if (command && command.trim()) {
args.push(command.trim());
}
opencodeProcess = spawnFunction('opencode', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
activeOpenCodeProcesses.set(processKey, opencodeProcess);
opencodeProcess.sessionId = processKey;
opencodeProcess.stdin.end();
opencodeProcess.stdout.on('data', (data) => {
stdoutLineBuffer += data.toString();
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
completeLines.forEach((line) => {
processOpenCodeOutputLine(line.trim());
});
});
opencodeProcess.stderr.on('data', (data) => {
const stderrText = data.toString();
if (!stderrText.trim()) {
return;
}
ws.send(createNormalizedMessage({
kind: 'error',
content: stderrText,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
});
opencodeProcess.on('close', async (code) => {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeOpenCodeProcesses.delete(finalSessionId);
activeOpenCodeProcesses.delete(processKey);
if (stdoutLineBuffer.trim()) {
processOpenCodeOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
const tokenBudget = readOpenCodeTokenUsage(finalSessionId);
if (tokenBudget) {
ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: finalSessionId,
provider: 'opencode',
}));
}
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
}
if (code === 0) {
notifyTerminalState({ code });
resolve();
return;
}
if (code === 127 || code === null) {
const installed = await providerAuthService.isProviderInstalled('opencode');
if (!installed) {
ws.send(createNormalizedMessage({
kind: 'error',
content: 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/',
sessionId: finalSessionId,
provider: 'opencode',
}));
}
}
notifyTerminalState({ code });
reject(new Error(code === null ? 'OpenCode CLI process was terminated' : `OpenCode CLI exited with code ${code}`));
});
opencodeProcess.on('error', async (error) => {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeOpenCodeProcesses.delete(finalSessionId);
activeOpenCodeProcesses.delete(processKey);
const installed = await providerAuthService.isProviderInstalled('opencode');
const errorContent = !installed
? 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/'
: error.message;
ws.send(createNormalizedMessage({
kind: 'error',
content: errorContent,
sessionId: finalSessionId,
provider: 'opencode',
}));
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
});
}).catch(reject);
});
}
function abortOpenCodeSession(sessionId) {
const process = activeOpenCodeProcesses.get(sessionId);
if (!process) {
return false;
}
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeOpenCodeProcesses.delete(sessionId);
return true;
}
function isOpenCodeSessionActive(sessionId) {
return activeOpenCodeProcesses.has(sessionId);
}
function getActiveOpenCodeSessions() {
return Array.from(activeOpenCodeProcesses.keys());
}
export {
spawnOpenCode,
abortOpenCodeSession,
isOpenCodeSessionActive,
getActiveOpenCodeSessions,
};

View File

@@ -1,95 +0,0 @@
import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { spawnOpenCode } from './opencode-cli.js';
const findEnvKey = (name) =>
Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
async function createFakeOpenCodeExecutable(binDir) {
const scriptPath = path.join(binDir, 'opencode.js');
await writeFile(scriptPath, `
const events = [
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
{ type: 'step_finish', sessionID: 'open-live-1' },
];
for (const event of events) {
console.log(JSON.stringify(event));
}
`, 'utf8');
if (process.platform === 'win32') {
const commandPath = path.join(binDir, 'opencode.cmd');
await writeFile(commandPath, '@echo off\r\nnode "%~dp0opencode.js" %*\r\n', 'utf8');
return;
}
const commandPath = path.join(binDir, 'opencode');
await writeFile(commandPath, '#!/bin/sh\nnode "$(dirname "$0")/opencode.js" "$@"\n', 'utf8');
await chmod(commandPath, 0o755);
}
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
const pathKey = findEnvKey('PATH');
const pathExtKey = findEnvKey('PATHEXT');
const previousPath = process.env[pathKey];
const previousPathExt = process.env[pathExtKey];
const messages = [];
const writer = {
userId: null,
sessionId: null,
send(message) {
messages.push(message);
},
setSessionId(sessionId) {
this.sessionId = sessionId;
},
};
try {
await createFakeOpenCodeExecutable(tempRoot);
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
if (process.platform === 'win32') {
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
? previousPathExt
: `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`;
}
await spawnOpenCode('Hi', { cwd: tempRoot }, writer);
const sessionCreatedIndex = messages.findIndex((message) => message.kind === 'session_created');
const assistantDeltaIndex = messages.findIndex((message) =>
message.kind === 'stream_delta' && message.content === 'assistant response',
);
const streamEnd = messages.find((message) => message.kind === 'stream_end');
const complete = messages.find((message) => message.kind === 'complete');
assert.notEqual(sessionCreatedIndex, -1);
assert.notEqual(assistantDeltaIndex, -1);
assert.ok(sessionCreatedIndex < assistantDeltaIndex);
assert.equal(messages[sessionCreatedIndex].newSessionId, 'open-live-1');
assert.equal(writer.sessionId, 'open-live-1');
assert.equal(streamEnd?.sessionId, 'open-live-1');
assert.equal(complete?.sessionId, 'open-live-1');
assert.equal(messages.some((message) => message.kind === 'error'), false);
} finally {
if (previousPath === undefined) {
delete process.env[pathKey];
} else {
process.env[pathKey] = previousPath;
}
if (previousPathExt === undefined) {
delete process.env[pathExtKey];
} else {
process.env[pathExtKey] = previousPathExt;
}
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -9,9 +9,8 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js'; import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js'; import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { providerModelsService } from '../modules/providers/services/provider-models.service.js'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { IS_PLATFORM } from '../constants/config.js'; import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js'; import { normalizeProjectPath } from '../shared/utils.js';
@@ -592,14 +591,12 @@ class ResponseCollector {
} }
} }
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
return { return {
inputTokens, inputTokens: totalInput,
outputTokens: totalOutput, outputTokens: totalOutput,
cacheReadTokens: totalCacheRead, cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation, cacheCreationTokens: totalCacheCreation,
totalTokens: inputTokens + totalOutput totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
}; };
} }
} }
@@ -611,7 +608,7 @@ class ResponseCollector {
/** /**
* POST /api/agent * POST /api/agent
* *
* Trigger an AI agent to work on a project. * Trigger an AI agent (Claude or Cursor) to work on a project.
* Supports automatic GitHub branch and pull request creation after successful completion. * Supports automatic GitHub branch and pull request creation after successful completion.
* *
* ================================================================================================ * ================================================================================================
@@ -636,7 +633,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName) * - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made * - Fallback for PR title if no commits are made
* *
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
* Default: 'claude' * Default: 'claude'
* *
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -646,7 +643,7 @@ class ResponseCollector {
* *
* @param {string} model - (Optional) Model identifier for providers. * @param {string} model - (Optional) Model identifier for providers.
* *
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable' * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
@@ -754,7 +751,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request): * Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither) * - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string * - message must be non-empty string
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode' * - provider must be 'claude', 'cursor', 'codex', or 'gemini'
* - createBranch/createPR requires githubUrl OR projectPath (not neither) * - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided) * - branchName must pass Git naming rules (if provided)
* *
@@ -862,8 +859,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' }); return res.status(400).json({ error: 'message is required' });
} }
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) { if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' }); return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
} }
// Validate GitHub branch/PR creation requirements // Validate GitHub branch/PR creation requirements
@@ -941,10 +938,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
}); });
} }
const codexModels = (await providerModelsService.getProviderModels('codex')).models;
const geminiModels = (await providerModelsService.getProviderModels('gemini')).models;
const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
// Start the appropriate session // Start the appropriate session
if (provider === 'claude') { if (provider === 'claude') {
console.log('🤖 Starting Claude SDK session'); console.log('🤖 Starting Claude SDK session');
@@ -974,7 +967,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model || codexModels.DEFAULT, model: model || CODEX_MODELS.DEFAULT,
permissionMode: 'bypassPermissions' permissionMode: 'bypassPermissions'
}, writer); }, writer);
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
@@ -984,18 +977,9 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath, projectPath: finalProjectPath,
cwd: finalProjectPath, cwd: finalProjectPath,
sessionId: sessionId || null, sessionId: sessionId || null,
model: model || geminiModels.DEFAULT, model: model,
skipPermissions: true // CLI mode bypasses permissions skipPermissions: true // CLI mode bypasses permissions
}, writer); }, writer);
} else if (provider === 'opencode') {
console.log('Starting OpenCode CLI session');
await spawnOpenCode(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
}, writer);
} }
// Handle GitHub branch and PR creation after successful agent completion // Handle GitHub branch and PR creation after successful agent completion

View File

@@ -1,12 +1,12 @@
import { promises as fs } from "fs"; import { promises as fs } from 'fs';
import os from "os"; import os from 'os';
import path from "path"; import path from 'path';
import express from "express"; import express from 'express';
import { providerModelsService } from "../modules/providers/services/provider-models.service.js"; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { parseFrontMatter } from "../shared/frontmatter.js"; import { parseFrontMatter } from '../shared/frontmatter.js';
import { findAppRoot, getModuleDir } from "../utils/runtime-paths.js"; import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
const __dirname = getModuleDir(import.meta.url); const __dirname = getModuleDir(import.meta.url);
// This route reads the top-level package.json for the status command, so it needs the real // This route reads the top-level package.json for the status command, so it needs the real
@@ -15,77 +15,6 @@ const APP_ROOT = findAppRoot(__dirname);
const router = express.Router(); const router = express.Router();
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
const MODEL_PROVIDER_LABELS = {
claude: "Claude",
cursor: "Cursor",
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
};
const readModelProvider = (value) => {
if (typeof value !== "string") {
return "claude";
}
const normalized = value.trim().toLowerCase();
return MODEL_PROVIDERS.includes(normalized) ? normalized : "claude";
};
const hasConcreteSessionId = (value) =>
typeof value === "string" && value.trim().length > 0;
const resolveCommandModel = async (provider, catalog, sessionId) => {
if (!hasConcreteSessionId(sessionId)) {
return catalog.DEFAULT;
}
const currentActiveModel = await providerModelsService.getCurrentActiveModel(
provider,
sessionId,
);
return currentActiveModel?.model || catalog.DEFAULT;
};
export const executeModelsCommand = async (args, context) => {
const currentProvider = readModelProvider(context?.provider);
const result = await providerModelsService.getProviderModels(currentProvider);
const catalog = result.models;
const currentModel = await resolveCommandModel(
currentProvider,
catalog,
context?.sessionId,
);
const availableModels = catalog.OPTIONS.map((option) => option.value);
const availableOptions = catalog.OPTIONS.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
}));
return {
type: "builtin",
action: "models",
data: {
current: {
provider: currentProvider,
providerLabel: MODEL_PROVIDER_LABELS[currentProvider],
model: currentModel,
},
available: {
[currentProvider]: availableModels,
},
availableModels,
availableOptions,
defaultModel: catalog.DEFAULT,
cache: result.cache,
message: `Current model: ${currentModel}`,
},
};
};
/** /**
* Recursively scan directory for command files (.md) * Recursively scan directory for command files (.md)
* @param {string} dir - Directory to scan * @param {string} dir - Directory to scan
@@ -107,30 +36,24 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Recursively scan subdirectories // Recursively scan subdirectories
const subCommands = await scanCommandsDirectory( const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
fullPath,
baseDir,
namespace,
);
commands.push(...subCommands); commands.push(...subCommands);
} else if (entry.isFile() && entry.name.endsWith(".md")) { } else if (entry.isFile() && entry.name.endsWith('.md')) {
// Parse markdown file for metadata // Parse markdown file for metadata
try { try {
const content = await fs.readFile(fullPath, "utf8"); const content = await fs.readFile(fullPath, 'utf8');
const { data: frontmatter, content: commandContent } = const { data: frontmatter, content: commandContent } = parseFrontMatter(content);
parseFrontMatter(content);
// Calculate relative path from baseDir for command name // Calculate relative path from baseDir for command name
const relativePath = path.relative(baseDir, fullPath); const relativePath = path.relative(baseDir, fullPath);
// Remove .md extension and convert to command name // Remove .md extension and convert to command name
const commandName = const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
"/" + relativePath.replace(/\.md$/, "").replace(/\\/g, "/");
// Extract description from frontmatter or first line of content // Extract description from frontmatter or first line of content
let description = frontmatter.description || ""; let description = frontmatter.description || '';
if (!description) { if (!description) {
const firstLine = commandContent.trim().split("\n")[0]; const firstLine = commandContent.trim().split('\n')[0];
description = firstLine.replace(/^#+\s*/, "").trim(); description = firstLine.replace(/^#+\s*/, '').trim();
} }
commands.push({ commands.push({
@@ -139,7 +62,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
relativePath, relativePath,
description, description,
namespace, namespace,
metadata: frontmatter, metadata: frontmatter
}); });
} catch (err) { } catch (err) {
console.error(`Error parsing command file ${fullPath}:`, err.message); console.error(`Error parsing command file ${fullPath}:`, err.message);
@@ -148,7 +71,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
} }
} catch (err) { } catch (err) {
// Directory doesn't exist or can't be accessed - this is okay // Directory doesn't exist or can't be accessed - this is okay
if (err.code !== "ENOENT" && err.code !== "EACCES") { if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
console.error(`Error scanning directory ${dir}:`, err.message); console.error(`Error scanning directory ${dir}:`, err.message);
} }
} }
@@ -161,41 +84,53 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
*/ */
const builtInCommands = [ const builtInCommands = [
{ {
name: "/help", name: '/help',
description: "Show help documentation for Claude Code", description: 'Show help documentation for Claude Code',
namespace: "builtin", namespace: 'builtin',
metadata: { type: "builtin" }, metadata: { type: 'builtin' }
}, },
{ {
name: "/models", name: '/clear',
description: "View available models for the current provider", description: 'Clear the conversation history',
namespace: "builtin", namespace: 'builtin',
metadata: { type: "builtin" }, metadata: { type: 'builtin' }
}, },
{ {
name: "/cost", name: '/model',
description: "Display token usage information", description: 'Switch or view the current AI model',
namespace: "builtin", namespace: 'builtin',
metadata: { type: "builtin" }, metadata: { type: 'builtin' }
}, },
{ {
name: "/memory", name: '/cost',
description: "Open CLAUDE.md memory file for editing", description: 'Display token usage and cost information',
namespace: "builtin", namespace: 'builtin',
metadata: { type: "builtin" }, metadata: { type: 'builtin' }
}, },
{ {
name: "/config", name: '/memory',
description: "Open settings and configuration", description: 'Open CLAUDE.md memory file for editing',
namespace: "builtin", namespace: 'builtin',
metadata: { type: "builtin" }, metadata: { type: 'builtin' }
}, },
{ {
name: "/status", name: '/config',
description: "Show system status and version information", description: 'Open settings and configuration',
namespace: "builtin", namespace: 'builtin',
metadata: { type: "builtin" }, metadata: { type: 'builtin' }
}, },
{
name: '/status',
description: 'Show system status and version information',
namespace: 'builtin',
metadata: { type: 'builtin' }
},
{
name: '/rewind',
description: 'Rewind the conversation to a previous state',
namespace: 'builtin',
metadata: { type: 'builtin' }
}
]; ];
/** /**
@@ -203,18 +138,14 @@ const builtInCommands = [
* Each handler returns { type: 'builtin', action: string, data: any } * Each handler returns { type: 'builtin', action: string, data: any }
*/ */
const builtInHandlers = { const builtInHandlers = {
"/help": async (args, context) => { '/help': async (args, context) => {
const helpText = `# Claude Code Commands const helpText = `# Claude Code Commands
## Built-in Commands ## Built-in Commands
${builtInCommands ${builtInCommands.map(cmd => `### ${cmd.name}
.map(
(cmd) => `### ${cmd.name}
${cmd.description} ${cmd.description}
`, `).join('\n')}
)
.join("\n")}
## Custom Commands ## Custom Commands
@@ -236,169 +167,184 @@ Custom commands can be created in:
`; `;
return { return {
type: "builtin", type: 'builtin',
action: "help", action: 'help',
data: { data: {
content: helpText, content: helpText,
format: "markdown", format: 'markdown'
commands: builtInCommands.map((command) => ({ }
name: command.name,
description: command.description,
namespace: command.namespace,
})),
},
}; };
}, },
"/models": executeModelsCommand, '/clear': async (args, context) => {
return {
type: 'builtin',
action: 'clear',
data: {
message: 'Conversation history cleared'
}
};
},
"/cost": async (args, context) => { '/model': async (args, context) => {
// Read available models from centralized constants
const availableModels = {
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
};
const currentProvider = context?.provider || 'claude';
const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;
return {
type: 'builtin',
action: 'model',
data: {
current: {
provider: currentProvider,
model: currentModel
},
available: availableModels,
message: args.length > 0
? `Switching to model: ${args[0]}`
: `Current model: ${currentModel}`
}
};
},
'/cost': async (args, context) => {
const tokenUsage = context?.tokenUsage || {}; const tokenUsage = context?.tokenUsage || {};
const provider = readModelProvider(context?.provider); const provider = context?.provider || 'claude';
const catalog = (await providerModelsService.getProviderModels(provider)).models; const model =
const model = await resolveCommandModel(provider, catalog, context?.sessionId); context?.model ||
(provider === 'cursor'
? CURSOR_MODELS.DEFAULT
: provider === 'codex'
? CODEX_MODELS.DEFAULT
: CLAUDE_MODELS.DEFAULT);
const reportedUsed = const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
Number(
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
) || 0;
const total = const total =
Number( Number(
tokenUsage.total ?? tokenUsage.total ??
tokenUsage.contextWindow ?? tokenUsage.contextWindow ??
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
) || 160000;
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.promptTokens ??
0, 0,
) || 0; ) || 0;
const normalizedInputValue =
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens;
const directInputTokens =
Number(
normalizedInputValue ??
tokenUsage.input_tokens ??
0
) || 0;
const cacheReadTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cache_read_input_tokens ??
tokenUsage.cacheReadInputTokens ??
0,
) || 0;
const cacheCreationTokens =
Number(
tokenUsage.cacheCreationTokens ??
tokenUsage.cache_creation_input_tokens ??
tokenUsage.cacheCreationInputTokens ??
0,
) || 0;
const inputTokens = normalizedInputValue == null
? directInputTokens + cacheReadTokens + cacheCreationTokens
: directInputTokens;
const outputTokens = const outputTokens =
Number( Number(
tokenUsage.outputTokens ?? tokenUsage.outputTokens ??
tokenUsage.output ?? tokenUsage.output ??
tokenUsage.output_tokens ??
tokenUsage.cumulativeOutputTokens ?? tokenUsage.cumulativeOutputTokens ??
tokenUsage.breakdown?.output ??
tokenUsage.completionTokens ?? tokenUsage.completionTokens ??
0, 0,
) || 0; ) || 0;
const computedUsed = inputTokens + outputTokens; const cacheTokens =
const hasTokenBreakdown = computedUsed > 0; Number(
const used = Math.max(reportedUsed, computedUsed); tokenUsage.cacheReadTokens ??
tokenUsage.cacheCreationTokens ??
tokenUsage.cacheTokens ??
tokenUsage.cachedTokens ??
0,
) || 0;
// If we only have total used tokens, treat them as input for display/estimation.
const inputTokens =
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
// Rough default rates by provider (USD / 1M tokens).
const pricingByProvider = {
claude: { input: 3, output: 15 },
cursor: { input: 3, output: 15 },
codex: { input: 1.5, output: 6 },
};
const rates = pricingByProvider[provider] || pricingByProvider.claude;
const inputCost = (inputTokens / 1_000_000) * rates.input;
const outputCost = (outputTokens / 1_000_000) * rates.output;
const totalCost = inputCost + outputCost;
return { return {
type: "builtin", type: 'builtin',
action: "cost", action: 'cost',
data: { data: {
tokenUsage: { tokenUsage: {
used, used,
total, total,
percentage,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
}, },
...(hasTokenBreakdown
? {
tokenBreakdown: {
input: inputTokens,
output: outputTokens,
},
}
: {}),
provider,
model, model,
}, },
}; };
}, },
"/status": async (args, context) => { '/status': async (args, context) => {
// Read version from package.json // Read version from package.json
const packageJsonPath = path.join(APP_ROOT, "package.json"); const packageJsonPath = path.join(APP_ROOT, 'package.json');
let version = "unknown"; let version = 'unknown';
let packageName = "claude-code-ui"; let packageName = 'claude-code-ui';
try { try {
const packageJson = JSON.parse( const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
await fs.readFile(packageJsonPath, "utf8"),
);
version = packageJson.version; version = packageJson.version;
packageName = packageJson.name; packageName = packageJson.name;
} catch (err) { } catch (err) {
console.error("Error reading package.json:", err); console.error('Error reading package.json:', err);
} }
const uptime = process.uptime(); const uptime = process.uptime();
const uptimeMinutes = Math.floor(uptime / 60); const uptimeMinutes = Math.floor(uptime / 60);
const uptimeHours = Math.floor(uptimeMinutes / 60); const uptimeHours = Math.floor(uptimeMinutes / 60);
const uptimeFormatted = const uptimeFormatted = uptimeHours > 0
uptimeHours > 0 ? `${uptimeHours}h ${uptimeMinutes % 60}m`
? `${uptimeHours}h ${uptimeMinutes % 60}m` : `${uptimeMinutes}m`;
: `${uptimeMinutes}m`;
const statusProvider = readModelProvider(context?.provider);
const statusCatalog = (await providerModelsService.getProviderModels(statusProvider)).models;
const model = await resolveCommandModel(statusProvider, statusCatalog, context?.sessionId);
const memoryUsage = process.memoryUsage();
return { return {
type: "builtin", type: 'builtin',
action: "status", action: 'status',
data: { data: {
version, version,
packageName, packageName,
uptime: uptimeFormatted, uptime: uptimeFormatted,
uptimeSeconds: Math.floor(uptime), uptimeSeconds: Math.floor(uptime),
model, model: context?.model || CLAUDE_MODELS.DEFAULT,
provider: statusProvider, provider: context?.provider || 'claude',
nodeVersion: process.version, nodeVersion: process.version,
platform: process.platform, platform: process.platform
pid: process.pid, }
memoryUsage: {
rssMb: Math.round(memoryUsage.rss / 1024 / 1024),
heapUsedMb: Math.round(memoryUsage.heapUsed / 1024 / 1024),
heapTotalMb: Math.round(memoryUsage.heapTotal / 1024 / 1024),
},
},
}; };
}, },
"/memory": async (args, context) => { '/memory': async (args, context) => {
const projectPath = context?.projectPath; const projectPath = context?.projectPath;
if (!projectPath) { if (!projectPath) {
return { return {
type: "builtin", type: 'builtin',
action: "memory", action: 'memory',
data: { data: {
error: "No project selected", error: 'No project selected',
message: "Please select a project to access its CLAUDE.md file", message: 'Please select a project to access its CLAUDE.md file'
}, }
}; };
} }
const claudeMdPath = path.join(projectPath, "CLAUDE.md"); const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
// Check if CLAUDE.md exists // Check if CLAUDE.md exists
let exists = false; let exists = false;
@@ -410,63 +356,85 @@ Custom commands can be created in:
} }
return { return {
type: "builtin", type: 'builtin',
action: "memory", action: 'memory',
data: { data: {
path: claudeMdPath, path: claudeMdPath,
exists, exists,
message: exists message: exists
? `Opening CLAUDE.md at ${claudeMdPath}` ? `Opening CLAUDE.md at ${claudeMdPath}`
: `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`, : `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
}, }
}; };
}, },
"/config": async (args, context) => { '/config': async (args, context) => {
return { return {
type: "builtin", type: 'builtin',
action: "config", action: 'config',
data: { data: {
message: "Opening settings...", message: 'Opening settings...'
}, }
}; };
}, },
'/rewind': async (args, context) => {
const steps = args[0] ? parseInt(args[0]) : 1;
if (isNaN(steps) || steps < 1) {
return {
type: 'builtin',
action: 'rewind',
data: {
error: 'Invalid steps parameter',
message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
}
};
}
return {
type: 'builtin',
action: 'rewind',
data: {
steps,
message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
}
};
}
}; };
/** /**
* POST /api/commands/list * POST /api/commands/list
* List all available commands from project and user directories * List all available commands from project and user directories
*/ */
router.post("/list", async (req, res) => { router.post('/list', async (req, res) => {
try { try {
const { projectPath } = req.body; const { projectPath } = req.body;
const allCommands = [...builtInCommands]; const allCommands = [...builtInCommands];
// Scan project-level commands (.claude/commands/) // Scan project-level commands (.claude/commands/)
if (projectPath) { if (projectPath) {
const projectCommandsDir = path.join(projectPath, ".claude", "commands"); const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
const projectCommands = await scanCommandsDirectory( const projectCommands = await scanCommandsDirectory(
projectCommandsDir, projectCommandsDir,
projectCommandsDir, projectCommandsDir,
"project", 'project'
); );
allCommands.push(...projectCommands); allCommands.push(...projectCommands);
} }
// Scan user-level commands (~/.claude/commands/) // Scan user-level commands (~/.claude/commands/)
const homeDir = os.homedir(); const homeDir = os.homedir();
const userCommandsDir = path.join(homeDir, ".claude", "commands"); const userCommandsDir = path.join(homeDir, '.claude', 'commands');
const userCommands = await scanCommandsDirectory( const userCommands = await scanCommandsDirectory(
userCommandsDir, userCommandsDir,
userCommandsDir, userCommandsDir,
"user", 'user'
); );
allCommands.push(...userCommands); allCommands.push(...userCommands);
// Separate built-in and custom commands // Separate built-in and custom commands
const customCommands = allCommands.filter( const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
(cmd) => cmd.namespace !== "builtin",
);
// Sort commands alphabetically by name // Sort commands alphabetically by name
customCommands.sort((a, b) => a.name.localeCompare(b.name)); customCommands.sort((a, b) => a.name.localeCompare(b.name));
@@ -474,13 +442,13 @@ router.post("/list", async (req, res) => {
res.json({ res.json({
builtIn: builtInCommands, builtIn: builtInCommands,
custom: customCommands, custom: customCommands,
count: allCommands.length, count: allCommands.length
}); });
} catch (error) { } catch (error) {
console.error("Error listing commands:", error); console.error('Error listing commands:', error);
res.status(500).json({ res.status(500).json({
error: "Failed to list commands", error: 'Failed to list commands',
message: error.message, message: error.message
}); });
} }
}); });
@@ -491,13 +459,13 @@ router.post("/list", async (req, res) => {
* This endpoint prepares the command content but doesn't execute bash commands yet * This endpoint prepares the command content but doesn't execute bash commands yet
* (that will be handled in the command parser utility) * (that will be handled in the command parser utility)
*/ */
router.post("/execute", async (req, res) => { router.post('/execute', async (req, res) => {
try { try {
const { commandName, commandPath, args = [], context = {} } = req.body; const { commandName, commandPath, args = [], context = {} } = req.body;
if (!commandName) { if (!commandName) {
return res.status(400).json({ return res.status(400).json({
error: "Command name is required", error: 'Command name is required'
}); });
} }
@@ -508,17 +476,14 @@ router.post("/execute", async (req, res) => {
const result = await handler(args, context); const result = await handler(args, context);
return res.json({ return res.json({
...result, ...result,
command: commandName, command: commandName
}); });
} catch (error) { } catch (error) {
console.error( console.error(`Error executing built-in command ${commandName}:`, error);
`Error executing built-in command ${commandName}:`,
error,
);
return res.status(500).json({ return res.status(500).json({
error: "Command execution failed", error: 'Command execution failed',
message: error.message, message: error.message,
command: commandName, command: commandName
}); });
} }
} }
@@ -526,7 +491,7 @@ router.post("/execute", async (req, res) => {
// Handle custom commands // Handle custom commands
if (!commandPath) { if (!commandPath) {
return res.status(400).json({ return res.status(400).json({
error: "Command path is required for custom commands", error: 'Command path is required for custom commands'
}); });
} }
@@ -534,62 +499,56 @@ router.post("/execute", async (req, res) => {
// Security: validate commandPath is within allowed directories // Security: validate commandPath is within allowed directories
{ {
const resolvedPath = path.resolve(commandPath); const resolvedPath = path.resolve(commandPath);
const userBase = path.resolve( const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
path.join(os.homedir(), ".claude", "commands"),
);
const projectBase = context?.projectPath const projectBase = context?.projectPath
? path.resolve(path.join(context.projectPath, ".claude", "commands")) ? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
: null; : null;
const isUnder = (base) => { const isUnder = (base) => {
const rel = path.relative(base, resolvedPath); const rel = path.relative(base, resolvedPath);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}; };
if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) { if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
return res.status(403).json({ return res.status(403).json({
error: "Access denied", error: 'Access denied',
message: "Command must be in .claude/commands directory", message: 'Command must be in .claude/commands directory'
}); });
} }
} }
const content = await fs.readFile(commandPath, "utf8"); const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = const { data: metadata, content: commandContent } = parseFrontMatter(content);
parseFrontMatter(content);
// Basic argument replacement (will be enhanced in command parser utility) // Basic argument replacement (will be enhanced in command parser utility)
let processedContent = commandContent; let processedContent = commandContent;
// Replace $ARGUMENTS with all arguments joined // Replace $ARGUMENTS with all arguments joined
const argsString = args.join(" "); const argsString = args.join(' ');
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString); processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
// Replace $1, $2, etc. with positional arguments // Replace $1, $2, etc. with positional arguments
args.forEach((arg, index) => { args.forEach((arg, index) => {
const placeholder = `$${index + 1}`; const placeholder = `$${index + 1}`;
processedContent = processedContent.replace( processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
new RegExp(`\\${placeholder}\\b`, "g"),
arg,
);
}); });
res.json({ res.json({
type: "custom", type: 'custom',
command: commandName, command: commandName,
content: processedContent, content: processedContent,
metadata, metadata,
hasFileIncludes: processedContent.includes("@"), hasFileIncludes: processedContent.includes('@'),
hasBashCommands: processedContent.includes("!"), hasBashCommands: processedContent.includes('!')
}); });
} catch (error) { } catch (error) {
if (error.code === "ENOENT") { if (error.code === 'ENOENT') {
return res.status(404).json({ return res.status(404).json({
error: "Command not found", error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`, message: `Command file not found: ${req.body.commandPath}`
}); });
} }
console.error("Error executing command:", error); console.error('Error executing command:', error);
res.status(500).json({ res.status(500).json({
error: "Failed to execute command", error: 'Failed to execute command',
message: error.message, message: error.message
}); });
} }
}); });

View File

@@ -2,7 +2,7 @@ import express from 'express';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CURSOR_FALLBACK_MODELS } from '../modules/providers/list/cursor/cursor-models.provider.js'; import { CURSOR_MODELS } from '../../shared/modelConstants.js';
const router = express.Router(); const router = express.Router();
@@ -29,7 +29,7 @@ router.get('/config', async (req, res) => {
config: { config: {
version: 1, version: 1,
model: { model: {
modelId: CURSOR_FALLBACK_MODELS.DEFAULT, modelId: CURSOR_MODELS.DEFAULT,
displayName: 'GPT-5', displayName: 'GPT-5',
}, },
permissions: { permissions: {

View File

@@ -1,5 +1,4 @@
import express from 'express'; import express from 'express';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js'; import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
import { getPublicKey } from '../services/vapid-keys.js'; import { getPublicKey } from '../services/vapid-keys.js';
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js'; import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
@@ -274,4 +273,14 @@ router.post('/push/unsubscribe', async (req, res) => {
} }
}); });
// Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
router.get('/server-env', async (req, res) => {
try {
res.json({ platform: process.platform });
} catch (error) {
console.error('Error reading server environment:', error);
res.status(500).json({ error: 'Failed to read server environment' });
}
});
export default router; export default router;

View File

@@ -1,82 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeModelsCommand } from '../commands.js';
import { providerModelsService } from '../../modules/providers/services/provider-models.service.js';
test('models command returns available models only for the active provider', async () => {
const originalGetProviderModels = providerModelsService.getProviderModels;
const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
let getCurrentActiveModelCalls = 0;
providerModelsService.getProviderModels = async () => ({
models: {
OPTIONS: [{ value: 'gpt-5.4', label: 'gpt-5.4' }],
DEFAULT: 'gpt-5.4',
},
cache: {
updatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-04T00:00:00.000Z',
source: 'fresh',
},
});
providerModelsService.getCurrentActiveModel = async () => {
getCurrentActiveModelCalls += 1;
return {
model: 'gpt-5.3-codex',
};
};
try {
const result = await executeModelsCommand([], {
provider: 'codex',
model: 'gpt-5.4',
});
assert.equal(result.type, 'builtin');
assert.equal(result.action, 'models');
assert.equal(result.data.current.provider, 'codex');
assert.equal(result.data.current.model, 'gpt-5.4');
assert.deepEqual(Object.keys(result.data.available), ['codex']);
assert.deepEqual(result.data.available.codex, result.data.availableModels);
assert.ok(result.data.availableModels.includes('gpt-5.4'));
assert.equal(result.data.available.claude, undefined);
assert.equal(result.data.available.cursor, undefined);
assert.equal(getCurrentActiveModelCalls, 0);
} finally {
providerModelsService.getProviderModels = originalGetProviderModels;
providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
}
});
test('models command falls back to claude for unsupported providers', async () => {
const originalGetProviderModels = providerModelsService.getProviderModels;
const originalGetCurrentActiveModel = providerModelsService.getCurrentActiveModel;
providerModelsService.getProviderModels = async () => ({
models: {
OPTIONS: [{ value: 'default', label: 'Default (recommended)' }],
DEFAULT: 'default',
},
cache: {
updatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-04T00:00:00.000Z',
source: 'fresh',
},
});
providerModelsService.getCurrentActiveModel = async () => ({
model: 'default',
});
try {
const result = await executeModelsCommand([], {
provider: 'unknown-provider',
});
assert.equal(result.data.current.provider, 'claude');
assert.deepEqual(Object.keys(result.data.available), ['claude']);
} finally {
providerModelsService.getProviderModels = originalGetProviderModels;
providerModelsService.getCurrentActiveModel = originalGetCurrentActiveModel;
}
});

View File

@@ -98,44 +98,6 @@ function normalizeSessionName(sessionName) {
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
} }
function rowMatchesProvider(row, provider) {
return row && (!provider || row.provider === provider);
}
function resolveSessionRow(sessionId, provider) {
if (!sessionId) {
return null;
}
const appSessionRow = sessionsDb.getSessionById(sessionId);
if (rowMatchesProvider(appSessionRow, provider)) {
return appSessionRow;
}
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
if (rowMatchesProvider(providerSessionRow, provider)) {
return providerSessionRow;
}
return null;
}
function normalizeNotificationSession(event) {
if (!event?.sessionId || !event.provider || event.provider === 'system') {
return event;
}
const row = resolveSessionRow(event.sessionId, event.provider);
if (!row || row.session_id === event.sessionId) {
return event;
}
return {
...event,
sessionId: row.session_id
};
}
function resolveSessionName(event) { function resolveSessionName(event) {
const explicitSessionName = normalizeSessionName(event.meta?.sessionName); const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
if (explicitSessionName) { if (explicitSessionName) {
@@ -150,29 +112,28 @@ function resolveSessionName(event) {
} }
function buildPushBody(event) { function buildPushBody(event) {
const normalizedEvent = normalizeNotificationSession(event);
const CODE_MAP = { const CODE_MAP = {
'permission.required': normalizedEvent.meta?.toolName 'permission.required': event.meta?.toolName
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval` ? `Action Required: Tool "${event.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval', : 'Action Required: A tool needs your approval',
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped', 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error', 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification', 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!' 'push.enabled': 'Push notifications are now enabled!'
}; };
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant'; const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant';
const sessionName = resolveSessionName(normalizedEvent); const sessionName = resolveSessionName(event);
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification'; const message = CODE_MAP[event.code] || 'You have a new notification';
return { return {
title: sessionName || 'CloudCLI', title: sessionName || 'CloudCLI',
body: `${providerLabel}: ${message}`, body: `${providerLabel}: ${message}`,
data: { data: {
sessionId: normalizedEvent.sessionId || null, sessionId: event.sessionId || null,
code: normalizedEvent.code, code: event.code,
provider: normalizedEvent.provider || null, provider: event.provider || null,
sessionName, sessionName,
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}` tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}`
} }
}; };
} }
@@ -214,16 +175,15 @@ function notifyUserIfEnabled({ userId, event }) {
return; return;
} }
const normalizedEvent = normalizeNotificationSession(event);
const preferences = notificationPreferencesDb.getPreferences(userId); const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, normalizedEvent)) { if (!shouldSendPush(preferences, event)) {
return; return;
} }
if (isDuplicate(normalizedEvent)) { if (isDuplicate(event)) {
return; return;
} }
sendWebPush(userId, normalizedEvent).catch((err) => { sendWebPush(userId, event).catch((err) => {
console.error('Web push send error:', err); console.error('Web push send error:', err);
}); });
} }

View File

@@ -1,80 +0,0 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import webPush from 'web-push';
import {
closeConnection,
initializeDatabase,
notificationPreferencesDb,
pushSubscriptionsDb,
sessionsDb,
userDb,
} from '../modules/database/index.js';
import { notifyRunStopped } from './notification-orchestrator.js';
async function withIsolatedDatabase(runTest) {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
test('push payload uses the app session id when notified with a provider session id', async () => {
const originalSendNotification = webPush.sendNotification;
const sentPayloads = [];
webPush.sendNotification = async (_subscription, payload) => {
sentPayloads.push(JSON.parse(payload));
return {};
};
try {
await withIsolatedDatabase(async () => {
const user = userDb.createUser('notify-user', 'hash');
const userId = Number(user.id);
notificationPreferencesDb.updatePreferences(userId, {
channels: { webPush: true },
events: { actionRequired: true, stop: true, error: true },
});
pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth');
sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo');
sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1');
notifyRunStopped({
userId,
provider: 'claude',
sessionId: 'claude-native-1',
stopReason: 'completed',
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(sentPayloads.length, 1);
assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1');
assert.match(sentPayloads[0]?.data?.tag, /app-session-1/);
});
} finally {
webPush.sendNotification = originalSendNotification;
}
});

View File

@@ -7,11 +7,7 @@ import type {
ProviderSkill, ProviderSkill,
ProviderSkillListOptions, ProviderSkillListOptions,
ProviderAuthStatus, ProviderAuthStatus,
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderMcpServer, ProviderMcpServer,
ProviderSessionActiveModelChange,
UpsertProviderMcpServerInput, UpsertProviderMcpServerInput,
} from '@/shared/types.js'; } from '@/shared/types.js';
@@ -24,7 +20,6 @@ import type {
*/ */
export interface IProvider { export interface IProvider {
readonly id: LLMProvider; readonly id: LLMProvider;
readonly models: IProviderModels;
readonly mcp: IProviderMcp; readonly mcp: IProviderMcp;
readonly auth: IProviderAuth; readonly auth: IProviderAuth;
readonly skills: IProviderSkills; readonly skills: IProviderSkills;
@@ -32,46 +27,6 @@ export interface IProvider {
readonly sessionSynchronizer: IProviderSessionSynchronizer; readonly sessionSynchronizer: IProviderSessionSynchronizer;
} }
// ---------------------------
//----------------- PROVIDER MODEL INTERFACE ------------
/**
* Model catalog contract for one provider.
*
* Implementations are responsible for resolving the provider's currently
* supported models and converting them into the shared
* `ProviderModelsDefinition` shape used by backend routes and frontend model
* pickers. The `DEFAULT` field should be the most appropriate default selection
* for that provider at the time the catalog is read.
*/
export interface IProviderModels {
/**
* Returns the provider's currently supported model catalog.
*/
getSupportedModels(): Promise<ProviderModelsDefinition>;
/**
* Returns the currently active model for one session or provider runtime.
*
* Implementations must use the provider-specific lookup mechanism approved
* for that provider and fall back only to the provider catalog default when
* no active model can be resolved.
*/
getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel>;
/**
* Persists a session-scoped model override that the next resumed turn should
* honor for this provider.
*
* This does not require the provider to mutate an already running remote
* session in-place. Instead, adapters store the user's explicit model choice
* so the backend resume path can add the correct provider-native model option
* on the next CLI/SDK invocation for the same session.
*/
changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange>;
}
// --------------------------- // ---------------------------
//----------------- PROVIDER AUTH INTERFACE ------------ //----------------- PROVIDER AUTH INTERFACE ------------
/** /**

View File

@@ -1,42 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sliceTailPage } from '@/shared/utils.js';
const ITEMS = ['a', 'b', 'c', 'd', 'e'];
test('offset 0 returns the most recent page', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 2, 0);
assert.deepEqual(page, ['d', 'e']);
assert.equal(hasMore, true);
});
test('increasing offsets walk backwards in time', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 2, 2);
assert.deepEqual(page, ['b', 'c']);
assert.equal(hasMore, true);
});
test('the oldest page reports hasMore false', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 2, 4);
assert.deepEqual(page, ['a']);
assert.equal(hasMore, false);
});
test('null limit returns everything', () => {
const { page, hasMore } = sliceTailPage(ITEMS, null, 0);
assert.deepEqual(page, ITEMS);
assert.equal(hasMore, false);
});
test('offsets past the start return an empty page', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 3, 10);
assert.deepEqual(page, []);
assert.equal(hasMore, false);
});
test('zero limit returns an empty page but keeps hasMore accurate', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 0, 0);
assert.deepEqual(page, []);
assert.equal(hasMore, true);
});

View File

@@ -65,94 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
* Use this as the source of truth whenever a function or payload needs to identify * Use this as the source of truth whenever a function or payload needs to identify
* a specific LLM integration. * a specific LLM integration.
*/ */
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode'; export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
/**
* One selectable model row in a provider model catalog.
*/
export type ProviderModelOption = {
value: string;
label: string;
description?: string;
};
/**
* Provider model catalog returned by `GET /api/providers/:provider/models`.
*/
export type ProviderModelsDefinition = {
OPTIONS: ProviderModelOption[];
DEFAULT: string;
};
/**
* Cache metadata returned alongside one provider model catalog.
*
* `updatedAt` is when the current cached snapshot was last refreshed from the
* provider itself. `expiresAt` is the backend cache expiry timestamp, and
* `source` tells callers whether the current response came from in-memory cache,
* persisted disk cache, or a fresh provider fetch.
*/
export type ProviderModelsCacheInfo = {
updatedAt: string;
expiresAt: string;
source: 'memory' | 'disk' | 'fresh';
};
/**
* Full provider model lookup result returned by the backend service layer.
*
* Use this shape when a caller needs both the selectable model catalog and the
* cache metadata that explains how current the catalog is.
*/
export type ProviderModelsResult = {
models: ProviderModelsDefinition;
cache: ProviderModelsCacheInfo;
};
// ---------------------------
//----------------- PROVIDER ACTIVE MODEL TYPES ------------
/**
* Provider-neutral result for the model that is actively driving a session or
* provider runtime at the time of lookup.
*
* `model` must always be populated. Provider adapters should use the
* provider-specific lookup method requested by the caller, and only fall back
* to the provider catalog `DEFAULT` value when the active model cannot be read.
*/
export type ProviderCurrentActiveModel = {
model: string;
};
/**
* Input payload used when one session needs to use a different model on its
* next resumed turn.
*
* This is a backend-owned session override, not a claim that the provider has
* already switched the currently running session in-place. Provider adapters
* persist this request so the next CLI/SDK resume can inject the chosen model
* using the provider-specific mechanism supported by that runtime.
*/
export type ProviderChangeActiveModelInput = {
sessionId: string;
model: string;
};
/**
* Provider-neutral session model-change state.
*
* `supported` indicates whether the provider adapter supports the app's
* session-scoped resume override flow. `changed` is the persisted boolean the
* resume layer checks before forcing a model on the next resumed turn. When
* `changed` is `false`, `model` is `null` and the runtime should use the
* normal request/default model selection path.
*/
export type ProviderSessionActiveModelChange = {
provider: LLMProvider;
sessionId: string;
supported: boolean;
changed: boolean;
model: string | null;
};
/** /**
* Message/event variants emitted by provider adapters and normalized transports. * Message/event variants emitted by provider adapters and normalized transports.
@@ -175,30 +88,6 @@ export type MessageKind =
| 'interactive_prompt' | 'interactive_prompt'
| 'task_notification'; | 'task_notification';
/**
* Event kinds added by the chat gateway layer on top of provider message kinds.
*
* These are app-level realtime events (subscription acks, sidebar deltas,
* project loading progress, protocol failures) that are not produced by any
* provider adapter. Together with `MessageKind` they form the complete set of
* `kind` values a websocket client can receive, so the frontend only ever
* needs one kind-based switch.
*/
export type GatewayEventKind =
| 'chat_subscribed'
| 'session_upserted'
| 'loading_progress'
| 'protocol_error';
/**
* Complete set of `kind` values emitted to websocket clients.
*
* Every server-to-client websocket frame carries a `kind` from this union.
* Provider runtimes emit `MessageKind` values; gateway services emit
* `GatewayEventKind` values.
*/
export type ServerEventKind = MessageKind | GatewayEventKind;
/** /**
* Provider-neutral message envelope used in REST responses and realtime channels. * Provider-neutral message envelope used in REST responses and realtime channels.
* *
@@ -211,13 +100,6 @@ export type NormalizedMessage = {
timestamp: string; timestamp: string;
provider: LLMProvider; provider: LLMProvider;
kind: MessageKind; kind: MessageKind;
/**
* Monotonic per-run sequence number assigned by the chat run registry when a
* live event is forwarded to the websocket. History messages loaded over
* REST do not carry it. Clients use it with `chat.subscribe` to replay only
* the live events they missed across websocket reconnects.
*/
seq?: number;
role?: 'user' | 'assistant'; role?: 'user' | 'assistant';
content?: string; content?: string;
/** /**
@@ -268,18 +150,11 @@ export type NormalizedMessage = {
* *
* Consumers should pass provider-specific lookup hints (`projectPath`) only * Consumers should pass provider-specific lookup hints (`projectPath`) only
* when the selected provider requires them. * when the selected provider requires them.
*
* `providerSessionId` is the provider-native session id from the sessions
* index (transcript file name / provider database key). Provider adapters
* must use it — never the app-facing session id they were called with — when
* matching transcript rows on disk, because app-created sessions use an
* app-allocated id that the provider has never seen.
*/ */
export type FetchHistoryOptions = { export type FetchHistoryOptions = {
projectPath?: string; projectPath?: string;
limit?: number | null; limit?: number | null;
offset?: number; offset?: number;
providerSessionId?: string;
}; };
/** /**

View File

@@ -22,13 +22,7 @@ import type {
AnyRecord, AnyRecord,
ApiSuccessShape, ApiSuccessShape,
AppErrorOptions, AppErrorOptions,
LLMProvider,
NormalizedMessage, NormalizedMessage,
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
ProviderSkillSource,
WorkspacePathValidationResult, WorkspacePathValidationResult,
} from '@/shared/types.js'; } from '@/shared/types.js';
@@ -346,84 +340,6 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
}; };
} }
/**
* Build the unified terminal `complete` lifecycle message.
*
* Contract: every provider run ends with exactly one `complete` (the
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
* must NOT emit their own). The frontend treats `complete` as the only
* terminal signal and never needs provider-specific handling:
*
* - `sessionId` — the id the client knows this run by ('' if never discovered)
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
* the provider rewrote it mid-run
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
* is reported as failure
* - `success` — exitCode === 0 and not aborted
* - `aborted` — run was cancelled by the user
*/
export function createCompleteMessage(opts: {
provider: NormalizedMessage['provider'];
sessionId?: string | null;
actualSessionId?: string | null;
exitCode?: number | null;
aborted?: boolean;
}): NormalizedMessage {
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
const aborted = Boolean(opts.aborted);
return createNormalizedMessage({
kind: 'complete',
provider: opts.provider,
sessionId: opts.sessionId || null,
actualSessionId: opts.actualSessionId || opts.sessionId || null,
exitCode,
success: exitCode === 0 && !aborted,
aborted,
});
}
// ---------------------------
//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------
/**
* Slices one page from the END of a chronologically ordered message list.
*
* This is the single pagination contract for conversation history across all
* providers: `offset = 0` returns the most recent `limit` items, increasing
* offsets walk backwards in time (for "scroll up to load older" UIs), and a
* `null` limit returns everything. Items must already be sorted oldest-first;
* the returned page preserves that order.
*
* Every provider history reader must use this helper instead of slicing
* manually so `offset`/`limit` query params behave identically regardless of
* which provider produced the session.
*/
export function sliceTailPage<T>(
items: T[],
limit: number | null,
offset: number,
): { page: T[]; hasMore: boolean } {
const total = items.length;
const normalizedOffset = Math.max(0, offset);
if (limit === null) {
// A null limit returns the full list; offset still trims newest entries
// so "everything before the page I already have" stays expressible.
const end = Math.max(0, total - normalizedOffset);
return {
page: items.slice(0, end),
hasMore: false,
};
}
const end = Math.max(0, total - normalizedOffset);
const start = Math.max(0, end - Math.max(0, limit));
return {
page: items.slice(start, end),
hasMore: start > 0,
};
}
// --------------------------- // ---------------------------
//----------------- MCP CONFIG PARSING UTILITIES ------------ //----------------- MCP CONFIG PARSING UTILITIES ------------
/** /**
@@ -497,231 +413,6 @@ export const readStringRecord = (value: unknown): Record<string, string> | undef
return Object.keys(normalized).length > 0 ? normalized : undefined; return Object.keys(normalized).length > 0 ? normalized : undefined;
}; };
// ---------------------------
//----------------- PROVIDER MODEL LOOKUP UTILITIES ------------
/**
* Builds the standard "default current model" result used when a provider
* cannot resolve a session-backed active model.
*
* Provider model adapters should call this after loading their supported model
* catalog so the fallback stays aligned with the provider's current `DEFAULT`
* selection instead of drifting to a hard-coded duplicate.
*/
export function buildDefaultProviderCurrentActiveModel(
models: ProviderModelsDefinition,
): ProviderCurrentActiveModel {
return {
model: models.DEFAULT,
};
}
// ---------------------------
//----------------- PROVIDER SESSION MODEL CHANGE UTILITIES ------------
type ProviderSessionActiveModelChangeCacheEntry = ProviderSessionActiveModelChange & {
updatedAt: string;
};
type ProviderSessionActiveModelChangeCacheFile = {
version: number;
entries: Record<string, ProviderSessionActiveModelChangeCacheEntry>;
};
const PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION = 1;
/**
* Resolves the backend-owned cache file used for session-scoped resume model
* overrides.
*
* The file lives under `~/.cloudcli` because these overrides are an application
* concern rather than a provider-native config file. Providers, routes, and
* runtime command launchers should all use this helper instead of re-creating
* the path so the storage location stays consistent.
*/
export function getProviderSessionActiveModelChangesPath(): string {
return path.join(os.homedir(), '.cloudcli', 'provider-session-active-model-changes.json');
}
const buildProviderSessionActiveModelChangeKey = (
provider: LLMProvider,
sessionId: string,
): string => `${provider}:${sessionId}`;
const isProviderSessionActiveModelChangeCacheEntry = (
value: unknown,
): value is ProviderSessionActiveModelChangeCacheEntry => {
const record = readObjectRecord(value);
return Boolean(
record
&& typeof record.provider === 'string'
&& typeof record.sessionId === 'string'
&& typeof record.supported === 'boolean'
&& typeof record.changed === 'boolean'
&& (typeof record.model === 'string' || record.model === null)
&& typeof record.updatedAt === 'string',
);
};
const readProviderSessionActiveModelChangeCacheFile = async (
filePath: string,
): Promise<ProviderSessionActiveModelChangeCacheFile> => {
try {
const raw = await readFile(filePath, 'utf8');
const parsed = readObjectRecord(JSON.parse(raw));
if (
!parsed
|| parsed.version !== PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION
|| !readObjectRecord(parsed.entries)
) {
return {
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
entries: {},
};
}
const entries = Object.fromEntries(
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderSessionActiveModelChangeCacheEntry] =>
isProviderSessionActiveModelChangeCacheEntry(entry[1]),
),
);
return {
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
entries,
};
} catch {
return {
version: PROVIDER_SESSION_ACTIVE_MODEL_CHANGE_CACHE_VERSION,
entries: {},
};
}
};
const writeProviderSessionActiveModelChangeCacheFile = async (
filePath: string,
payload: ProviderSessionActiveModelChangeCacheFile,
): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
};
const buildUnsupportedProviderSessionActiveModelChange = (
provider: LLMProvider,
sessionId: string,
): ProviderSessionActiveModelChange => ({
provider,
sessionId,
supported: false,
changed: false,
model: null,
});
/**
* Reads the persisted session model-change state for one provider session.
*
* Runtime resume paths use this to decide whether they should inject a
* provider-specific model argument/thread option for the next resumed turn.
* Missing cache entries are normalized to `{ changed: false }` so callers can
* treat absence as "use the ordinary model selection flow".
*/
export async function readProviderSessionActiveModelChange(
provider: LLMProvider,
sessionId: string,
options: {
filePath?: string;
supported?: boolean;
} = {},
): Promise<ProviderSessionActiveModelChange> {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
}
const supported = options.supported ?? true;
if (!supported) {
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
}
const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
const cacheEntry = cacheFile.entries[
buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)
];
if (!cacheEntry || !cacheEntry.changed || !cacheEntry.model?.trim()) {
return {
provider,
sessionId: normalizedSessionId,
supported: true,
changed: false,
model: null,
};
}
return {
provider,
sessionId: normalizedSessionId,
supported: true,
changed: true,
model: cacheEntry.model.trim(),
};
}
/**
* Persists a session model-change request for one provider.
*
* Provider adapters call this when the frontend explicitly selects a different
* model for an existing session. The stored `changed: true` flag is the single
* source of truth used later by resume paths to decide whether they should add
* a provider-native model override on the next invocation.
*/
export async function writeProviderSessionActiveModelChange(
provider: LLMProvider,
input: ProviderChangeActiveModelInput,
options: {
filePath?: string;
supported?: boolean;
} = {},
): Promise<ProviderSessionActiveModelChange> {
const normalizedSessionId = input.sessionId.trim();
const normalizedModel = input.model.trim();
const supported = options.supported ?? true;
if (!supported) {
return buildUnsupportedProviderSessionActiveModelChange(provider, normalizedSessionId);
}
if (!normalizedSessionId || !normalizedModel) {
return {
provider,
sessionId: normalizedSessionId,
supported: true,
changed: false,
model: null,
};
}
const filePath = options.filePath ?? getProviderSessionActiveModelChangesPath();
const cacheFile = await readProviderSessionActiveModelChangeCacheFile(filePath);
cacheFile.entries[buildProviderSessionActiveModelChangeKey(provider, normalizedSessionId)] = {
provider,
sessionId: normalizedSessionId,
supported: true,
changed: true,
model: normalizedModel,
updatedAt: new Date().toISOString(),
};
await writeProviderSessionActiveModelChangeCacheFile(filePath, cacheFile);
return {
provider,
sessionId: normalizedSessionId,
supported: true,
changed: true,
model: normalizedModel,
};
}
// --------------------------- // ---------------------------
//----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------ //----------------- WEBSOCKET PAYLOAD PARSING UTILITIES ------------
/** /**
@@ -815,67 +506,6 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
// --------------------------- // ---------------------------
//----------------- PROVIDER SKILL FILE UTILITIES ------------ //----------------- PROVIDER SKILL FILE UTILITIES ------------
async function hasGitMarker(dirPath: string): Promise<boolean> {
try {
const gitMarkerStats = await stat(path.join(dirPath, '.git'));
return gitMarkerStats.isDirectory() || gitMarkerStats.isFile();
} catch {
return false;
}
}
/**
* Finds the highest git worktree root visible from a starting directory.
*
* Provider skill systems such as Codex and OpenCode walk upward through parent
* folders when resolving repository/project skills. Use this helper when a
* provider needs the topmost `.git` marker instead of only the nearest one, so
* monorepos and nested package folders discover shared root-level skills once.
*/
export async function findTopmostGitRoot(startPath: string): Promise<string | null> {
let currentPath = path.resolve(startPath);
let topmostGitRoot: string | null = null;
while (true) {
if (await hasGitMarker(currentPath)) {
topmostGitRoot = currentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
currentPath = parentPath;
}
return topmostGitRoot;
}
/**
* Adds one provider skill source after normalizing and de-duplicating its root.
*
* Provider skill lookup rules often point at overlapping folders (for example a
* workspace folder can also be the git root). Use this helper while building a
* provider's `ProviderSkillSource[]` so the shared skills scanner reads each
* physical root once and still preserves provider-specific scope/command data.
*/
export function addUniqueProviderSkillSource(
sources: ProviderSkillSource[],
seenRootDirs: Set<string>,
source: ProviderSkillSource,
): void {
const normalizedRootDir = path.resolve(source.rootDir);
if (seenRootDirs.has(normalizedRootDir)) {
return;
}
seenRootDirs.add(normalizedRootDir);
sources.push({ ...source, rootDir: normalizedRootDir });
}
// ---------------------------
//----------------- PROVIDER SKILL MARKDOWN UTILITIES ------------
/** /**
* Finds direct child skill markdown files under a provider skill root. * Finds direct child skill markdown files under a provider skill root.
* *
@@ -986,98 +616,6 @@ export function normalizeSessionName(rawValue: string | undefined, fallback: str
return normalized.slice(0, 120); return normalized.slice(0, 120);
} }
// ---------------------------
//----------------- PROVIDER SESSION VALUE NORMALIZATION UTILITIES ------------
/**
* Converts provider-native timestamps into ISO strings.
*
* Provider CLIs commonly persist epoch timestamps as milliseconds, seconds, or
* already-formatted date strings. Use this helper when normalizing session
* metadata or transcript events so every provider writes the same ISO timestamp
* shape to API responses and database rows.
*/
export function normalizeProviderTimestamp(value: unknown): string {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
const millis = value < 1_000_000_000_000 ? value * 1000 : value;
return new Date(millis).toISOString();
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return normalizeProviderTimestamp(parsed);
}
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
return date.toISOString();
}
}
return new Date().toISOString();
}
/**
* Parses a JSON string or narrows an existing object into a plain record.
*
* Use this when provider databases store structured JSON inside text columns.
* Invalid JSON, arrays, and primitive values return `null` so callers can skip
* malformed optional metadata without hiding the rest of a session transcript.
*/
export function readJsonRecord(value: unknown): AnyRecord | null {
if (typeof value !== 'string') {
return readObjectRecord(value);
}
try {
return readObjectRecord(JSON.parse(value));
} catch {
return null;
}
}
// ---------------------------
//----------------- OPENCODE SESSION STORAGE UTILITIES ------------
/**
* Resolves the OpenCode SQLite session database path.
*
* OpenCode stores session, message, part, and project metadata in one shared
* `opencode.db` file under its XDG data directory. Provider readers and
* synchronizers should use this path for read-only access and should never store
* it as a deletable transcript path for an individual app session row.
*/
export function getOpenCodeDatabasePath(): string {
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
}
// ---------------------------
//----------------- SAFE DIRECTORY NAME UTILITIES ------------
/**
* Validates that a user or provider supplied identifier can safely be treated
* as one leaf directory name under an existing root folder.
*
* Use this before composing paths like `<root>/<session-id>/file.db>` to block
* path traversal and accidental nested paths. The returned string is trimmed but
* otherwise unchanged so callers can still match the provider's on-disk naming.
*/
export function sanitizeLeafDirectoryName(inputName: string, label = 'directory name'): string {
const normalized = inputName.trim();
if (!normalized) {
throw new Error(`${label} is required.`);
}
if (
normalized.includes('..')
|| normalized.includes(path.posix.sep)
|| normalized.includes(path.win32.sep)
|| normalized !== path.basename(normalized)
) {
throw new Error(`Invalid ${label} "${inputName}".`);
}
return normalized;
}
// --------------------------- // ---------------------------
//----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------ //----------------- SESSION SYNCHRONIZER FILESYSTEM HELPERS ------------
/** /**

View File

@@ -1,8 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { spawn } from 'child_process';
import { spawn } from 'cross-spawn';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');

View File

@@ -7,41 +7,6 @@ const runningPlugins = new Map();
// Map<pluginName, Promise<port>> — in-flight start operations // Map<pluginName, Promise<port>> — in-flight start operations
const startingPlugins = new Map(); const startingPlugins = new Map();
/**
* Build the environment handed to a plugin server subprocess.
*
* Intentionally minimal: only non-secret essentials, never the host's full
* environment. On Windows a handful of system variables are required for any
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
* to). Without APPDATA a `pip install --user` tool cannot locate its
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
* resolve system DLLs, executable extensions and a temp directory. None of
* these carry secrets, so the ones that are set get passed straight through.
*/
function buildPluginEnv(name) {
const env = {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
};
if (process.platform === 'win32') {
const WINDOWS_ESSENTIALS = [
'SystemRoot', 'windir', 'SystemDrive',
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
'TEMP', 'TMP', 'PATHEXT',
];
for (const key of WINDOWS_ESSENTIALS) {
if (process.env[key] !== undefined) {
env[key] = process.env[key];
}
}
}
return env;
}
/** /**
* Start a plugin's server subprocess. * Start a plugin's server subprocess.
* The plugin's server entry must print a JSON line with { ready: true, port: <number> } * The plugin's server entry must print a JSON line with { ready: true, port: <number> }
@@ -61,9 +26,15 @@ export function startPluginServer(name, pluginDir, serverEntry) {
const serverPath = path.join(pluginDir, serverEntry); const serverPath = path.join(pluginDir, serverEntry);
// Restricted env — only essentials, no host secrets
const pluginProcess = spawn('node', [serverPath], { const pluginProcess = spawn('node', [serverPath], {
cwd: pluginDir, cwd: pluginDir,
env: buildPluginEnv(name), env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
NODE_ENV: process.env.NODE_ENV || 'production',
PLUGIN_NAME: name,
},
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });

107
shared/modelConstants.js Normal file
View File

@@ -0,0 +1,107 @@
/**
* Centralized Model Definitions
* Single source of truth for all supported AI models
*/
/**
* Claude (Anthropic) Models
*
* Note: Claude uses two different formats:
* - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js
* - API format ('claude-sonnet-4.5') - used by slash commands for display
*/
export const CLAUDE_MODELS = {
// Models in SDK format (what the actual SDK accepts)
OPTIONS: [
{ value: "opus", label: "Opus" },
{ value: "sonnet", label: "Sonnet" },
{ value: "haiku", label: "Haiku" },
{ value: "claude-opus-4-6", label: "Opus 4.6" },
{ value: "opusplan", label: "Opus Plan" },
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
{ value: "opus[1m]", label: "Opus [1M]" },
],
DEFAULT: "opus",
};
/**
* Cursor Models
*/
export const CURSOR_MODELS = {
OPTIONS: [
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "gpt-5.1", label: "GPT-5.1" },
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
{ value: "composer-1", label: "Composer 1" },
{ value: "auto", label: "Auto" },
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
{ value: "grok", label: "Grok" },
],
DEFAULT: "gpt-5.3-codex",
};
/**
* Codex (OpenAI) Models
*/
export const CODEX_MODELS = {
OPTIONS: [
{ value: "gpt-5.5", label: "GPT-5.5" },
{ value: "gpt-5.4", label: "GPT-5.4" },
{ value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ value: "gpt-5.2", label: "GPT-5.2" },
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ value: "o3", label: "O3" },
{ value: "o4-mini", label: "O4-mini" },
],
DEFAULT: "gpt-5.4",
};
/**
* Gemini Models
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
{ value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
{
value: "gemini-2.0-flash-thinking-exp",
label: "Gemini 2.0 Flash Thinking",
},
],
DEFAULT: "gemini-3.1-pro-preview",
};
/**
* Ordered provider registry. Display order in selection UIs.
*/
export const PROVIDERS = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
];

View File

@@ -1,6 +1,5 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, ProtectedRoute } from './components/auth'; import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext';
@@ -10,99 +9,7 @@ import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent'; import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js'; import i18n from './i18n/config.js';
const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']);
/**
* Detect the router basename from explicit runtime config or deployment hints.
*
* CloudCLI can be served from a path prefix by a reverse proxy, for example:
* /ai/manifest.json
* /ai/assets/index-abc123.js
* /ai/icons/icon-192x192.png
*
* React Router needs that prefix as its basename, but the packaged app should
* also keep working when served directly from the domain root. The direct-root
* case is easy to misread because asset URLs such as /icons/icon-192x192.png
* contain a directory even though there is no application basename.
*/
function detectRouterBasename() {
const explicitBasename = typeof window !== 'undefined' ? window.__ROUTER_BASENAME__ || '' : '';
if (explicitBasename) {
// Keep the deployment escape hatch authoritative. A trailing slash is
// harmless for humans but React Router expects a normalized basename.
return explicitBasename.replace(/\/+$/, '');
}
if (typeof window === 'undefined' || typeof document === 'undefined') {
return '';
}
const candidatePaths = [
{ kind: 'manifest' as const, value: document.querySelector('link[rel="manifest"]')?.getAttribute('href') },
{ kind: 'script' as const, value: document.querySelector('script[type="module"][src]')?.getAttribute('src') },
...Array.from(
document.querySelectorAll(
'link[rel~="icon"][href], link[rel="apple-touch-icon"][href], link[rel="apple-touch-icon-precomposed"][href], link[rel="mask-icon"][href]'
)
).map((node) => ({
kind: 'icon' as const,
value: node.getAttribute('href'),
})),
].filter((candidate): candidate is { kind: 'manifest' | 'script' | 'icon'; value: string } => Boolean(candidate.value));
let detectedBasename = '';
for (const candidate of candidatePaths) {
try {
const candidateUrl = new URL(candidate.value, document.baseURI || window.location.href);
if (candidateUrl.origin !== window.location.origin) {
continue;
}
const pathname = candidateUrl.pathname;
const normalizedPathname = pathname.replace(/\/+$/, '');
let normalized = '';
if (candidate.kind === 'script') {
const match = normalizedPathname.match(/^(.*)\/assets\//);
normalized = match?.[1] ? match[1].replace(/\/+$/, '') : '';
} else {
const manifestMatch = normalizedPathname.match(/^(.*)\/(?:manifest\.json|site\.webmanifest)$/);
const iconMatch = normalizedPathname.match(
/^(.*)\/(?:favicon(?:\.[^/]+)?|apple-touch-icon(?:-[^/]+)?(?:\.[^/]+)?|mask-icon(?:\.[^/]+)?|[^/]*icon[^/]*)$/
);
const match = candidate.kind === 'manifest' ? manifestMatch : iconMatch;
if (match?.[1]) {
const segments = match[1].split('/').filter(Boolean);
// Strip directories that describe where static files live, not where
// the app is mounted. This must also run for a single segment:
// /icons/icon-192x192.png -> ''
// /ai/icons/icon-192x192.png -> '/ai'
// The previous implementation only stripped while more than one
// segment remained, which incorrectly turned root deployments into a
// Router basename of /icons and caused a blank page after login.
while (segments.length > 0 && DEPLOYMENT_ASSET_DIRECTORIES.has(segments[segments.length - 1])) {
segments.pop();
}
normalized = segments.length > 0 ? `/${segments.join('/')}` : '';
}
}
if (normalized.length > detectedBasename.length) {
detectedBasename = normalized;
}
} catch {
// Ignore invalid candidate URLs and continue checking other hints.
}
}
return detectedBasename;
}
export default function App() { export default function App() {
const routerBasename = detectRouterBasename();
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<ThemeProvider> <ThemeProvider>
@@ -112,7 +19,7 @@ export default function App() {
<TasksSettingsProvider> <TasksSettingsProvider>
<TaskMasterProvider> <TaskMasterProvider>
<ProtectedRoute> <ProtectedRoute>
<Router basename={routerBasename}> <Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes> <Routes>
<Route path="/" element={<AppContent />} /> <Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} /> <Route path="/session/:sessionId" element={<AppContent />} />

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -10,33 +10,6 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState'; import { useProjectsState } from '../../hooks/useProjectsState';
import { api } from '../../utils/api';
type RunningSessionApiItem = {
sessionId?: unknown;
startedAt?: unknown;
statusText?: unknown;
canInterrupt?: unknown;
};
type RunningSessionsApiPayload = {
data?: {
sessions?: RunningSessionApiItem[];
};
};
const parseStartedAt = (value: unknown): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value !== 'string') {
return undefined;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
export default function AppContent() { export default function AppContent() {
return ( return (
@@ -51,13 +24,16 @@ function AppContentInner() {
const { sessionId } = useParams<{ sessionId?: string }>(); const { sessionId } = useParams<{ sessionId?: string }>();
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const { isMobile } = useDeviceSettings({ trackPWA: false }); const { isMobile } = useDeviceSettings({ trackPWA: false });
const { ws, sendMessage, subscribe } = useWebSocket(); const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
const wasConnectedRef = useRef(false);
const { const {
activeSessions,
processingSessions, processingSessions,
markSessionProcessing, markSessionAsActive,
markSessionIdle, markSessionAsInactive,
syncProcessingSessions, markSessionAsProcessing,
markSessionAsNotProcessing,
} = useSessionProtection(); } = useSessionProtection();
const { const {
@@ -74,64 +50,16 @@ function AppContentInner() {
setShowSettings, setShowSettings,
openSettings, openSettings,
refreshProjectsSilently, refreshProjectsSilently,
registerOptimisticSession,
sidebarSharedProps, sidebarSharedProps,
handleNewSession, handleNewSession,
} = useProjectsState({ } = useProjectsState({
sessionId, sessionId,
navigate, navigate,
subscribe, latestMessage,
isMobile, isMobile,
activeSessions: processingSessions, activeSessions,
}); });
const refreshRunningSessions = useCallback(async () => {
try {
const response = await api.runningSessions();
if (!response.ok) {
return;
}
const payload = (await response.json()) as RunningSessionsApiPayload;
const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : [];
syncProcessingSessions(
sessions
.map((session) => {
if (typeof session.sessionId !== 'string' || !session.sessionId) {
return null;
}
return {
sessionId: session.sessionId,
startedAt: parseStartedAt(session.startedAt),
statusText: typeof session.statusText === 'string' ? session.statusText : undefined,
canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined,
};
})
.filter((session): session is NonNullable<typeof session> => Boolean(session)),
);
} catch (error) {
console.error('[AppContent] Failed to sync running sessions:', error);
}
}, [syncProcessingSessions]);
useEffect(() => {
void refreshRunningSessions();
}, [refreshRunningSessions]);
useEffect(() => {
if (processingSessions.size === 0) {
return;
}
const interval = window.setInterval(() => {
void refreshRunningSessions();
}, 5000);
return () => window.clearInterval(interval);
}, [processingSessions.size, refreshRunningSessions]);
usePaletteOpsRegister({ usePaletteOpsRegister({
openSettings, openSettings,
refreshProjects: refreshProjectsSilently, refreshProjects: refreshProjectsSilently,
@@ -171,9 +99,23 @@ function AppContentInner() {
}; };
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
// Pending tool permissions are recovered through the `chat.subscribe` flow: // Permission recovery: query pending permissions on WebSocket reconnect or session change
// the `chat_subscribed` ack carries them on session open and on reconnect, useEffect(() => {
// so no separate permission-recovery message is needed here. const isReconnect = isConnected && !wasConnectedRef.current;
if (isReconnect) {
wasConnectedRef.current = true;
} else if (!isConnected) {
wasConnectedRef.current = false;
}
if (isConnected && selectedSession?.id) {
sendMessage({
type: 'get-pending-permissions',
sessionId: selectedSession.id
});
}
}, [isConnected, selectedSession?.id, sendMessage]);
// Adjust the app container to stay above the virtual keyboard on iOS Safari. // Adjust the app container to stay above the virtual keyboard on iOS Safari.
// On Chrome for Android the layout viewport already shrinks when the keyboard opens, // On Chrome for Android the layout viewport already shrinks when the keyboard opens,
@@ -238,19 +180,19 @@ function AppContentInner() {
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
ws={ws} ws={ws}
sendMessage={sendMessage} sendMessage={sendMessage}
latestMessage={latestMessage}
isMobile={isMobile} isMobile={isMobile}
onMenuClick={() => setSidebarOpen(true)} onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
onInputFocusChange={setIsInputFocused} onInputFocusChange={setIsInputFocused}
onSessionProcessing={markSessionProcessing} onSessionActive={markSessionAsActive}
onSessionIdle={markSessionIdle} onSessionInactive={markSessionAsInactive}
onSessionProcessing={markSessionAsProcessing}
onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions} processingSessions={processingSessions}
onNavigateToSession={(targetSessionId: string, options) => onNavigateToSession={(targetSessionId: string, options) =>
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
} }
onSessionEstablished={(targetSessionId, context) =>
registerOptimisticSession({ sessionId: targetSessionId, ...context })
}
onShowSettings={() => setShowSettings(true)} onShowSettings={() => setShowSettings(true)}
externalMessageUpdate={externalMessageUpdate} externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger} newSessionTrigger={newSessionTrigger}

View File

@@ -0,0 +1,44 @@
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
export const thinkingModes = [
{
id: 'none',
name: 'Standard',
description: 'Regular Claude response',
icon: null,
prefix: '',
color: 'text-gray-600'
},
{
id: 'think',
name: 'Think',
description: 'Basic extended thinking',
icon: Brain,
prefix: 'think',
color: 'text-blue-600'
},
{
id: 'think-hard',
name: 'Think Hard',
description: 'More thorough evaluation',
icon: Zap,
prefix: 'think hard',
color: 'text-purple-600'
},
{
id: 'think-harder',
name: 'Think Harder',
description: 'Deep analysis with alternatives',
icon: Sparkles,
prefix: 'think harder',
color: 'text-indigo-600'
},
{
id: 'ultrathink',
name: 'Ultrathink',
description: 'Maximum thinking budget',
icon: Atom,
prefix: 'ultrathink',
color: 'text-red-600'
}
];

View File

@@ -12,21 +12,25 @@ import type {
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage'; import { safeLocalStorage } from '../utils/chatStorage';
import type { import type {
ChatMessage, ChatMessage,
PendingPermissionRequest, PendingPermissionRequest,
PermissionMode, PermissionMode,
SessionEstablishedContext,
} from '../types/types'; } from '../types/types';
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting'; import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions'; import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands'; import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
interface UseChatComposerStateArgs { interface UseChatComposerStateArgs {
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -38,26 +42,24 @@ interface UseChatComposerStateArgs {
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
geminiModel: string; geminiModel: string;
opencodeModel: string;
isLoading: boolean; isLoading: boolean;
canAbortSession: boolean; canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onSessionProcessing?: MarkSessionProcessing; onSessionActive?: (sessionId?: string | null) => void;
/** onSessionProcessing?: (sessionId?: string | null) => void;
* Invoked with the freshly allocated session id when the user sends the
* first message of a brand-new conversation. The backend allocates the id
* via POST /api/providers/sessions BEFORE the websocket send, so the id is
* stable for the conversation's whole lifetime — the consumer navigates to
* /session/:id and records it as the current session.
*/
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void; scrollToBottom: () => void;
addMessage: (msg: ChatMessage) => void; addMessage: (msg: ChatMessage) => void;
clearMessages: () => void;
rewindMessages: (count: number) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>; setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
} }
@@ -76,69 +78,6 @@ interface CommandExecutionResult {
hasFileIncludes?: boolean; hasFileIncludes?: boolean;
} }
export type ModelCommandData = {
current?: {
provider?: string;
providerLabel?: string;
model?: string;
};
available?: Partial<Record<LLMProvider, string[]>>;
availableModels?: string[];
availableOptions?: Array<{
value: string;
label?: string;
description?: string;
}>;
defaultModel?: string;
cache?: ProviderModelsCacheInfo;
};
export type CostCommandData = {
tokenUsage?: {
used?: number;
total?: number;
};
tokenBreakdown?: {
input?: number;
output?: number;
};
provider?: string;
model?: string;
};
export type StatusCommandData = {
version?: string;
packageName?: string;
uptime?: string;
model?: string;
provider?: string;
nodeVersion?: string;
platform?: string;
pid?: number;
memoryUsage?: {
rssMb?: number;
heapUsedMb?: number;
heapTotalMb?: number;
};
};
export type HelpCommandData = {
content?: string;
format?: string;
commands?: Array<{
name: string;
description?: string;
namespace?: string;
}>;
};
export type CommandModalKind = 'help' | 'models' | 'cost' | 'status';
export type CommandModalPayload = {
kind: CommandModalKind;
data: HelpCommandData | ModelCommandData | CostCommandData | StatusCommandData;
};
const createFakeSubmitEvent = () => { const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>; return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
}; };
@@ -172,19 +111,24 @@ export function useChatComposerState({
claudeModel, claudeModel,
codexModel, codexModel,
geminiModel, geminiModel,
opencodeModel,
isLoading, isLoading,
canAbortSession, canAbortSession,
tokenBudget, tokenBudget,
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionEstablished,
onInputFocusChange, onInputFocusChange,
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
pendingViewSessionRef,
scrollToBottom, scrollToBottom,
addMessage, addMessage,
clearMessages,
rewindMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp, setIsUserScrolledUp,
setPendingPermissionRequests, setPendingPermissionRequests,
}: UseChatComposerStateArgs) { }: UseChatComposerStateArgs) {
@@ -200,7 +144,7 @@ export function useChatComposerState({
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map()); const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map()); const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null); const [thinkingMode, setThinkingMode] = useState('none');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null); const inputHighlightRef = useRef<HTMLDivElement>(null);
@@ -214,33 +158,35 @@ export function useChatComposerState({
(result: CommandExecutionResult) => { (result: CommandExecutionResult) => {
const { action, data } = result; const { action, data } = result;
switch (action) { switch (action) {
case 'clear':
clearMessages();
break;
case 'help': case 'help':
setCommandModalPayload({ addMessage({
kind: 'help', type: 'assistant',
data: (data || {}) as HelpCommandData, content: data.content,
timestamp: Date.now(),
}); });
break; break;
case 'models': case 'model':
setCommandModalPayload({ addMessage({
kind: 'models', type: 'assistant',
data: (data || {}) as ModelCommandData, content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
timestamp: Date.now(),
}); });
break; break;
case 'cost': { case 'cost': {
setCommandModalPayload({ const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
kind: 'cost', addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
data: (data || {}) as CostCommandData,
});
break; break;
} }
case 'status': { case 'status': {
setCommandModalPayload({ const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
kind: 'status', addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
data: (data || {}) as StatusCommandData,
});
break; break;
} }
@@ -267,17 +213,30 @@ export function useChatComposerState({
onShowSettings?.(); onShowSettings?.();
break; break;
case 'rewind':
if (data.error) {
addMessage({
type: 'assistant',
content: `Warning: ${data.message}`,
timestamp: Date.now(),
});
} else {
rewindMessages(data.steps * 2);
addMessage({
type: 'assistant',
content: `Rewound ${data.steps} step(s). ${data.message}`,
timestamp: Date.now(),
});
}
break;
default: default:
console.warn('Unknown built-in command action:', action); console.warn('Unknown built-in command action:', action);
} }
}, },
[onFileOpen, onShowSettings, addMessage], [onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],
); );
const closeCommandModal = useCallback(() => {
setCommandModalPayload(null);
}, []);
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => { const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
const { content, hasBashCommands } = result; const { content, hasBashCommands } = result;
@@ -308,7 +267,7 @@ export function useChatComposerState({
}, [addMessage]); }, [addMessage]);
const executeCommand = useCallback( const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => { async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) { if (!command || !selectedProject) {
return; return;
} }
@@ -326,15 +285,7 @@ export function useChatComposerState({
projectId: selectedProject.projectId, projectId: selectedProject.projectId,
sessionId: currentSessionId, sessionId: currentSessionId,
provider, provider,
model: provider === 'cursor' model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel,
tokenUsage: tokenBudget, tokenUsage: tokenBudget,
}; };
@@ -365,10 +316,8 @@ export function useChatComposerState({
const result = (await response.json()) as CommandExecutionResult; const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') { if (result.type === 'builtin') {
handleBuiltInCommand(result); handleBuiltInCommand(result);
if (!options?.preserveInput) { setInput('');
setInput(''); inputValueRef.current = '';
inputValueRef.current = '';
}
} else if (result.type === 'custom') { } else if (result.type === 'custom') {
await handleCustomCommand(result); await handleCustomCommand(result);
} }
@@ -388,7 +337,6 @@ export function useChatComposerState({
currentSessionId, currentSessionId,
cursorModel, cursorModel,
geminiModel, geminiModel,
opencodeModel,
handleBuiltInCommand, handleBuiltInCommand,
handleCustomCommand, handleCustomCommand,
input, input,
@@ -399,19 +347,6 @@ export function useChatComposerState({
], ],
); );
const showCostModal = useCallback(() => {
executeCommand(
{
name: '/cost',
description: 'Display token usage information',
namespace: 'builtin',
metadata: { type: 'builtin' },
} as SlashCommand,
'/cost',
{ preserveInput: true },
);
}, [executeCommand]);
const { const {
slashCommands, slashCommands,
slashCommandsCount, slashCommandsCount,
@@ -538,26 +473,13 @@ export function useChatComposerState({
} }
// Intercept slash commands only when "/" is the first input character. // Intercept slash commands only when "/" is the first input character.
// Also accept exact "help" as a convenience alias for users who expect CLI-style help.
const commandInput = currentInput.trimEnd(); const commandInput = currentInput.trimEnd();
const isHelpAlias = commandInput.trim().toLowerCase() === 'help'; if (commandInput.startsWith('/')) {
if (commandInput.startsWith('/') || isHelpAlias) {
const firstSpace = commandInput.indexOf(' '); const firstSpace = commandInput.indexOf(' ');
const commandName = isHelpAlias const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
? '/help' const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
: firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput;
const matchedCommand =
slashCommands.find((cmd: SlashCommand) => cmd.name === commandName) ||
(commandName === '/help'
? ({
name: '/help',
description: 'Show help documentation for Claude Code',
namespace: 'builtin',
metadata: { type: 'builtin' },
} as SlashCommand)
: undefined);
if (matchedCommand && matchedCommand.type !== 'skill') { if (matchedCommand && matchedCommand.type !== 'skill') {
executeCommand(matchedCommand, isHelpAlias ? '/help' : commandInput); executeCommand(matchedCommand, commandInput);
setInput(''); setInput('');
inputValueRef.current = ''; inputValueRef.current = '';
setAttachedImages([]); setAttachedImages([]);
@@ -572,7 +494,11 @@ export function useChatComposerState({
} }
} }
const messageContent = currentInput; let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
}
let uploadedImages: unknown[] = []; let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) { if (attachedImages.length > 0) {
@@ -606,54 +532,8 @@ export function useChatComposerState({
} }
} }
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; const effectiveSessionId =
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
// The conversation always has a stable backend-allocated session id
// BEFORE the first websocket send: brand-new chats allocate one here
// via the session gateway. There is no client-visible session-id
// handoff later — this id stays valid for the conversation's lifetime.
let targetSessionId = selectedSession?.id || currentSessionId || null;
if (!targetSessionId) {
try {
const response = await authenticatedFetch('/api/providers/sessions', {
method: 'POST',
body: JSON.stringify({
provider,
projectPath: resolvedProjectPath,
}),
});
if (!response.ok) {
throw new Error(`Failed to create session (${response.status})`);
}
const body = await response.json();
targetSessionId = body?.data?.sessionId || null;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Session creation failed:', error);
addMessage({
type: 'error',
content: `Failed to start a new session: ${message}`,
timestamp: new Date(),
});
return;
}
if (!targetSessionId) {
addMessage({
type: 'error',
content: 'Failed to start a new session: no session id returned.',
timestamp: new Date(),
});
return;
}
onSessionEstablished?.(targetSessionId, {
provider,
project: selectedProject,
summary: sessionSummary,
});
}
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
type: 'user', type: 'user',
@@ -663,17 +543,31 @@ export function useChatComposerState({
}; };
addMessage(userMessage); addMessage(userMessage);
// Mark this request as processing in the per-session activity map (the setIsLoading(true); // Processing banner starts
// single source of truth the indicator derives from). The id is always setCanAbortSession(true);
// concrete at this point — no pending placeholder exists anymore. setClaudeStatus({
onSessionProcessing?.(targetSessionId, { text: 'Processing',
statusText: null, tokens: 0,
canInterrupt: true, can_interrupt: true,
}); });
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100); setTimeout(() => scrollToBottom(), 100);
if (!effectiveSessionId && !selectedSession?.id) {
if (typeof window !== 'undefined') {
// Reset stale pending IDs from previous interrupted runs before creating a new one.
sessionStorage.removeItem('pendingSessionId');
}
// For new sessions we intentionally keep this as `null` until the backend
// emits `session_created` with the canonical provider session id.
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
if (effectiveSessionId) {
onSessionActive?.(effectiveSessionId);
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => { const getToolsSettings = () => {
try { try {
const settingsKey = const settingsKey =
@@ -683,8 +577,6 @@ export function useChatComposerState({
? 'codex-settings' ? 'codex-settings'
: provider === 'gemini' : provider === 'gemini'
? 'gemini-settings' ? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: 'claude-settings'; : 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey); const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) { if (savedSettings) {
@@ -702,35 +594,73 @@ export function useChatComposerState({
}; };
const toolsSettings = getToolsSettings(); const toolsSettings = getToolsSettings();
const model = const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
provider === 'cursor' const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
// One message shape for every provider. The backend resolves the if (provider === 'cursor') {
// provider, project path, and provider-native resume id from the sendMessage({
// session row; `options` only carries composer-level preferences. type: 'cursor-command',
sendMessage({ command: messageContent,
type: 'chat.send', sessionId: effectiveSessionId,
sessionId: targetSessionId, options: {
content: messageContent, cwd: resolvedProjectPath,
options: { projectPath: resolvedProjectPath,
model, sessionId: effectiveSessionId,
// Codex has no plan mode; downgrade rather than sending an resume: Boolean(effectiveSessionId),
// unsupported value to its runtime. model: cursorModel,
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, skipPermissions: toolsSettings?.skipPermissions || false,
toolsSettings, sessionSummary,
skipPermissions: toolsSettings?.skipPermissions || false, toolsSettings,
sessionSummary, },
images: uploadedImages, });
}, } else if (provider === 'codex') {
}); sendMessage({
type: 'codex-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: codexModel,
sessionSummary,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
sessionSummary,
permissionMode,
toolsSettings,
},
});
} else {
sendMessage({
type: 'claude-command',
command: messageContent,
options: {
projectPath: resolvedProjectPath,
cwd: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
toolsSettings,
permissionMode,
model: claudeModel,
sessionSummary,
images: uploadedImages,
},
});
}
setInput(''); setInput('');
inputValueRef.current = ''; inputValueRef.current = '';
@@ -739,6 +669,7 @@ export function useChatComposerState({
setUploadingImages(new Map()); setUploadingImages(new Map());
setImageErrors(new Map()); setImageErrors(new Map());
setIsTextareaExpanded(false); setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
@@ -755,19 +686,23 @@ export function useChatComposerState({
cursorModel, cursorModel,
executeCommand, executeCommand,
geminiModel, geminiModel,
opencodeModel,
isLoading, isLoading,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionEstablished, pendingViewSessionRef,
permissionMode, permissionMode,
provider, provider,
resetCommandMenuState, resetCommandMenuState,
scrollToBottom, scrollToBottom,
selectedProject, selectedProject,
sendMessage, sendMessage,
setCanAbortSession,
addMessage, addMessage,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp, setIsUserScrolledUp,
slashCommands, slashCommands,
thinkingMode,
], ],
); );
@@ -921,19 +856,33 @@ export function useChatComposerState({
return; return;
} }
const targetSessionId = selectedSession?.id || currentSessionId || null; const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const cursorSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
const candidateSessionIds = [
currentSessionId,
pendingViewSessionRef.current?.sessionId || null,
pendingSessionId,
provider === 'cursor' ? cursorSessionId : null,
selectedSession?.id || null,
];
const targetSessionId =
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
if (!targetSessionId) { if (!targetSessionId) {
console.warn('Abort requested but no session ID is available.'); console.warn('Abort requested but no concrete session ID is available yet.');
return; return;
} }
// The backend resolves the provider from the session row, so no provider
// field is needed here.
sendMessage({ sendMessage({
type: 'chat.abort', type: 'abort-session',
sessionId: targetSessionId, sessionId: targetSessionId,
provider,
}); });
}, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
const handleGrantToolPermission = useCallback( const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => { (suggestion: { entry: string; toolName: string }) => {
@@ -958,7 +907,7 @@ export function useChatComposerState({
validIds.forEach((requestId) => { validIds.forEach((requestId) => {
sendMessage({ sendMessage({
type: 'chat.permission-response', type: 'claude-permission-response',
requestId, requestId,
allow: Boolean(decision?.allow), allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput, updatedInput: decision?.updatedInput,
@@ -967,11 +916,15 @@ export function useChatComposerState({
}); });
}); });
setPendingPermissionRequests((previous) => setPendingPermissionRequests((previous) => {
previous.filter((request) => !validIds.includes(request.requestId)), const next = previous.filter((request) => !validIds.includes(request.requestId));
); if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
}, },
[sendMessage, setPendingPermissionRequests], [sendMessage, setClaudeStatus, setPendingPermissionRequests],
); );
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
@@ -990,6 +943,8 @@ export function useChatComposerState({
textareaRef, textareaRef,
inputHighlightRef, inputHighlightRef,
isTextareaExpanded, isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount, slashCommandsCount,
filteredCommands, filteredCommands,
frequentCommands, frequentCommands,
@@ -1025,8 +980,5 @@ export function useChatComposerState({
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused, isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
}; };
} }

View File

@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, SubagentChildTool } from '../types/types'; import type { ChatMessage, SubagentChildTool } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting'; import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
function formatToolResultContent(content: unknown): string {
const text = typeof content === 'string' ? content : JSON.stringify(content);
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
}
/** /**
* Convert NormalizedMessage[] from the session store into ChatMessage[] * Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect. * that the existing UI components expect.
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
// First pass: collect tool results for attachment // First pass: collect tool results for attachment
const toolResultMap = new Map<string, NormalizedMessage>(); const toolResultMap = new Map<string, NormalizedMessage>();
const toolUseIds = new Set<string>();
for (const msg of messages) { for (const msg of messages) {
if (msg.kind === 'tool_use' && msg.toolId) {
toolUseIds.add(msg.toolId);
}
if (msg.kind === 'tool_result' && msg.toolId) { if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg); toolResultMap.set(msg.toolId, msg);
} }
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
const toolResult = tr const toolResult = tr
? { ? {
content: formatToolResultContent(tr.content), content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: Boolean(tr.isError), isError: Boolean(tr.isError),
toolUseResult: (tr as any).toolUseResult, toolUseResult: (tr as any).toolUseResult,
} }
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break; break;
// tool_result is handled via attachment to tool_use above // tool_result is handled via attachment to tool_use above
case 'tool_result': { case 'tool_result':
if (msg.toolId && toolUseIds.has(msg.toolId)) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;
}
converted.push({
type: msg.isError ? 'error' : 'assistant',
content,
timestamp: msg.timestamp,
toolId: msg.toolId,
...sharedMetadata,
});
break; break;
}
default: default:
break; break;

View File

@@ -1,329 +1,43 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { import type { ProjectSession, LLMProvider } from '../../../types/app';
ProjectSession,
LLMProvider,
Project,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
} from '../../../types/app';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = { const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
claude: 'opus', if (provider === 'codex') {
cursor: 'gpt-5.3-codex', return ['default', 'acceptEdits', 'bypassPermissions'];
codex: 'gpt-5.4', }
gemini: 'gemini-3.1-pro-preview', if (provider === 'claude') {
opencode: 'anthropic/claude-sonnet-4-5', return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
}; }
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
* source of truth; this mirror exists so the composer renders sensibly on
* first paint and when the capabilities request fails.
*/
const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
};
type ProviderCapabilities = {
provider: LLMProvider;
permissionModes: string[];
defaultPermissionMode: string;
supportsImages: boolean;
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
};
type ProviderCapabilitiesApiResponse = {
success?: boolean;
data?: {
providers?: ProviderCapabilities[];
};
}; };
interface UseChatProviderStateArgs { interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
selectedProject: Project | null;
} }
type ProviderModelsApiResponse = { export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
success?: boolean;
data?: {
models?: ProviderModelsDefinition;
cache?: ProviderModelsCacheInfo;
};
};
type ChangeActiveModelApiResponse = {
success?: boolean;
data?: {
provider?: LLMProvider;
sessionId?: string;
supported?: boolean;
changed?: boolean;
model?: string | null;
};
};
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default'); const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]); const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => { const [provider, setProvider] = useState<LLMProvider>(() => {
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
}); });
const [cursorModel, setCursorModel] = useState<string>(() => { const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor; return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
}); });
const [claudeModel, setClaudeModel] = useState<string>(() => { const [claudeModel, setClaudeModel] = useState<string>(() => {
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude; return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
}); });
const [codexModel, setCodexModel] = useState<string>(() => { const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
}); });
const [geminiModel, setGeminiModel] = useState<string>(() => { const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
}); });
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
* mode picker (and is the extension point for future per-provider UI
* differences) so the frontend stays free of hardcoded provider branching.
* Null until `/api/providers/capabilities` resolves; the static fallback
* map covers that window.
*/
const [providerCapabilities, setProviderCapabilities] = useState<
Partial<Record<LLMProvider, ProviderCapabilities>> | null
>(null);
const [providerModelCatalog, setProviderModelCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsDefinition>>
>({});
const [providerModelCacheCatalog, setProviderModelCacheCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsCacheInfo>>
>({});
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
const lastProviderRef = useRef(provider); const lastProviderRef = useRef(provider);
const providerModelsRequestIdRef = useRef(0);
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
if (targetProvider === 'claude') {
setClaudeModel(model);
localStorage.setItem('claude-model', model);
return;
}
if (targetProvider === 'cursor') {
setCursorModel(model);
localStorage.setItem('cursor-model', model);
return;
}
if (targetProvider === 'codex') {
setCodexModel(model);
localStorage.setItem('codex-model', model);
return;
}
if (targetProvider === 'gemini') {
setGeminiModel(model);
localStorage.setItem('gemini-model', model);
return;
}
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
if (isHardRefresh) {
setProviderModelsRefreshing(true);
} else {
setProviderModelsLoading(true);
}
try {
const results = await Promise.all(
providers.map(async (p) => {
const params = new URLSearchParams();
if (options.bypassCache) {
params.set('bypassCache', 'true');
}
const queryString = params.toString();
const response = await authenticatedFetch(`/api/providers/${p}/models${queryString ? `?${queryString}` : ''}`);
const body = (await response.json()) as ProviderModelsApiResponse;
if (!body.success || !body.data?.models || !body.data?.cache) {
return null;
}
return body.data;
}),
);
if (providerModelsRequestIdRef.current !== requestId) {
return;
}
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
providers.forEach((p, i) => {
const entry = results[i];
if (!entry) {
return;
}
nextCatalog[p] = entry.models;
nextCacheCatalog[p] = entry.cache;
});
setProviderModelCatalog(nextCatalog);
setProviderModelCacheCatalog(nextCacheCatalog);
} catch (error) {
console.error('Error loading provider models:', error);
} finally {
if (providerModelsRequestIdRef.current === requestId) {
setProviderModelsLoading(false);
setProviderModelsRefreshing(false);
}
}
}, []);
useEffect(() => {
void loadProviderModels();
}, [loadProviderModels]);
useEffect(() => {
let cancelled = false;
const loadCapabilities = async () => {
try {
const response = await authenticatedFetch('/api/providers/capabilities');
const body = (await response.json()) as ProviderCapabilitiesApiResponse;
if (cancelled || !body.success || !Array.isArray(body.data?.providers)) {
return;
}
const byProvider: Partial<Record<LLMProvider, ProviderCapabilities>> = {};
for (const capabilities of body.data.providers) {
byProvider[capabilities.provider] = capabilities;
}
setProviderCapabilities(byProvider);
} catch (error) {
console.error('Error loading provider capabilities:', error);
}
};
void loadCapabilities();
return () => {
cancelled = true;
};
}, []);
const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => {
const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes;
if (capabilityModes && capabilityModes.length > 0) {
return capabilityModes as PermissionMode[];
}
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
def: ProviderModelsDefinition,
): string => {
const stored = localStorage.getItem(storageKey);
if (stored && def.OPTIONS.some((o) => o.value === stored)) {
return stored;
}
if (current && def.OPTIONS.some((o) => o.value === current)) {
return current;
}
return def.DEFAULT;
};
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
const next = pickStoredOrCurrent('claude-model', claudeModel, claude);
if (next !== claudeModel) {
setClaudeModel(next);
}
if (localStorage.getItem('claude-model') !== next) {
localStorage.setItem('claude-model', next);
}
}
}, [providerModelCatalog.claude, claudeModel]);
useEffect(() => {
const cursor = providerModelCatalog.cursor;
if (cursor) {
const next = pickStoredOrCurrent('cursor-model', cursorModel, cursor);
if (next !== cursorModel) {
setCursorModel(next);
}
if (localStorage.getItem('cursor-model') !== next) {
localStorage.setItem('cursor-model', next);
}
}
}, [providerModelCatalog.cursor, cursorModel]);
useEffect(() => {
const codex = providerModelCatalog.codex;
if (codex) {
const next = pickStoredOrCurrent('codex-model', codexModel, codex);
if (next !== codexModel) {
setCodexModel(next);
}
if (localStorage.getItem('codex-model') !== next) {
localStorage.setItem('codex-model', next);
}
}
}, [providerModelCatalog.codex, codexModel]);
useEffect(() => {
const gemini = providerModelCatalog.gemini;
if (gemini) {
const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini);
if (next !== geminiModel) {
setGeminiModel(next);
}
if (localStorage.getItem('gemini-model') !== next) {
localStorage.setItem('gemini-model', next);
}
}
}, [providerModelCatalog.gemini, geminiModel]);
useEffect(() => {
const opencode = providerModelCatalog.opencode;
if (opencode) {
const next = pickStoredOrCurrent('opencode-model', opencodeModel, opencode);
if (next !== opencodeModel) {
setOpenCodeModel(next);
}
if (localStorage.getItem('opencode-model') !== next) {
localStorage.setItem('opencode-model', next);
}
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => { useEffect(() => {
if (!selectedSession?.id) { if (!selectedSession?.id) {
@@ -333,7 +47,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider); const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default'); setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider, getPermissionModesForProvider]); }, [selectedSession?.id, provider]);
useEffect(() => { useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) { if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -391,42 +105,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
if (selectedSession?.id) { if (selectedSession?.id) {
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode); localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
} }
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]); }, [permissionMode, provider, selectedSession?.id]);
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,
model: string,
sessionId?: string | null,
) => {
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!normalizedSessionId) {
setStoredProviderModel(targetProvider, model);
return {
scope: 'default' as const,
changed: false,
model,
};
}
const response = await authenticatedFetch(
`/api/providers/${targetProvider}/sessions/${encodeURIComponent(normalizedSessionId)}/active-model`,
{
method: 'POST',
body: JSON.stringify({ model }),
},
);
const body = (await response.json()) as ChangeActiveModelApiResponse;
if (!response.ok || !body.success || !body.data?.supported) {
throw new Error('Unable to change the active model for this session.');
}
return {
scope: 'session' as const,
changed: body.data.changed === true,
model: body.data.model || model,
};
}, [setStoredProviderModel]);
return { return {
provider, provider,
@@ -439,18 +118,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setCodexModel, setCodexModel,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel,
setOpenCodeModel,
permissionMode, permissionMode,
setPermissionMode, setPermissionMode,
pendingPermissionRequests, pendingPermissionRequests,
setPendingPermissionRequests, setPendingPermissionRequests,
cyclePermissionMode, cyclePermissionMode,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsLoading,
providerModelsRefreshing,
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel,
}; };
} }

Some files were not shown because too many files have changed in this diff Show More