mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 21:25:34 +08:00
Compare commits
24 Commits
fix/do-not
...
fix/websoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca8345330a | ||
|
|
10528a2bdd | ||
|
|
b2e3a61030 | ||
|
|
17db71c43c | ||
|
|
116f91bc3a | ||
|
|
ded630863f | ||
|
|
54130e8d14 | ||
|
|
de25f6d78e | ||
|
|
e57ce4248e | ||
|
|
5554e4e85e | ||
|
|
13d1d436f8 | ||
|
|
8273dc51c5 | ||
|
|
684e127213 | ||
|
|
29f6d94fbf | ||
|
|
c9413815a4 | ||
|
|
5bd179a36e | ||
|
|
ce36fb85e7 | ||
|
|
f289ce8419 | ||
|
|
6f3e48f4fb | ||
|
|
41e221f1b3 | ||
|
|
59fbddecaa | ||
|
|
1325f18173 | ||
|
|
c064aff568 | ||
|
|
b3fe1b4392 |
55
CHANGELOG.md
55
CHANGELOG.md
@@ -3,61 +3,6 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [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)
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **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)
|
||||
- **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 in [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
||||
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **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)
|
||||
- **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 (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported 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
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
|
||||
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **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)
|
||||
- **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 [`public/modelConstants.js`](public/modelConstants.js) dosyasına 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ıç
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -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 100m;
|
||||
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,11 +157,7 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
|
||||
pattern: [
|
||||
"server/shared/utils.{js,ts}",
|
||||
"server/shared/frontmatter.ts",
|
||||
"server/shared/claude-cli-path.ts",
|
||||
], // classify shared utility files so modules can depend on them explicitly
|
||||
pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly
|
||||
mode: "file",
|
||||
},
|
||||
{
|
||||
|
||||
261
package-lock.json
generated
261
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.1",
|
||||
"version": "1.31.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.1",
|
||||
"version": "1.31.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"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-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -39,7 +39,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -139,33 +138,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz",
|
||||
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.116.tgz",
|
||||
"integrity": "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.116.tgz",
|
||||
"integrity": "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -176,9 +177,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.116.tgz",
|
||||
"integrity": "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -189,9 +190,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.116.tgz",
|
||||
"integrity": "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -202,9 +203,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz",
|
||||
"integrity": "sha512-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==",
|
||||
"version": "0.2.116",
|
||||
"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-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -215,9 +216,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.116.tgz",
|
||||
"integrity": "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -228,9 +229,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz",
|
||||
"integrity": "sha512-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==",
|
||||
"version": "0.2.116",
|
||||
"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-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -241,9 +242,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.116.tgz",
|
||||
"integrity": "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -254,9 +255,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==",
|
||||
"version": "0.2.116",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.116.tgz",
|
||||
"integrity": "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -267,14 +268,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.100.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
|
||||
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
|
||||
"integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
@@ -1837,7 +1836,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
@@ -2602,7 +2600,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -2643,7 +2640,6 @@
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
@@ -2653,11 +2649,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2674,7 +2669,6 @@
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -2699,7 +2693,6 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -2713,7 +2706,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
@@ -2723,7 +2715,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -2767,7 +2758,6 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -2789,7 +2779,6 @@
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2799,7 +2788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
@@ -2820,7 +2808,6 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -2836,15 +2823,13 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2854,7 +2839,6 @@
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -2867,7 +2851,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
@@ -2884,17 +2867,15 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -2910,7 +2891,6 @@
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
@@ -2926,7 +2906,6 @@
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -2953,7 +2932,6 @@
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
@@ -2973,42 +2951,22 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"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": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
@@ -4325,13 +4283,6 @@
|
||||
"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": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
||||
@@ -4629,13 +4580,6 @@
|
||||
"@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": {
|
||||
"version": "3.0.3",
|
||||
"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",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
@@ -5479,11 +5422,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -5499,8 +5441,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
@@ -7544,15 +7485,6 @@
|
||||
"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": {
|
||||
"version": "5.3.0",
|
||||
"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",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
@@ -8575,11 +8506,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
||||
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
@@ -8687,13 +8617,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -8809,13 +8738,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -9873,11 +9795,10 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10249,9 +10170,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -10694,8 +10615,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
@@ -10966,11 +10886,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"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",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
@@ -11052,8 +10970,7 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
@@ -14045,7 +13962,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
@@ -15346,7 +15262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
@@ -15363,7 +15278,6 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
@@ -16422,17 +16336,6 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
@@ -17143,8 +17046,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"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",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.1",
|
||||
"version": "1.31.5",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -10,8 +10,6 @@
|
||||
"files": [
|
||||
"server/",
|
||||
"shared/",
|
||||
"public/api-docs.html",
|
||||
"public/modelConstants.js",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"scripts/",
|
||||
@@ -67,7 +65,7 @@
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"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-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -96,7 +94,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"express": "^4.18.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
||||
@@ -822,7 +822,7 @@ data: {"type":"done"}</code></pre>
|
||||
|
||||
<script type="module">
|
||||
// Import model constants
|
||||
import { PROVIDERS } from './modelConstants.js';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
|
||||
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
@@ -834,14 +834,15 @@ data: {"type":"done"}</code></pre>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
const providerModels = PROVIDERS.map(provider => {
|
||||
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
|
||||
}).join('<br><br>');
|
||||
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(', ');
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
${providerModels}
|
||||
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
|
||||
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,848 +0,0 @@
|
||||
/**
|
||||
* Documentation Model Definitions
|
||||
* Used by README links and the public API docs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "default",
|
||||
label: "Default (recommended)",
|
||||
description:
|
||||
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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", label: "gpt-5.2" },
|
||||
],
|
||||
|
||||
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.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",
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode Models
|
||||
*
|
||||
* OpenCode model ids include the upstream provider prefix.
|
||||
*/
|
||||
export const OPENCODE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "opencode/big-pickle",
|
||||
label: "Big Pickle",
|
||||
description: "opencode - opencode/big-pickle",
|
||||
},
|
||||
{
|
||||
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-haiku-20241022",
|
||||
label: "Claude 3.5 Haiku (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-latest",
|
||||
label: "Claude 3.5 Haiku Latest",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-latest",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20240620",
|
||||
label: "Claude 3.5 Sonnet (2024-06-20)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
|
||||
},
|
||||
{
|
||||
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-3-7-sonnet-20250219",
|
||||
label: "Claude 3.7 Sonnet (2025-02-19)",
|
||||
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-haiku-20240307",
|
||||
label: "Claude 3 Haiku (2024-03-07)",
|
||||
description: "anthropic - anthropic/claude-3-haiku-20240307",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-opus-20240229",
|
||||
label: "Claude 3 Opus (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-opus-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-sonnet-20240229",
|
||||
label: "Claude 3 Sonnet (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-sonnet-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5",
|
||||
label: "Claude Haiku 4.5",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5-20251001",
|
||||
label: "Claude Haiku 4.5 (2025-10-01)",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-0",
|
||||
label: "Claude Opus 4.0",
|
||||
description: "anthropic - anthropic/claude-opus-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1",
|
||||
label: "Claude Opus 4.1",
|
||||
description: "anthropic - anthropic/claude-opus-4-1",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1-20250805",
|
||||
label: "Claude Opus 4.1 (2025-08-05)",
|
||||
description: "anthropic - anthropic/claude-opus-4-1-20250805",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-20250514",
|
||||
label: "Claude Opus 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-opus-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5",
|
||||
label: "Claude Opus 4.5",
|
||||
description: "anthropic - anthropic/claude-opus-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5-20251101",
|
||||
label: "Claude Opus 4.5 (2025-11-01)",
|
||||
description: "anthropic - anthropic/claude-opus-4-5-20251101",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6",
|
||||
label: "Claude Opus 4.6",
|
||||
description: "anthropic - anthropic/claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6-fast",
|
||||
label: "Claude Opus 4.6 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-6-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7",
|
||||
label: "Claude Opus 4.7",
|
||||
description: "anthropic - anthropic/claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7-fast",
|
||||
label: "Claude Opus 4.7 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-7-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-0",
|
||||
label: "Claude Sonnet 4.0",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-20250514",
|
||||
label: "Claude Sonnet 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5",
|
||||
label: "Claude Sonnet 4.5",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5-20250929",
|
||||
label: "Claude Sonnet 4.5 (2025-09-29)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.2",
|
||||
label: "GPT-5.2",
|
||||
description: "openai - openai/gpt-5.2",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex",
|
||||
label: "GPT-5.3 Codex",
|
||||
description: "openai - openai/gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex-spark",
|
||||
label: "GPT-5.3 Codex Spark",
|
||||
description: "openai - openai/gpt-5.3-codex-spark",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
description: "openai - openai/gpt-5.4",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-fast",
|
||||
label: "GPT-5.4 Fast",
|
||||
description: "openai - openai/gpt-5.4-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
description: "openai - openai/gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
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",
|
||||
label: "GPT-5.5",
|
||||
description: "openai - openai/gpt-5.5",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-fast",
|
||||
label: "GPT-5.5 Fast",
|
||||
description: "openai - openai/gpt-5.5-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-pro",
|
||||
label: "GPT-5.5 Pro",
|
||||
description: "openai - openai/gpt-5.5-pro",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "anthropic/claude-sonnet-4-5",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordered provider registry. Display order in documentation.
|
||||
*/
|
||||
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 },
|
||||
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
|
||||
];
|
||||
@@ -75,7 +75,7 @@
|
||||
- **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)
|
||||
- **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 (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported 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
|
||||
|
||||
@@ -17,8 +17,7 @@ import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
import {
|
||||
createNotificationEvent,
|
||||
@@ -205,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// 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
|
||||
|
||||
// Map system prompt configuration
|
||||
@@ -285,68 +284,43 @@ function transformMessage(sdkMessage) {
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
function readNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts token usage from SDK messages.
|
||||
* Prefers per-step `message.usage` (Claude message payload), then falls back
|
||||
* to result-level usage/modelUsage for compatibility across SDK versions.
|
||||
* @param {Object} sdkMessage - SDK stream message
|
||||
* Extracts token usage from SDK result messages
|
||||
* @param {Object} resultMessage - SDK result message
|
||||
* @returns {Object|null} Token budget object or null
|
||||
*/
|
||||
function extractTokenBudget(sdkMessage) {
|
||||
if (!sdkMessage || typeof sdkMessage !== 'object') {
|
||||
function extractTokenBudget(resultMessage) {
|
||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||
if (messageUsage && typeof messageUsage === 'object') {
|
||||
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
// Get the first model's usage data
|
||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
||||
const modelData = resultMessage.modelUsage[modelKey];
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
|
||||
if (!modelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallback for older SDK messages with only modelUsage
|
||||
const modelKey = Object.keys(sdkMessage.modelUsage)[0];
|
||||
const modelData = sdkMessage.modelUsage[modelKey];
|
||||
// Use cumulative tokens if available (tracks total for the session)
|
||||
// Otherwise fall back to per-request tokens
|
||||
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') {
|
||||
return null;
|
||||
}
|
||||
// Total used = input + output + cache tokens
|
||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
|
||||
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
// Use configured context window budget from environment (default 160000)
|
||||
// This is the user's budget limit, not the model's context window
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||
|
||||
// Token calc logged via token-budget WS event
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
total: contextWindow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,17 +491,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
};
|
||||
|
||||
try {
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||
'claude',
|
||||
sessionId,
|
||||
options.model,
|
||||
);
|
||||
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK({
|
||||
...options,
|
||||
model: resolvedModel || options.model,
|
||||
});
|
||||
const sdkOptions = mapCliOptionsToSDK(options);
|
||||
|
||||
// Load MCP configuration
|
||||
const mcpServers = await loadMcpConfig(options.cwd);
|
||||
@@ -709,10 +674,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
ws.send(msg);
|
||||
}
|
||||
|
||||
// Extract and send token budget updates from assistant/result usage payloads
|
||||
const tokenBudgetData = extractTokenBudget(message);
|
||||
if (tokenBudgetData) {
|
||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const models = Object.keys(message.modelUsage || {});
|
||||
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' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import crossSpawn from 'cross-spawn';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
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 { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
@@ -29,7 +28,6 @@ function isWorkspaceTrustPrompt(text = '') {
|
||||
async function spawnCursor(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
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 sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let hasRetriedWithTrust = false;
|
||||
@@ -54,10 +52,9 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Provide a prompt (works for both new and resumed sessions)
|
||||
baseArgs.push('-p', command);
|
||||
|
||||
// Model overrides are applied to both new and resumed sessions so a
|
||||
// session-scoped change request can take effect on the next turn.
|
||||
if (resolvedModel) {
|
||||
baseArgs.push('--model', resolvedModel);
|
||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||
if (!sessionId && model) {
|
||||
baseArgs.push('--model', model);
|
||||
}
|
||||
|
||||
// Request streaming JSON when we are providing a prompt
|
||||
|
||||
@@ -9,7 +9,6 @@ import sessionManager from './sessionManager.js';
|
||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.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';
|
||||
|
||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||
@@ -121,11 +120,6 @@ async function buildGeminiProcessEnv() {
|
||||
|
||||
async function spawnGemini(command, options = {}, ws) {
|
||||
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 sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||
@@ -250,7 +244,7 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// 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('--output-format', 'stream-json');
|
||||
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
// Gemini Response Handler - JSON Stream processing
|
||||
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 {
|
||||
constructor(ws, options = {}) {
|
||||
@@ -87,17 +60,6 @@ class GeminiResponseHandler {
|
||||
for (const msg of normalized) {
|
||||
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() {
|
||||
|
||||
336
server/index.js
336
server/index.js
@@ -10,9 +10,8 @@ import { spawn } from 'child_process';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
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 { createWebSocketServer } from '@/modules/websocket/index.js';
|
||||
|
||||
@@ -46,12 +45,6 @@ import {
|
||||
isGeminiSessionActive,
|
||||
getActiveGeminiSessions,
|
||||
} from './gemini-cli.js';
|
||||
import {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
isOpenCodeSessionActive,
|
||||
getActiveOpenCodeSessions,
|
||||
} from './opencode-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import {
|
||||
stripAnsiSequences,
|
||||
@@ -73,7 +66,7 @@ import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.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 { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
@@ -101,25 +94,21 @@ const wss = createWebSocketServer(server, {
|
||||
spawnCursor,
|
||||
queryCodex,
|
||||
spawnGemini,
|
||||
spawnOpenCode,
|
||||
abortClaudeSDKSession,
|
||||
abortCursorSession,
|
||||
abortCodexSession,
|
||||
abortGeminiSession,
|
||||
abortOpenCodeSession,
|
||||
resolveToolApproval,
|
||||
isClaudeSDKSessionActive,
|
||||
isCursorSessionActive,
|
||||
isCodexSessionActive,
|
||||
isGeminiSessionActive,
|
||||
isOpenCodeSessionActive,
|
||||
reconnectSessionWriter,
|
||||
getPendingApprovalsForSession,
|
||||
getActiveClaudeSDKSessions,
|
||||
getActiveCursorSessions,
|
||||
getActiveCodexSessions,
|
||||
getActiveGeminiSessions,
|
||||
getActiveOpenCodeSessions,
|
||||
},
|
||||
shell: {
|
||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
||||
@@ -1142,129 +1131,23 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
breakdown: { input: 0, output: 0 },
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Cursor sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Gemini sessions - they are raw logs in our current setup
|
||||
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({
|
||||
used: totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
}
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Gemini sessions'
|
||||
});
|
||||
}
|
||||
|
||||
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(safeSessionId);
|
||||
|
||||
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
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
@@ -1305,8 +1188,6 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
throw error;
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let totalTokens = 0;
|
||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||
|
||||
@@ -1319,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) {
|
||||
const tokenInfo = entry.payload.info;
|
||||
if (tokenInfo.total_token_usage) {
|
||||
inputTokens = tokenInfo.total_token_usage.input_tokens || 0;
|
||||
outputTokens = tokenInfo.total_token_usage.output_tokens || 0;
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || inputTokens + outputTokens;
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
||||
}
|
||||
if (tokenInfo.model_context_window) {
|
||||
contextWindow = tokenInfo.model_context_window;
|
||||
@@ -1336,13 +1215,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
|
||||
return res.json({
|
||||
used: totalTokens,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
}
|
||||
total: contextWindow
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1385,7 +1258,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
@@ -1398,7 +1272,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
outputTokens = usage.output_tokens || 0;
|
||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
@@ -1408,16 +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 totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
cacheCreation: cacheCreationTokens,
|
||||
cacheRead: cacheReadTokens
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1483,133 +1358,74 @@ function permToRwx(perm) {
|
||||
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) {
|
||||
// Using fsPromises from import
|
||||
let entries;
|
||||
const items = [];
|
||||
|
||||
try {
|
||||
await acquire();
|
||||
try {
|
||||
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
} finally {
|
||||
release();
|
||||
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Debug: log all entries including hidden files
|
||||
|
||||
|
||||
// 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) {
|
||||
// Only log non-permission errors to avoid spam
|
||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||
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) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
|
||||
@@ -33,7 +33,6 @@ type ProjectApiView = {
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
opencodeSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
@@ -85,7 +84,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
opencodeSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
|
||||
@@ -14,7 +14,7 @@ type SessionSummary = {
|
||||
lastActivity: string;
|
||||
};
|
||||
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
@@ -34,7 +34,6 @@ export type ProjectListItem = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -75,7 +74,6 @@ export type ProjectSessionsPageApiView = {
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -141,7 +139,6 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
opencode: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -256,7 +253,6 @@ export async function getProjectsWithSessions(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -313,7 +309,6 @@ export async function getArchivedProjectsWithSessions(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -346,7 +341,6 @@ export async function getProjectSessionsPage(
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# Providers Module Guide
|
||||
|
||||
This file documents the current provider contract in `server/modules/providers`.
|
||||
Keep it current whenever provider wiring, skill discovery, or session sync
|
||||
behavior changes. The goal is that a human or AI agent can add a new provider
|
||||
without guessing which files need to move.
|
||||
|
||||
## Current Provider Shape
|
||||
|
||||
Every provider wrapper exposes five facets:
|
||||
|
||||
- `auth`
|
||||
- `mcp`
|
||||
- `skills`
|
||||
- `sessions`
|
||||
- `sessionSynchronizer`
|
||||
|
||||
These correspond to the shared interfaces in `server/shared/interfaces.ts`:
|
||||
|
||||
- `IProviderAuth`
|
||||
- `IProviderMcp`
|
||||
- `IProviderSkills`
|
||||
- `IProviderSessions`
|
||||
- `IProviderSessionSynchronizer`
|
||||
|
||||
The services that consume them are:
|
||||
|
||||
- `providerAuthService`
|
||||
- `providerMcpService`
|
||||
- `providerSkillsService`
|
||||
- `sessionsService`
|
||||
- `sessionSynchronizerService`
|
||||
|
||||
Current provider ids in this repo are:
|
||||
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `cursor`
|
||||
- `gemini`
|
||||
- `opencode`
|
||||
|
||||
Those ids are mirrored in backend unions and frontend provider constants. If
|
||||
adding a new provider, update every place that hardcodes this list.
|
||||
|
||||
## Current File Layout
|
||||
|
||||
Each provider lives under its own folder in `server/modules/providers/list/`:
|
||||
|
||||
```text
|
||||
server/modules/providers/list/<provider>/
|
||||
<provider>.provider.ts
|
||||
<provider>-auth.provider.ts
|
||||
<provider>-mcp.provider.ts
|
||||
<provider>-skills.provider.ts
|
||||
<provider>-sessions.provider.ts
|
||||
<provider>-session-synchronizer.provider.ts
|
||||
```
|
||||
|
||||
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
`opencode`.
|
||||
|
||||
## What Each Facet Does
|
||||
|
||||
| Facet | Responsibility | Base / Service |
|
||||
| --- | --- | --- |
|
||||
| `auth` | Report install/auth state for the provider runtime | `IProviderAuth` -> `providerAuthService` |
|
||||
| `mcp` | Read, list, write, and remove provider-native MCP config | `McpProvider` -> `providerMcpService` |
|
||||
| `skills` | Discover provider-native skill markdown files | `SkillsProvider` -> `providerSkillsService` |
|
||||
| `sessions` | Normalize live events and fetch session history | `IProviderSessions` -> `sessionsService` |
|
||||
| `sessionSynchronizer` | Scan transcript artifacts and upsert session metadata | `IProviderSessionSynchronizer` -> `sessionSynchronizerService` |
|
||||
|
||||
`sessions` and `sessionSynchronizer` are separate concerns:
|
||||
|
||||
- `sessions` handles runtime event normalization and history fetches.
|
||||
- `sessionSynchronizer` handles file-backed session indexing into `sessionsDb`.
|
||||
|
||||
## How To Add A Provider
|
||||
|
||||
1. Add the provider id everywhere it is part of the contract.
|
||||
|
||||
- Update `server/shared/types.ts` `LLMProvider`.
|
||||
- Update `src/types/app.ts` `LLMProvider` if the frontend should know about it.
|
||||
- Update `server/modules/providers/provider.routes.ts`.
|
||||
- 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 `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||
the provider should be selectable in chat.
|
||||
- Update `src/components/provider-auth/view/ProviderLoginModal.tsx` if the
|
||||
provider has a login/setup flow.
|
||||
|
||||
2. Create the wrapper class.
|
||||
|
||||
- Add `server/modules/providers/list/<provider>/<provider>.provider.ts`.
|
||||
- Extend `AbstractProvider`.
|
||||
- Expose readonly `auth`, `mcp`, `skills`, `sessions`, and `sessionSynchronizer`.
|
||||
- Call `super('<provider>')`.
|
||||
|
||||
3. Implement auth.
|
||||
|
||||
- Return a full `ProviderAuthStatus`.
|
||||
- Treat normal `not installed` / `not authenticated` states as data, not exceptions.
|
||||
- Keep provider-specific credential discovery inside the auth provider.
|
||||
- If the provider has no auth step, return a stable unauthenticated or not-installed status instead of omitting the facet.
|
||||
|
||||
4. Implement MCP.
|
||||
|
||||
- Extend `McpProvider`.
|
||||
- Pass the supported scopes and transports to `super(...)`.
|
||||
- Implement the four required methods:
|
||||
- `readScopedServers(...)`
|
||||
- `writeScopedServers(...)`
|
||||
- `buildServerConfig(...)`
|
||||
- `normalizeServerConfig(...)`
|
||||
- Use the shared validation and normalization behavior from `McpProvider`.
|
||||
- Keep the provider-specific config format local to the provider implementation.
|
||||
|
||||
Current MCP formats in this repo are:
|
||||
|
||||
| Provider | User / Project Storage | Supported Scopes | Supported Transports |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` |
|
||||
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
|
||||
| Cursor | `.cursor/mcp.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.
|
||||
|
||||
- Extend `SkillsProvider`.
|
||||
- Implement `getSkillSources(workspacePath)`.
|
||||
- Return the actual discovery roots for the provider.
|
||||
- Skills are discovered from `SKILL.md` files.
|
||||
- `readProviderSkillMarkdownDefinition(...)` reads front matter `name` and `description`.
|
||||
- If `name` is missing, the parent directory name is used as a fallback.
|
||||
- Use `recursive: true` only when the provider stores skills in nested trees.
|
||||
- Keep the emitted `command` string aligned with the provider's real skill syntax.
|
||||
|
||||
Current skill discovery roots are:
|
||||
|
||||
| Provider | User Roots | Project / Repo Roots | Prefix | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Claude | `~/.claude/skills` | `<workspace>/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. |
|
||||
| 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. |
|
||||
| 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:
|
||||
|
||||
- Claude user/project skills: `/skill-name`
|
||||
- Claude plugin skills: `/plugin-name:skill-name`
|
||||
- Codex skills: `$skill-name`
|
||||
- Cursor skills: `/skill-name`
|
||||
- Gemini skills: `/skill-name`
|
||||
- OpenCode skills: `/skill-name`
|
||||
|
||||
6. Implement sessions.
|
||||
|
||||
- Implement `normalizeMessage(raw, sessionId)` and `fetchHistory(sessionId, options)`.
|
||||
- Use `createNormalizedMessage(...)` and `generateMessageId(...)` for emitted messages.
|
||||
- Keep normalized message ids unique. If one raw event produces multiple text
|
||||
parts, append a discriminator so ids do not collide.
|
||||
- Keep pagination consistent:
|
||||
- `limit: null` means unbounded/full history.
|
||||
- `limit: 0` means an empty page.
|
||||
- always return `total`, `hasMore`, `offset`, and `limit` when paginating.
|
||||
- Sanitize any filesystem-derived ids before using them in file or database paths.
|
||||
- Do not assume a provider's history format matches another provider's format.
|
||||
|
||||
7. Implement session synchronization.
|
||||
|
||||
- Implement `synchronize(since?: Date)` to scan provider artifacts and upsert
|
||||
sessions into `sessionsDb`.
|
||||
- Implement `synchronizeFile(filePath)` for single-file watcher updates.
|
||||
- Use the existing helpers when they fit:
|
||||
- `buildLookupMap(...)`
|
||||
- `extractFirstValidJsonlData(...)`
|
||||
- `findFilesRecursivelyCreatedAfter(...)`
|
||||
- `normalizeSessionName(...)`
|
||||
- `readFileTimestamps(...)`
|
||||
- Make the sync resilient to partial, malformed, or missing provider files.
|
||||
- The orchestration service runs all provider synchronizers and only advances
|
||||
`scan_state.last_scanned_at` when every provider succeeds.
|
||||
|
||||
Current session sync roots are:
|
||||
|
||||
| Provider | Scan Roots | Metadata Helpers / Notes |
|
||||
| --- | --- | --- |
|
||||
| Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. |
|
||||
| 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. |
|
||||
| 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.
|
||||
|
||||
- Add the new provider class to `server/modules/providers/provider.registry.ts`.
|
||||
- Update `server/modules/providers/provider.routes.ts` provider parsing.
|
||||
- If the provider introduces a new service or lifecycle hook, export it from the module entrypoint that consumes providers.
|
||||
|
||||
9. Wire runtime and UI surfaces outside the providers module when needed.
|
||||
|
||||
If the provider can run live chat sessions, update the runtime entrypoints too:
|
||||
|
||||
- `server/routes/agent.js`
|
||||
- `server/index.js`
|
||||
|
||||
If the provider is visible in the UI, update:
|
||||
|
||||
- provider model fallback files under `server/modules/providers/list/<provider>/`
|
||||
- `src/components/chat/hooks/useChatProviderState.ts`
|
||||
- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx`
|
||||
- `src/components/provider-auth/view/ProviderLoginModal.tsx`
|
||||
- `src/components/mcp/constants.ts`
|
||||
|
||||
## Minimal Wrapper Template
|
||||
|
||||
```ts
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { <Provider>ProviderAuth } from './<provider>-auth.provider.js';
|
||||
import { <Provider>McpProvider } from './<provider>-mcp.provider.js';
|
||||
import { <Provider>SkillsProvider } from './<provider>-skills.provider.js';
|
||||
import { <Provider>SessionsProvider } from './<provider>-sessions.provider.js';
|
||||
import { <Provider>SessionSynchronizer } from './<provider>-session-synchronizer.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSessions,
|
||||
IProviderSkills,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class <Provider>Provider extends AbstractProvider {
|
||||
readonly auth: IProviderAuth = new <Provider>ProviderAuth();
|
||||
readonly mcp: IProviderMcp = new <Provider>McpProvider();
|
||||
readonly skills: IProviderSkills = new <Provider>SkillsProvider();
|
||||
readonly sessions: IProviderSessions = new <Provider>SessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer =
|
||||
new <Provider>SessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('<provider>');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Minimal Skills Template
|
||||
|
||||
```ts
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
|
||||
export class <Provider>SkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('<provider>');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.<provider>', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Minimal Session Sync Template
|
||||
|
||||
```ts
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
|
||||
export class <Provider>SessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AI Prompt Template
|
||||
|
||||
Use this prompt when asking an AI agent to add a provider:
|
||||
|
||||
```text
|
||||
Add a new provider "<provider>" using the current provider module architecture.
|
||||
|
||||
Requirements:
|
||||
1) Create:
|
||||
- server/modules/providers/list/<provider>/<provider>.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-auth.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-mcp.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-skills.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-sessions.provider.ts
|
||||
- server/modules/providers/list/<provider>/<provider>-session-synchronizer.provider.ts
|
||||
2) Register in:
|
||||
- server/modules/providers/provider.registry.ts
|
||||
- server/modules/providers/provider.routes.ts
|
||||
- server/shared/types.ts LLMProvider
|
||||
- src/types/app.ts LLMProvider
|
||||
3) Mirror the nearest existing provider implementation for file naming, style,
|
||||
and error handling.
|
||||
4) Implement skills support with SkillsProvider and the current skill roots.
|
||||
5) Implement session synchronization if the provider stores transcript files.
|
||||
6) Ensure sessions use unique ids, safe path handling, and correct pagination.
|
||||
7) Keep `sessions` and `sessionSynchronizer` separate.
|
||||
8) Run:
|
||||
- npx eslint <touched files>
|
||||
- npx tsc --noEmit -p server/tsconfig.json
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
After adding or changing a provider, run the relevant checks:
|
||||
|
||||
```bash
|
||||
npx eslint server/modules/providers/**/*.ts server/shared/types.ts server/shared/interfaces.ts
|
||||
npx tsc --noEmit -p server/tsconfig.json
|
||||
```
|
||||
|
||||
Useful tests in this repo:
|
||||
|
||||
- `server/modules/providers/tests/mcp.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
|
||||
alongside the implementation.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Adding provider files but forgetting `provider.registry.ts` or
|
||||
`provider.routes.ts`.
|
||||
- Updating backend provider ids but not `src/types/app.ts` or the frontend
|
||||
provider constants.
|
||||
- Omitting `skills` or `sessionSynchronizer` from the wrapper.
|
||||
- Returning duplicate normalized message ids for split content.
|
||||
- Treating `limit === 0` as unbounded history.
|
||||
- Building file paths from raw session ids without validation.
|
||||
- Hardcoding a skill root without checking the provider's actual discovery rules.
|
||||
- Forgetting that Claude plugin skills are discovered differently from normal
|
||||
user/project skill folders.
|
||||
- Assuming one provider's MCP config file format works for the others.
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||
export { providerSkillsService } from './services/skills.service.js';
|
||||
|
||||
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const hasErrorCode = (error: unknown, code: string): boolean => (
|
||||
error instanceof Error && 'code' in error && error.code === code
|
||||
);
|
||||
|
||||
export class ClaudeProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
@@ -120,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Claude login has expired. Run claude /login again.',
|
||||
email,
|
||||
method: 'credentials_file',
|
||||
error: 'OAuth token has expired. Please re-authenticate with claude login',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: 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,
|
||||
};
|
||||
return { authenticated: false, email: null, method: null };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,196 +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: "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);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import { parseFrontMatter } from '@/shared/frontmatter.js';
|
||||
import type {
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
findProviderSkillMarkdownFiles,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readProviderSkillMarkdownDefinition,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude');
|
||||
|
||||
const getClaudePluginName = (pluginId: string): string | null => {
|
||||
const normalizedPluginId = pluginId.trim();
|
||||
if (!normalizedPluginId || normalizedPluginId === '@') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [pluginName] = normalizedPluginId.split('@');
|
||||
return readOptionalString(pluginName) ?? null;
|
||||
};
|
||||
|
||||
const stripMarkdownExtension = (filename: string): string =>
|
||||
filename.replace(/\.md$/i, '');
|
||||
|
||||
const pathExistsAsDirectory = async (directoryPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const directoryStats = await stat(directoryPath);
|
||||
return directoryStats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const listChildDirectories = async (directoryPath: string): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await readdir(directoryPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(directoryPath, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readClaudePluginName = async (
|
||||
installPath: string,
|
||||
pluginId: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const pluginConfig = await readJsonConfig(
|
||||
path.join(installPath, '.claude-plugin', 'plugin.json'),
|
||||
);
|
||||
|
||||
// Older or partial plugin installs may not have plugin.json yet. Falling
|
||||
// back keeps discovery useful without inventing a separate namespace.
|
||||
return readOptionalString(pluginConfig.name) ?? getClaudePluginName(pluginId);
|
||||
} catch {
|
||||
return getClaudePluginName(pluginId);
|
||||
}
|
||||
};
|
||||
|
||||
export class ClaudeSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('claude');
|
||||
}
|
||||
|
||||
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
||||
return [
|
||||
...(await super.listSkills(options)),
|
||||
...(await this.listPluginSkills(getClaudeHomePath())),
|
||||
];
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const claudeHomePath = getClaudeHomePath();
|
||||
|
||||
return [
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(claudeHomePath, 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.claude', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
|
||||
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
|
||||
const enabledPlugins = readObjectRecord(settings.enabledPlugins);
|
||||
if (!enabledPlugins) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const installedConfig = await readJsonConfig(
|
||||
path.join(claudeHomePath, 'plugins', 'installed_plugins.json'),
|
||||
);
|
||||
const installedPlugins = readObjectRecord(installedConfig.plugins);
|
||||
if (!installedPlugins) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills: ProviderSkill[] = [];
|
||||
const visitedPluginFolders = new Set<string>();
|
||||
const pluginEntries = Object.entries(enabledPlugins)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
for (const [pluginId, enabled] of pluginEntries) {
|
||||
if (enabled !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const installs = installedPlugins[pluginId];
|
||||
if (!Array.isArray(installs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const install of installs) {
|
||||
const installRecord = readObjectRecord(install);
|
||||
const installPath = readOptionalString(installRecord?.installPath);
|
||||
if (!installPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claude's installed path points at one version folder; the usable
|
||||
// plugin payloads live in the direct child folders beside it.
|
||||
const pluginFolders = await listChildDirectories(path.dirname(installPath));
|
||||
for (const pluginFolder of pluginFolders) {
|
||||
const pluginFolderKey = `${pluginId}:${path.resolve(pluginFolder)}`;
|
||||
if (visitedPluginFolders.has(pluginFolderKey)) {
|
||||
continue;
|
||||
}
|
||||
visitedPluginFolders.add(pluginFolderKey);
|
||||
|
||||
const pluginName = await readClaudePluginName(pluginFolder, pluginId);
|
||||
if (!pluginName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandsPath = path.join(pluginFolder, 'commands');
|
||||
if (await pathExistsAsDirectory(commandsPath)) {
|
||||
skills.push(
|
||||
...(await this.listPluginCommandSkills(commandsPath, pluginId, pluginName)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillsPath = path.join(pluginFolder, 'skills');
|
||||
if (!(await pathExistsAsDirectory(skillsPath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.push(
|
||||
...(await this.listPluginSkillMarkdowns(pluginFolder, pluginId, pluginName)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
private async listPluginCommandSkills(
|
||||
commandsPath: string,
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const skills: ProviderSkill[] = [];
|
||||
|
||||
try {
|
||||
const entries = await readdir(commandsPath, { withFileTypes: true });
|
||||
const commandFiles = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
for (const commandFile of commandFiles) {
|
||||
const sourcePath = path.join(commandsPath, commandFile.name);
|
||||
try {
|
||||
const definition = await this.readPluginCommandDefinition(sourcePath);
|
||||
skills.push({
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command: `/${pluginName}:${definition.name}`,
|
||||
scope: 'plugin',
|
||||
sourcePath,
|
||||
pluginName,
|
||||
pluginId,
|
||||
});
|
||||
} catch {
|
||||
// Malformed command markdown should not block sibling plugin commands.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable command folders are treated as empty plugin command sets.
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
private async readPluginCommandDefinition(
|
||||
commandPath: string,
|
||||
): Promise<{ name: string; description: string }> {
|
||||
const content = await readFile(commandPath, 'utf8');
|
||||
const parsed = parseFrontMatter(content);
|
||||
const data = readObjectRecord(parsed.data) ?? {};
|
||||
|
||||
return {
|
||||
name: stripMarkdownExtension(path.basename(commandPath)),
|
||||
description: readOptionalString(data.description) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private async listPluginSkillMarkdowns(
|
||||
installPath: string,
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const skillFiles = await findProviderSkillMarkdownFiles(path.join(installPath, 'skills'), {
|
||||
recursive: true,
|
||||
});
|
||||
const skills: ProviderSkill[] = [];
|
||||
|
||||
for (const skillPath of skillFiles) {
|
||||
try {
|
||||
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
||||
skills.push({
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command: `/${pluginName}:${definition.name}`,
|
||||
scope: 'plugin',
|
||||
sourcePath: skillPath,
|
||||
pluginName,
|
||||
pluginId,
|
||||
});
|
||||
} catch {
|
||||
// A bad plugin skill file should not block other installed plugin skills.
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.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 { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.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 type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class ClaudeProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new ClaudeProviderModels();
|
||||
readonly mcp = new ClaudeMcpProvider();
|
||||
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
||||
readonly skills: IProviderSkills = new ClaudeSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new ClaudeSessionSynchronizer();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +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';
|
||||
|
||||
export class CodexSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('codex');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const sources: ProviderSkillSource[] = [];
|
||||
const seenRootDirs = new Set<string>();
|
||||
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
|
||||
if (repoRoot) {
|
||||
// Codex checks repository skills at the launch folder, one folder above it,
|
||||
// and the topmost git root; these can collapse to the same directory.
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(repoRoot, '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
}
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'admin',
|
||||
rootDir: path.join('/etc', 'codex', 'skills'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'system',
|
||||
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
|
||||
commandPrefix: '$',
|
||||
});
|
||||
|
||||
return sources;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.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 { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.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 type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class CodexProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new CodexProviderModels();
|
||||
readonly mcp = new CodexMcpProvider();
|
||||
readonly auth: IProviderAuth = new CodexProviderAuth();
|
||||
readonly skills: IProviderSkills = new CodexSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new CodexSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CodexSessionSynchronizer();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
readObjectRecord,
|
||||
sanitizeLeafDirectoryName,
|
||||
} from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
@@ -191,6 +186,24 @@ function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown
|
||||
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 {
|
||||
/**
|
||||
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
|
||||
@@ -201,7 +214,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
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 storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
|
||||
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
|
||||
|
||||
@@ -1,31 +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';
|
||||
|
||||
export class CursorSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('cursor');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.cursor', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.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 { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.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 type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class CursorProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new CursorProviderModels();
|
||||
readonly mcp = new CursorMcpProvider();
|
||||
readonly auth: IProviderAuth = new CursorProviderAuth();
|
||||
readonly skills: IProviderSkills = new CursorSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new CursorSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CursorSessionSynchronizer();
|
||||
|
||||
|
||||
@@ -1,42 +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.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.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',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -88,15 +88,22 @@ function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
||||
const record = tokens as AnyRecord;
|
||||
const input = Number(record.input || 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 {
|
||||
used: total,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
total: total,
|
||||
breakdown: {
|
||||
input,
|
||||
output,
|
||||
cached,
|
||||
thoughts,
|
||||
tool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,36 +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';
|
||||
|
||||
export class GeminiSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.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 { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.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 type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new GeminiProviderModels();
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
readonly skills: IProviderSkills = new GeminiSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +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';
|
||||
const existingSession = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,506 +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,
|
||||
} 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;
|
||||
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(sessionId) as OpenCodeHistoryRow[];
|
||||
|
||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
||||
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const total = normalized.length;
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, total - normalizedOffset),
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
total,
|
||||
hasMore: normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.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 { LLMProvider } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
@@ -12,7 +11,6 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,17 +2,9 @@ import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.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 { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
|
||||
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -180,13 +172,7 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (
|
||||
normalized === 'claude'
|
||||
|| normalized === 'codex'
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
) {
|
||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -252,29 +238,6 @@ const parseSessionSearchLimit = (value: unknown): number => {
|
||||
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(
|
||||
'/:provider/auth/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
@@ -284,41 +247,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 -----------------
|
||||
router.get(
|
||||
'/:provider/skills',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||
const skills = await providerSkillsService.listProviderSkills(provider, { workspacePath });
|
||||
res.json(createApiSuccessResponse({ provider, skills }));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.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 = {
|
||||
/**
|
||||
@@ -64,7 +75,7 @@ export const providerMcpService = {
|
||||
|
||||
const scope = input.scope ?? 'project';
|
||||
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) {
|
||||
try {
|
||||
await provider.mcp.upsertServer({ ...input, scope });
|
||||
|
||||
@@ -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']);
|
||||
|
||||
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();
|
||||
@@ -22,7 +22,6 @@ export const sessionSynchronizerService = {
|
||||
codex: 0,
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
},
|
||||
{
|
||||
provider: 'opencode',
|
||||
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||
},
|
||||
];
|
||||
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
@@ -71,10 +67,6 @@ let watcherRescheduleAfterRefresh = false;
|
||||
* Filters watcher events to provider-specific session artifact file types.
|
||||
*/
|
||||
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
if (provider === 'opencode') {
|
||||
return path.basename(filePath) === 'opencode.db';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
|
||||
|
||||
export const providerSkillsService = {
|
||||
/**
|
||||
* Lists normalized skills visible to one provider.
|
||||
*/
|
||||
async listProviderSkills(
|
||||
providerName: string,
|
||||
options?: ProviderSkillListOptions,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.listSkills(options);
|
||||
},
|
||||
};
|
||||
@@ -2,9 +2,7 @@ import type {
|
||||
IProvider,
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
@@ -18,10 +16,8 @@ import type { LLMProvider } from '@/shared/types.js';
|
||||
*/
|
||||
export abstract class AbstractProvider implements IProvider {
|
||||
readonly id: LLMProvider;
|
||||
abstract readonly models: IProviderModels;
|
||||
abstract readonly mcp: IProviderMcp;
|
||||
abstract readonly auth: IProviderAuth;
|
||||
abstract readonly skills: IProviderSkills;
|
||||
abstract readonly sessions: IProviderSessions;
|
||||
abstract readonly sessionSynchronizer: IProviderSessionSynchronizer;
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderSkills } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
findProviderSkillMarkdownFiles,
|
||||
readProviderSkillMarkdownDefinition,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const resolveWorkspacePath = (workspacePath?: string): string =>
|
||||
path.resolve(workspacePath ?? process.cwd());
|
||||
|
||||
/**
|
||||
* Shared skills provider for provider-specific skill source discovery.
|
||||
*/
|
||||
export abstract class SkillsProvider implements IProviderSkills {
|
||||
protected readonly provider: LLMProvider;
|
||||
|
||||
protected constructor(provider: LLMProvider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
||||
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||
const sources = await this.getSkillSources(workspacePath);
|
||||
const skills: ProviderSkill[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
const skillFiles = await findProviderSkillMarkdownFiles(source.rootDir, {
|
||||
recursive: source.recursive,
|
||||
});
|
||||
for (const skillPath of skillFiles) {
|
||||
try {
|
||||
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
||||
const command = source.commandForSkill
|
||||
? source.commandForSkill(definition.name)
|
||||
: `${source.commandPrefix ?? '/'}${definition.name}`;
|
||||
|
||||
skills.push({
|
||||
provider: this.provider,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
command,
|
||||
scope: source.scope,
|
||||
sourcePath: skillPath,
|
||||
pluginName: source.pluginName,
|
||||
pluginId: source.pluginId,
|
||||
});
|
||||
} catch {
|
||||
// A malformed or unreadable skill markdown file should not hide other valid skills.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -341,7 +254,8 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
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));
|
||||
|
||||
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'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
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']);
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.addMcpServerToAllProviders({
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -1,512 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
const writeSkill = async (
|
||||
skillsRoot: string,
|
||||
directoryName: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<string> => {
|
||||
const skillDir = path.join(skillsRoot, directoryName);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillPath,
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\n\n`,
|
||||
'utf8',
|
||||
);
|
||||
return skillPath;
|
||||
};
|
||||
|
||||
const writeClaudePluginManifest = async (
|
||||
installPath: string,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
const pluginConfigDir = path.join(installPath, '.claude-plugin');
|
||||
await fs.mkdir(pluginConfigDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginConfigDir, 'plugin.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: '0.1.0',
|
||||
description: `${name} test plugin`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
};
|
||||
|
||||
const writeClaudePluginCommand = async (
|
||||
commandsRoot: string,
|
||||
commandName: string,
|
||||
description: string,
|
||||
): Promise<string> => {
|
||||
await fs.mkdir(commandsRoot, { recursive: true });
|
||||
const commandPath = path.join(commandsRoot, `${commandName}.md`);
|
||||
await fs.writeFile(
|
||||
commandPath,
|
||||
`---\ndescription: ${description}\nargument-hint: 'test args'\n---\n\nCommand body.\n`,
|
||||
'utf8',
|
||||
);
|
||||
return commandPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* This test covers Claude user/project skill folders plus plugin discovery from
|
||||
* installed plugin command files and fallback plugin skill files.
|
||||
*/
|
||||
test('providerSkillsService lists claude user, project, and enabled plugin skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-claude-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
const commandPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'notion-plugin',
|
||||
'notion',
|
||||
'abc123',
|
||||
);
|
||||
const skillPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'anthropic-agent-skills',
|
||||
'example-skills',
|
||||
'def456',
|
||||
);
|
||||
const disabledPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'disabled-marketplace',
|
||||
'disabled-skills',
|
||||
'ghi789',
|
||||
);
|
||||
const emptyIdPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'invalid-empty-plugin',
|
||||
'empty',
|
||||
'000',
|
||||
);
|
||||
const atIdPluginInstallPath = path.join(
|
||||
tempRoot,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'invalid-at-plugin',
|
||||
'at',
|
||||
'000',
|
||||
);
|
||||
const siblingSkillPluginPath = path.join(path.dirname(skillPluginInstallPath), 'legacy777');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.claude', 'skills'),
|
||||
'claude-user-dir',
|
||||
'claude-user',
|
||||
'Claude user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.claude', 'skills'),
|
||||
'claude-project-dir',
|
||||
'claude-project',
|
||||
'Claude project skill',
|
||||
);
|
||||
await writeClaudePluginManifest(commandPluginInstallPath, 'Notion');
|
||||
await writeClaudePluginCommand(
|
||||
path.join(commandPluginInstallPath, 'commands'),
|
||||
'insert-row',
|
||||
'Insert a Notion database row',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(commandPluginInstallPath, 'skills'),
|
||||
'ignored-command-plugin-skill-dir',
|
||||
'ignored-command-plugin-skill',
|
||||
'Command plugin fallback skill should be ignored',
|
||||
);
|
||||
await writeClaudePluginManifest(skillPluginInstallPath, 'ExampleSkills');
|
||||
await writeSkill(
|
||||
path.join(skillPluginInstallPath, 'skills'),
|
||||
'claude-plugin-dir',
|
||||
'claude-plugin',
|
||||
'Claude plugin skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(skillPluginInstallPath, 'skills'),
|
||||
'claude-plugin-second-dir',
|
||||
'claude-plugin-second',
|
||||
'Second Claude plugin skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(skillPluginInstallPath, 'skills', 'nested', 'collection'),
|
||||
'claude-plugin-nested-dir',
|
||||
'claude-plugin-nested',
|
||||
'Nested Claude plugin skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(siblingSkillPluginPath, 'skills'),
|
||||
'claude-plugin-sibling-dir',
|
||||
'claude-plugin-sibling',
|
||||
'Sibling Claude plugin skill',
|
||||
);
|
||||
await writeClaudePluginManifest(disabledPluginInstallPath, 'DisabledSkills');
|
||||
await writeClaudePluginCommand(
|
||||
path.join(disabledPluginInstallPath, 'commands'),
|
||||
'disabled-command',
|
||||
'Disabled plugin command',
|
||||
);
|
||||
await writeClaudePluginCommand(
|
||||
path.join(emptyIdPluginInstallPath, 'commands'),
|
||||
'invalid-empty-command',
|
||||
'Invalid empty id command',
|
||||
);
|
||||
await writeClaudePluginCommand(
|
||||
path.join(atIdPluginInstallPath, 'commands'),
|
||||
'invalid-at-command',
|
||||
'Invalid at id command',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(
|
||||
disabledPluginInstallPath,
|
||||
'skills',
|
||||
),
|
||||
'disabled-plugin-dir',
|
||||
'disabled-plugin',
|
||||
'Disabled plugin skill',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.claude', 'settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
enabledPlugins: {
|
||||
'': true,
|
||||
'@': true,
|
||||
'notion@notion-marketplace': true,
|
||||
'example-skills@anthropic-agent-skills': true,
|
||||
'disabled-skills@disabled-marketplace': false,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, '.claude', 'plugins', 'installed_plugins.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
plugins: {
|
||||
'': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: emptyIdPluginInstallPath,
|
||||
version: '000',
|
||||
},
|
||||
],
|
||||
'@': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: atIdPluginInstallPath,
|
||||
version: '000',
|
||||
},
|
||||
],
|
||||
'notion@notion-marketplace': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: commandPluginInstallPath,
|
||||
version: 'abc123',
|
||||
},
|
||||
],
|
||||
'example-skills@anthropic-agent-skills': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: skillPluginInstallPath,
|
||||
version: 'def456',
|
||||
},
|
||||
],
|
||||
'disabled-skills@disabled-marketplace': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: disabledPluginInstallPath,
|
||||
version: 'ghi789',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('claude', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('claude-user')?.scope, 'user');
|
||||
assert.equal(byName.get('claude-user')?.command, '/claude-user');
|
||||
assert.equal(byName.get('claude-project')?.scope, 'project');
|
||||
assert.equal(byName.get('claude-project')?.command, '/claude-project');
|
||||
|
||||
const pluginCommand = byName.get('insert-row');
|
||||
assert.equal(pluginCommand?.scope, 'plugin');
|
||||
assert.equal(pluginCommand?.pluginName, 'Notion');
|
||||
assert.equal(pluginCommand?.pluginId, 'notion@notion-marketplace');
|
||||
assert.equal(pluginCommand?.command, '/Notion:insert-row');
|
||||
assert.equal(pluginCommand?.description, 'Insert a Notion database row');
|
||||
assert.match(pluginCommand?.sourcePath ?? '', /commands[\\/]insert-row\.md$/);
|
||||
assert.equal(byName.has('ignored-command-plugin-skill'), false);
|
||||
|
||||
const pluginSkill = byName.get('claude-plugin');
|
||||
assert.equal(pluginSkill?.scope, 'plugin');
|
||||
assert.equal(pluginSkill?.pluginName, 'ExampleSkills');
|
||||
assert.equal(pluginSkill?.pluginId, 'example-skills@anthropic-agent-skills');
|
||||
assert.equal(pluginSkill?.command, '/ExampleSkills:claude-plugin');
|
||||
assert.equal(pluginSkill?.description, 'Claude plugin skill');
|
||||
assert.match(
|
||||
pluginSkill?.sourcePath ?? '',
|
||||
/cache[\\/]anthropic-agent-skills[\\/]example-skills[\\/]def456[\\/]skills[\\/]/,
|
||||
);
|
||||
|
||||
const secondPluginSkill = byName.get('claude-plugin-second');
|
||||
assert.equal(secondPluginSkill?.scope, 'plugin');
|
||||
assert.equal(secondPluginSkill?.command, '/ExampleSkills:claude-plugin-second');
|
||||
|
||||
const nestedPluginSkill = byName.get('claude-plugin-nested');
|
||||
assert.equal(nestedPluginSkill?.scope, 'plugin');
|
||||
assert.equal(nestedPluginSkill?.command, '/ExampleSkills:claude-plugin-nested');
|
||||
assert.equal(nestedPluginSkill?.description, 'Nested Claude plugin skill');
|
||||
|
||||
const siblingPluginSkill = byName.get('claude-plugin-sibling');
|
||||
assert.equal(siblingPluginSkill?.scope, 'plugin');
|
||||
assert.equal(siblingPluginSkill?.pluginName, 'example-skills');
|
||||
assert.equal(siblingPluginSkill?.command, '/example-skills:claude-plugin-sibling');
|
||||
assert.equal(siblingPluginSkill?.description, 'Sibling Claude plugin skill');
|
||||
assert.equal(byName.has('disabled-command'), false);
|
||||
assert.equal(byName.has('disabled-plugin'), false);
|
||||
assert.equal(byName.has('invalid-empty-command'), false);
|
||||
assert.equal(byName.has('invalid-at-command'), false);
|
||||
assert.equal(skills.some((skill) => skill.command.startsWith('/:')), false);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Codex repository/user/system skill folders and verifies that
|
||||
* repository lookup includes cwd, parent, and git root skill locations.
|
||||
*/
|
||||
test('providerSkillsService lists codex repository, user, and system skills', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-codex-'));
|
||||
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, '.agents', 'skills'),
|
||||
'codex-cwd-dir',
|
||||
'codex-cwd',
|
||||
'Codex cwd skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, 'packages', '.agents', 'skills'),
|
||||
'codex-parent-dir',
|
||||
'codex-parent',
|
||||
'Codex parent skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(repoRoot, '.agents', 'skills'),
|
||||
'codex-root-dir',
|
||||
'codex-root',
|
||||
'Codex root skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'codex-user-dir',
|
||||
'codex-user',
|
||||
'Codex user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.codex', 'skills', '.system'),
|
||||
'codex-system-dir',
|
||||
'codex-system',
|
||||
'Codex system skill',
|
||||
);
|
||||
|
||||
const skills = await providerSkillsService.listProviderSkills('codex', { workspacePath });
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
|
||||
assert.equal(byName.get('codex-cwd')?.scope, 'repo');
|
||||
assert.equal(byName.get('codex-parent')?.scope, 'repo');
|
||||
assert.equal(byName.get('codex-root')?.scope, 'repo');
|
||||
assert.equal(byName.get('codex-user')?.scope, 'user');
|
||||
assert.equal(byName.get('codex-system')?.scope, 'system');
|
||||
assert.equal(byName.get('codex-root')?.command, '$codex-root');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `.agents/skills` project support.
|
||||
*/
|
||||
test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.gemini', 'skills'),
|
||||
'gemini-user-dir',
|
||||
'gemini-user',
|
||||
'Gemini user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.agents', 'skills'),
|
||||
'agents-user-dir',
|
||||
'agents-user',
|
||||
'Agents user skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.gemini', 'skills'),
|
||||
'gemini-project-dir',
|
||||
'gemini-project',
|
||||
'Gemini project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.agents', 'skills'),
|
||||
'agents-project-dir',
|
||||
'agents-project',
|
||||
'Agents project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(workspacePath, '.cursor', 'skills'),
|
||||
'cursor-project-dir',
|
||||
'cursor-project',
|
||||
'Cursor project skill',
|
||||
);
|
||||
await writeSkill(
|
||||
path.join(tempRoot, '.cursor', 'skills'),
|
||||
'cursor-user-dir',
|
||||
'cursor-user',
|
||||
'Cursor user skill',
|
||||
);
|
||||
|
||||
const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
|
||||
const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
|
||||
assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
|
||||
assert.equal(geminiByName.get('agents-user')?.scope, 'user');
|
||||
assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
|
||||
assert.equal(geminiByName.get('agents-project')?.scope, 'project');
|
||||
assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
|
||||
|
||||
const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
|
||||
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
|
||||
assert.equal(cursorByName.get('agents-project')?.scope, 'project');
|
||||
assert.equal(cursorByName.get('cursor-project')?.scope, 'project');
|
||||
assert.equal(cursorByName.get('cursor-user')?.scope, 'user');
|
||||
assert.equal(cursorByName.get('cursor-user')?.command, '/cursor-user');
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -29,12 +29,10 @@ type ChatWebSocketDependencies = {
|
||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
||||
abortCursorSession: (sessionId: string) => boolean;
|
||||
abortCodexSession: (sessionId: string) => boolean;
|
||||
abortGeminiSession: (sessionId: string) => boolean;
|
||||
abortOpenCodeSession: (sessionId: string) => boolean;
|
||||
resolveToolApproval: (
|
||||
requestId: string,
|
||||
payload: {
|
||||
@@ -48,21 +46,19 @@ type ChatWebSocketDependencies = {
|
||||
isCursorSessionActive: (sessionId: string) => boolean;
|
||||
isCodexSessionActive: (sessionId: string) => boolean;
|
||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
||||
isOpenCodeSessionActive: (sessionId: string) => boolean;
|
||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
||||
getActiveClaudeSDKSessions: () => unknown;
|
||||
getActiveCursorSessions: () => unknown;
|
||||
getActiveCodexSessions: () => unknown;
|
||||
getActiveGeminiSessions: () => unknown;
|
||||
getActiveOpenCodeSessions: () => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
||||
*/
|
||||
function readProvider(value: unknown): LLMProvider {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -138,11 +134,6 @@ export function handleChatConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'opencode-command') {
|
||||
await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-resume') {
|
||||
await dependencies.spawnCursor(
|
||||
'',
|
||||
@@ -167,8 +158,6 @@ export function handleChatConnection(
|
||||
success = dependencies.abortCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
success = dependencies.abortGeminiSession(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
success = dependencies.abortOpenCodeSession(sessionId);
|
||||
} else {
|
||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||
}
|
||||
@@ -225,8 +214,6 @@ export function handleChatConnection(
|
||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
||||
} else if (provider === 'opencode') {
|
||||
isActive = dependencies.isOpenCodeSessionActive(sessionId);
|
||||
} else {
|
||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
@@ -264,7 +251,6 @@ export function handleChatConnection(
|
||||
cursor: dependencies.getActiveCursorSessions(),
|
||||
codex: dependencies.getActiveCodexSessions(),
|
||||
gemini: dependencies.getActiveGeminiSessions(),
|
||||
opencode: dependencies.getActiveOpenCodeSessions(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
|
||||
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) {
|
||||
clientWs.send(data, { binary: isBinary });
|
||||
clientWs.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
clientWs.on('message', (data, isBinary) => {
|
||||
clientWs.on('message', (data) => {
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.send(data, { binary: isBinary });
|
||||
upstream.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
|
||||
provider?: string;
|
||||
initialCommand?: string;
|
||||
isPlainShell?: boolean;
|
||||
forceRestart?: boolean;
|
||||
};
|
||||
|
||||
type PtySessionEntry = {
|
||||
@@ -137,13 +136,6 @@ function buildShellCommand(
|
||||
return command;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (hasSession && sessionId) {
|
||||
return `opencode --session "${sessionId}"`;
|
||||
}
|
||||
return initialCommand || 'opencode';
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (hasSession && sessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
@@ -181,7 +173,6 @@ export function handleShellConnection(
|
||||
const hasSession = readBoolean(data.hasSession);
|
||||
const provider = readString(data.provider, 'claude');
|
||||
const initialCommand = readString(data.initialCommand);
|
||||
const forceRestart = readBoolean(data.forceRestart);
|
||||
const isPlainShell =
|
||||
readBoolean(data.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
@@ -202,7 +193,7 @@ export function handleShellConnection(
|
||||
: '';
|
||||
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
||||
|
||||
if (isLoginCommand || forceRestart) {
|
||||
if (isLoginCommand) {
|
||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||
if (oldSession) {
|
||||
if (oldSession.timeoutId) {
|
||||
@@ -213,8 +204,7 @@ export function handleShellConnection(
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession =
|
||||
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
|
||||
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
||||
if (existingSession) {
|
||||
shellProcess = existingSession.pty;
|
||||
if (existingSession.timeoutId) {
|
||||
@@ -371,10 +361,6 @@ export function handleShellConnection(
|
||||
}
|
||||
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (session && session.pty !== shellProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
@@ -403,8 +389,6 @@ export function handleShellConnection(
|
||||
? 'Codex'
|
||||
: provider === 'gemini'
|
||||
? 'Gemini'
|
||||
: provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession
|
||||
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
@@ -458,10 +442,6 @@ export function handleShellConnection(
|
||||
|
||||
session.ws = null;
|
||||
session.timeoutId = setTimeout(() => {
|
||||
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.pty.kill();
|
||||
ptySessionsMap.delete(ptySessionKey as string);
|
||||
}, PTY_SESSION_TIMEOUT);
|
||||
|
||||
@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
|
||||
dependencies: WebSocketAuthDependencies
|
||||
): boolean {
|
||||
const request = info.req as AuthenticatedWebSocketRequest;
|
||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
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}`);
|
||||
console.log('WebSocket connection attempt to:', request.url);
|
||||
|
||||
// Platform mode: use the first DB user and skip token checks.
|
||||
if (dependencies.isPlatform) {
|
||||
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
|
||||
}
|
||||
|
||||
// OSS mode: read JWT from query string first, then Authorization header.
|
||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
const token =
|
||||
upgradeUrl.searchParams.get('token') ??
|
||||
request.headers.authorization?.split(' ')[1] ??
|
||||
|
||||
@@ -31,24 +31,6 @@ export function createWebSocketServer(
|
||||
});
|
||||
|
||||
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 url = incomingRequest.url ?? '/';
|
||||
const pathname = new URL(url, 'http://localhost').pathname;
|
||||
|
||||
@@ -17,40 +17,11 @@ import { Codex } from '@openai/codex-sdk';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
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 { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
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
|
||||
* @param {object} event - SDK event
|
||||
@@ -231,12 +202,6 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
permissionMode = 'default'
|
||||
} = options;
|
||||
|
||||
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||
'codex',
|
||||
sessionId,
|
||||
model,
|
||||
);
|
||||
|
||||
const workingDirectory = cwd || projectPath || process.cwd();
|
||||
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
|
||||
|
||||
@@ -257,7 +222,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode,
|
||||
approvalPolicy,
|
||||
model: resolvedModel
|
||||
model
|
||||
};
|
||||
|
||||
// Start or resume thread
|
||||
@@ -344,11 +309,9 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// Extract and send token usage if available (normalized to match Claude format)
|
||||
if (event.type === 'turn.completed') {
|
||||
const tokenBudget = extractCodexTokenBudget(event);
|
||||
if (tokenBudget) {
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||
}
|
||||
if (event.type === 'turn.completed' && event.usage) {
|
||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,336 +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 { 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;
|
||||
|
||||
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',
|
||||
}));
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command,
|
||||
sessionId: finalSessionId,
|
||||
provider: 'opencode',
|
||||
}));
|
||||
|
||||
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',
|
||||
}));
|
||||
notifyTerminalState({ error });
|
||||
reject(error);
|
||||
});
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function abortOpenCodeSession(sessionId) {
|
||||
const process = activeOpenCodeProcesses.get(sessionId);
|
||||
if (!process) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -9,9 +9,8 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { spawnGemini } from '../gemini-cli.js';
|
||||
import { spawnOpenCode } from '../opencode-cli.js';
|
||||
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 { normalizeProjectPath } from '../shared/utils.js';
|
||||
|
||||
@@ -609,7 +608,7 @@ class ResponseCollector {
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* ================================================================================================
|
||||
@@ -634,7 +633,7 @@ class ResponseCollector {
|
||||
* - Source for auto-generated branch names (if createBranch=true and no branchName)
|
||||
* - 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'
|
||||
*
|
||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
||||
@@ -752,7 +751,7 @@ class ResponseCollector {
|
||||
* Input Validations (400 Bad Request):
|
||||
* - Either githubUrl OR projectPath must be provided (not neither)
|
||||
* - 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)
|
||||
* - branchName must pass Git naming rules (if provided)
|
||||
*
|
||||
@@ -860,8 +859,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
|
||||
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -939,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
|
||||
if (provider === 'claude') {
|
||||
console.log('🤖 Starting Claude SDK session');
|
||||
@@ -972,7 +967,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || codexModels.DEFAULT,
|
||||
model: model || CODEX_MODELS.DEFAULT,
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
} else if (provider === 'gemini') {
|
||||
@@ -982,18 +977,9 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: sessionId || null,
|
||||
model: model || geminiModels.DEFAULT,
|
||||
model: model,
|
||||
skipPermissions: true // CLI mode bypasses permissions
|
||||
}, 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
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import { providerModelsService } from "../modules/providers/services/provider-models.service.js";
|
||||
import { parseFrontMatter } from "../shared/frontmatter.js";
|
||||
import { findAppRoot, getModuleDir } from "../utils/runtime-paths.js";
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { parseFrontmatter } from '../utils/frontmatter.js';
|
||||
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
|
||||
|
||||
const __dirname = getModuleDir(import.meta.url);
|
||||
// This route reads the top-level package.json for the status command, so it needs the real
|
||||
@@ -15,77 +13,6 @@ const APP_ROOT = findAppRoot(__dirname);
|
||||
|
||||
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)
|
||||
* @param {string} dir - Directory to scan
|
||||
@@ -107,30 +34,24 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
const subCommands = await scanCommandsDirectory(
|
||||
fullPath,
|
||||
baseDir,
|
||||
namespace,
|
||||
);
|
||||
const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
|
||||
commands.push(...subCommands);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Parse markdown file for metadata
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, "utf8");
|
||||
const { data: frontmatter, content: commandContent } =
|
||||
parseFrontMatter(content);
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
|
||||
|
||||
// Calculate relative path from baseDir for command name
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
// Remove .md extension and convert to command name
|
||||
const commandName =
|
||||
"/" + relativePath.replace(/\.md$/, "").replace(/\\/g, "/");
|
||||
const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
|
||||
|
||||
// Extract description from frontmatter or first line of content
|
||||
let description = frontmatter.description || "";
|
||||
let description = frontmatter.description || '';
|
||||
if (!description) {
|
||||
const firstLine = commandContent.trim().split("\n")[0];
|
||||
description = firstLine.replace(/^#+\s*/, "").trim();
|
||||
const firstLine = commandContent.trim().split('\n')[0];
|
||||
description = firstLine.replace(/^#+\s*/, '').trim();
|
||||
}
|
||||
|
||||
commands.push({
|
||||
@@ -139,7 +60,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
relativePath,
|
||||
description,
|
||||
namespace,
|
||||
metadata: frontmatter,
|
||||
metadata: frontmatter
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error parsing command file ${fullPath}:`, err.message);
|
||||
@@ -148,7 +69,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
}
|
||||
} catch (err) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -161,41 +82,53 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
||||
*/
|
||||
const builtInCommands = [
|
||||
{
|
||||
name: "/help",
|
||||
description: "Show help documentation for Claude Code",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/help',
|
||||
description: 'Show help documentation for Claude Code',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/models",
|
||||
description: "View available models for the current provider",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/clear',
|
||||
description: 'Clear the conversation history',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/cost",
|
||||
description: "Display token usage information",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/model',
|
||||
description: 'Switch or view the current AI model',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/memory",
|
||||
description: "Open CLAUDE.md memory file for editing",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/cost',
|
||||
description: 'Display token usage and cost information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/config",
|
||||
description: "Open settings and configuration",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/memory',
|
||||
description: 'Open CLAUDE.md memory file for editing',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: "/status",
|
||||
description: "Show system status and version information",
|
||||
namespace: "builtin",
|
||||
metadata: { type: "builtin" },
|
||||
name: '/config',
|
||||
description: 'Open settings and configuration',
|
||||
namespace: '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 +136,14 @@ const builtInCommands = [
|
||||
* Each handler returns { type: 'builtin', action: string, data: any }
|
||||
*/
|
||||
const builtInHandlers = {
|
||||
"/help": async (args, context) => {
|
||||
'/help': async (args, context) => {
|
||||
const helpText = `# Claude Code Commands
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
${builtInCommands
|
||||
.map(
|
||||
(cmd) => `### ${cmd.name}
|
||||
${builtInCommands.map(cmd => `### ${cmd.name}
|
||||
${cmd.description}
|
||||
`,
|
||||
)
|
||||
.join("\n")}
|
||||
`).join('\n')}
|
||||
|
||||
## Custom Commands
|
||||
|
||||
@@ -236,45 +165,77 @@ Custom commands can be created in:
|
||||
`;
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "help",
|
||||
type: 'builtin',
|
||||
action: 'help',
|
||||
data: {
|
||||
content: helpText,
|
||||
format: "markdown",
|
||||
commands: builtInCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
namespace: command.namespace,
|
||||
})),
|
||||
},
|
||||
format: 'markdown'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
"/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 provider = readModelProvider(context?.provider);
|
||||
const catalog = (await providerModelsService.getProviderModels(provider)).models;
|
||||
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
|
||||
const provider = context?.provider || 'claude';
|
||||
const model =
|
||||
context?.model ||
|
||||
(provider === 'cursor'
|
||||
? CURSOR_MODELS.DEFAULT
|
||||
: provider === 'codex'
|
||||
? CODEX_MODELS.DEFAULT
|
||||
: CLAUDE_MODELS.DEFAULT);
|
||||
|
||||
const reportedUsed =
|
||||
Number(
|
||||
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
|
||||
) || 0;
|
||||
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
|
||||
const total =
|
||||
Number(
|
||||
tokenUsage.total ??
|
||||
tokenUsage.contextWindow ??
|
||||
0,
|
||||
) || 0;
|
||||
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.input_tokens ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.breakdown?.input ??
|
||||
tokenUsage.promptTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
@@ -282,103 +243,106 @@ Custom commands can be created in:
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
tokenUsage.output ??
|
||||
tokenUsage.output_tokens ??
|
||||
tokenUsage.cumulativeOutputTokens ??
|
||||
tokenUsage.breakdown?.output ??
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
||||
const used = reportedUsed || inputTokensRaw + outputTokens;
|
||||
const cacheTokens =
|
||||
Number(
|
||||
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 {
|
||||
type: "builtin",
|
||||
action: "cost",
|
||||
type: 'builtin',
|
||||
action: 'cost',
|
||||
data: {
|
||||
tokenUsage: {
|
||||
used,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
},
|
||||
...(hasTokenBreakdown
|
||||
? {
|
||||
tokenBreakdown: {
|
||||
input: inputTokensRaw,
|
||||
output: outputTokens,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
"/status": async (args, context) => {
|
||||
'/status': async (args, context) => {
|
||||
// Read version from package.json
|
||||
const packageJsonPath = path.join(APP_ROOT, "package.json");
|
||||
let version = "unknown";
|
||||
let packageName = "claude-code-ui";
|
||||
const packageJsonPath = path.join(APP_ROOT, 'package.json');
|
||||
let version = 'unknown';
|
||||
let packageName = 'claude-code-ui';
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(packageJsonPath, "utf8"),
|
||||
);
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
version = packageJson.version;
|
||||
packageName = packageJson.name;
|
||||
} catch (err) {
|
||||
console.error("Error reading package.json:", err);
|
||||
console.error('Error reading package.json:', err);
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const uptimeMinutes = Math.floor(uptime / 60);
|
||||
const uptimeHours = Math.floor(uptimeMinutes / 60);
|
||||
const uptimeFormatted =
|
||||
uptimeHours > 0
|
||||
? `${uptimeHours}h ${uptimeMinutes % 60}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();
|
||||
const uptimeFormatted = uptimeHours > 0
|
||||
? `${uptimeHours}h ${uptimeMinutes % 60}m`
|
||||
: `${uptimeMinutes}m`;
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "status",
|
||||
type: 'builtin',
|
||||
action: 'status',
|
||||
data: {
|
||||
version,
|
||||
packageName,
|
||||
uptime: uptimeFormatted,
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
model,
|
||||
provider: statusProvider,
|
||||
model: context?.model || CLAUDE_MODELS.DEFAULT,
|
||||
provider: context?.provider || 'claude',
|
||||
nodeVersion: process.version,
|
||||
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),
|
||||
},
|
||||
},
|
||||
platform: process.platform
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
"/memory": async (args, context) => {
|
||||
'/memory': async (args, context) => {
|
||||
const projectPath = context?.projectPath;
|
||||
|
||||
if (!projectPath) {
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "memory",
|
||||
type: 'builtin',
|
||||
action: 'memory',
|
||||
data: {
|
||||
error: "No project selected",
|
||||
message: "Please select a project to access its CLAUDE.md file",
|
||||
},
|
||||
error: 'No project selected',
|
||||
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
|
||||
let exists = false;
|
||||
@@ -390,63 +354,85 @@ Custom commands can be created in:
|
||||
}
|
||||
|
||||
return {
|
||||
type: "builtin",
|
||||
action: "memory",
|
||||
type: 'builtin',
|
||||
action: 'memory',
|
||||
data: {
|
||||
path: claudeMdPath,
|
||||
exists,
|
||||
message: exists
|
||||
? `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 {
|
||||
type: "builtin",
|
||||
action: "config",
|
||||
type: 'builtin',
|
||||
action: 'config',
|
||||
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
|
||||
* List all available commands from project and user directories
|
||||
*/
|
||||
router.post("/list", async (req, res) => {
|
||||
router.post('/list', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
const allCommands = [...builtInCommands];
|
||||
|
||||
// Scan project-level commands (.claude/commands/)
|
||||
if (projectPath) {
|
||||
const projectCommandsDir = path.join(projectPath, ".claude", "commands");
|
||||
const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
|
||||
const projectCommands = await scanCommandsDirectory(
|
||||
projectCommandsDir,
|
||||
projectCommandsDir,
|
||||
"project",
|
||||
'project'
|
||||
);
|
||||
allCommands.push(...projectCommands);
|
||||
}
|
||||
|
||||
// Scan user-level commands (~/.claude/commands/)
|
||||
const homeDir = os.homedir();
|
||||
const userCommandsDir = path.join(homeDir, ".claude", "commands");
|
||||
const userCommandsDir = path.join(homeDir, '.claude', 'commands');
|
||||
const userCommands = await scanCommandsDirectory(
|
||||
userCommandsDir,
|
||||
userCommandsDir,
|
||||
"user",
|
||||
'user'
|
||||
);
|
||||
allCommands.push(...userCommands);
|
||||
|
||||
// Separate built-in and custom commands
|
||||
const customCommands = allCommands.filter(
|
||||
(cmd) => cmd.namespace !== "builtin",
|
||||
);
|
||||
const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
|
||||
|
||||
// Sort commands alphabetically by name
|
||||
customCommands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -454,13 +440,13 @@ router.post("/list", async (req, res) => {
|
||||
res.json({
|
||||
builtIn: builtInCommands,
|
||||
custom: customCommands,
|
||||
count: allCommands.length,
|
||||
count: allCommands.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error listing commands:", error);
|
||||
console.error('Error listing commands:', error);
|
||||
res.status(500).json({
|
||||
error: "Failed to list commands",
|
||||
message: error.message,
|
||||
error: 'Failed to list commands',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -471,13 +457,13 @@ router.post("/list", async (req, res) => {
|
||||
* This endpoint prepares the command content but doesn't execute bash commands yet
|
||||
* (that will be handled in the command parser utility)
|
||||
*/
|
||||
router.post("/execute", async (req, res) => {
|
||||
router.post('/execute', async (req, res) => {
|
||||
try {
|
||||
const { commandName, commandPath, args = [], context = {} } = req.body;
|
||||
|
||||
if (!commandName) {
|
||||
return res.status(400).json({
|
||||
error: "Command name is required",
|
||||
error: 'Command name is required'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,17 +474,14 @@ router.post("/execute", async (req, res) => {
|
||||
const result = await handler(args, context);
|
||||
return res.json({
|
||||
...result,
|
||||
command: commandName,
|
||||
command: commandName
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error executing built-in command ${commandName}:`,
|
||||
error,
|
||||
);
|
||||
console.error(`Error executing built-in command ${commandName}:`, error);
|
||||
return res.status(500).json({
|
||||
error: "Command execution failed",
|
||||
error: 'Command execution failed',
|
||||
message: error.message,
|
||||
command: commandName,
|
||||
command: commandName
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -506,7 +489,7 @@ router.post("/execute", async (req, res) => {
|
||||
// Handle custom commands
|
||||
if (!commandPath) {
|
||||
return res.status(400).json({
|
||||
error: "Command path is required for custom commands",
|
||||
error: 'Command path is required for custom commands'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,62 +497,56 @@ router.post("/execute", async (req, res) => {
|
||||
// Security: validate commandPath is within allowed directories
|
||||
{
|
||||
const resolvedPath = path.resolve(commandPath);
|
||||
const userBase = path.resolve(
|
||||
path.join(os.homedir(), ".claude", "commands"),
|
||||
);
|
||||
const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
|
||||
const projectBase = context?.projectPath
|
||||
? path.resolve(path.join(context.projectPath, ".claude", "commands"))
|
||||
? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
|
||||
: null;
|
||||
const isUnder = (base) => {
|
||||
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)))) {
|
||||
return res.status(403).json({
|
||||
error: "Access denied",
|
||||
message: "Command must be in .claude/commands directory",
|
||||
error: 'Access denied',
|
||||
message: 'Command must be in .claude/commands directory'
|
||||
});
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(commandPath, "utf8");
|
||||
const { data: metadata, content: commandContent } =
|
||||
parseFrontMatter(content);
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||
// Basic argument replacement (will be enhanced in command parser utility)
|
||||
let processedContent = commandContent;
|
||||
|
||||
// Replace $ARGUMENTS with all arguments joined
|
||||
const argsString = args.join(" ");
|
||||
const argsString = args.join(' ');
|
||||
processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
|
||||
|
||||
// Replace $1, $2, etc. with positional arguments
|
||||
args.forEach((arg, index) => {
|
||||
const placeholder = `$${index + 1}`;
|
||||
processedContent = processedContent.replace(
|
||||
new RegExp(`\\${placeholder}\\b`, "g"),
|
||||
arg,
|
||||
);
|
||||
processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
|
||||
});
|
||||
|
||||
res.json({
|
||||
type: "custom",
|
||||
type: 'custom',
|
||||
command: commandName,
|
||||
content: processedContent,
|
||||
metadata,
|
||||
hasFileIncludes: processedContent.includes("@"),
|
||||
hasBashCommands: processedContent.includes("!"),
|
||||
hasFileIncludes: processedContent.includes('@'),
|
||||
hasBashCommands: processedContent.includes('!')
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({
|
||||
error: "Command not found",
|
||||
message: `Command file not found: ${req.body.commandPath}`,
|
||||
error: 'Command not found',
|
||||
message: `Command file not found: ${req.body.commandPath}`
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Error executing command:", error);
|
||||
console.error('Error executing command:', error);
|
||||
res.status(500).json({
|
||||
error: "Failed to execute command",
|
||||
message: error.message,
|
||||
error: 'Failed to execute command',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
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();
|
||||
|
||||
@@ -29,7 +29,7 @@ router.get('/config', async (req, res) => {
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: CURSOR_FALLBACK_MODELS.DEFAULT,
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: 'GPT-5',
|
||||
},
|
||||
permissions: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import express from 'express';
|
||||
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -4,14 +4,8 @@ import type {
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
NormalizedMessage,
|
||||
ProviderSkill,
|
||||
ProviderSkillListOptions,
|
||||
ProviderAuthStatus,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderMcpServer,
|
||||
ProviderSessionActiveModelChange,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -24,54 +18,12 @@ import type {
|
||||
*/
|
||||
export interface IProvider {
|
||||
readonly id: LLMProvider;
|
||||
readonly models: IProviderModels;
|
||||
readonly mcp: IProviderMcp;
|
||||
readonly auth: IProviderAuth;
|
||||
readonly skills: IProviderSkills;
|
||||
readonly sessions: IProviderSessions;
|
||||
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 ------------
|
||||
/**
|
||||
@@ -87,22 +39,6 @@ export interface IProviderAuth {
|
||||
getStatus(): Promise<ProviderAuthStatus>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILLS INTERFACE ------------
|
||||
/**
|
||||
* Skills contract for one provider.
|
||||
*
|
||||
* Implementations discover provider-native skill markdown locations and return
|
||||
* normalized skill records with the exact command syntax expected by that
|
||||
* provider. Each skill is read from a `SKILL.md` file under its skill directory.
|
||||
*/
|
||||
export interface IProviderSkills {
|
||||
/**
|
||||
* Lists all skills visible to this provider for the optional workspace.
|
||||
*/
|
||||
listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]>;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER MCP INTERFACE ------------
|
||||
/**
|
||||
|
||||
@@ -65,94 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
|
||||
* Use this as the source of truth whenever a function or payload needs to identify
|
||||
* a specific LLM integration.
|
||||
*/
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
|
||||
|
||||
/**
|
||||
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
|
||||
*/
|
||||
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;
|
||||
};
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
|
||||
|
||||
/**
|
||||
* Message/event variants emitted by provider adapters and normalized transports.
|
||||
@@ -258,69 +171,6 @@ export type FetchHistoryResult = {
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- PROVIDER SKILL TYPES ------------
|
||||
/**
|
||||
* Scope where a provider skill definition was discovered.
|
||||
*
|
||||
* Provider skill adapters should use this to describe the origin of each
|
||||
* skill markdown file without leaking provider-specific folder names into route
|
||||
* contracts. `repo` is used for Codex repository lookup locations, while
|
||||
* `project` is used for providers that treat workspace-local skills as project
|
||||
* scoped.
|
||||
*/
|
||||
export type ProviderSkillScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system';
|
||||
|
||||
/**
|
||||
* Shared input accepted by provider skill listing operations.
|
||||
*
|
||||
* Routes pass `workspacePath` when a caller wants project/repository skills for
|
||||
* a specific folder. Providers should fall back to the backend process cwd when
|
||||
* this option is omitted.
|
||||
*/
|
||||
export type ProviderSkillListOptions = {
|
||||
workspacePath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalized skill record returned by provider skill adapters.
|
||||
*
|
||||
* The `command` value is the exact invocation text the selected provider expects
|
||||
* for this skill. Claude plugin skills use a namespaced command such as
|
||||
* `/plugin-name:skill-name`, while Codex skills use the `$skill-name` form.
|
||||
* `sourcePath` points to the skill markdown file that produced the record so
|
||||
* callers can distinguish duplicate skill names across scopes.
|
||||
*/
|
||||
export type ProviderSkill = {
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
scope: ProviderSkillScope;
|
||||
sourcePath: string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal source descriptor consumed by shared provider skill discovery logic.
|
||||
*
|
||||
* Concrete provider adapters build these records from their native lookup rules.
|
||||
* The shared skills provider then scans `rootDir` for child skill markdown files
|
||||
* and uses `commandForSkill` or `commandPrefix` to produce the provider-specific
|
||||
* invocation command. Set `recursive` only when a provider stores skills under
|
||||
* arbitrary nested folders below the source root.
|
||||
*/
|
||||
export type ProviderSkillSource = {
|
||||
scope: ProviderSkillScope;
|
||||
rootDir: string;
|
||||
recursive?: boolean;
|
||||
commandPrefix?: '/' | '$';
|
||||
commandForSkill?: (skillName: string) => string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SHARED ERROR TYPES ------------
|
||||
/**
|
||||
|
||||
@@ -17,18 +17,11 @@ import readline from 'node:readline';
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { parseFrontMatter } from '@/shared/frontmatter.js';
|
||||
import type {
|
||||
AnyRecord,
|
||||
ApiSuccessShape,
|
||||
AppErrorOptions,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
ProviderSkillSource,
|
||||
WorkspacePathValidationResult,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
@@ -419,231 +412,6 @@ export const readStringRecord = (value: unknown): Record<string, string> | undef
|
||||
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 ------------
|
||||
/**
|
||||
@@ -735,160 +503,6 @@ export const writeJsonConfig = async (filePath: string, data: Record<string, unk
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
// ---------------------------
|
||||
//----------------- 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.
|
||||
*
|
||||
* Skill systems usually store one skill per child directory, so direct mode
|
||||
* scans only `<root>/<skill-name>/SKILL.md`. Recursive mode is reserved for
|
||||
* provider sources that can nest skills arbitrarily, and it returns every
|
||||
* descendant `SKILL.md`. Missing or unreadable roots return an empty list
|
||||
* because users may not have every provider installed or configured.
|
||||
*/
|
||||
export async function findProviderSkillMarkdownFiles(
|
||||
rootDir: string,
|
||||
options: { recursive?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const skillFiles: string[] = [];
|
||||
|
||||
const collectRecursive = async (dirPath: string): Promise<void> => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const skillPath = path.join(dirPath, 'SKILL.md');
|
||||
const skillStats = await stat(skillPath);
|
||||
if (skillStats.isFile()) {
|
||||
skillFiles.push(skillPath);
|
||||
}
|
||||
} catch {
|
||||
// Directories without SKILL.md are expected while walking plugin trees.
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await collectRecursive(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.recursive) {
|
||||
await collectRecursive(rootDir);
|
||||
return skillFiles.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillPath = path.join(rootDir, entry.name, 'SKILL.md');
|
||||
try {
|
||||
const skillStats = await stat(skillPath);
|
||||
if (skillStats.isFile()) {
|
||||
skillFiles.push(skillPath);
|
||||
}
|
||||
} catch {
|
||||
// A partial skill directory should not block discovery of sibling skills.
|
||||
}
|
||||
}
|
||||
|
||||
return skillFiles.sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `name` and `description` fields from a provider skill markdown file.
|
||||
*
|
||||
* The metadata is expected in markdown front matter. If a skill omits `name`, the
|
||||
* parent directory name is used as a stable fallback so providers can still
|
||||
* expose the skill. Missing descriptions are normalized to an empty string.
|
||||
*/
|
||||
export async function readProviderSkillMarkdownDefinition(
|
||||
skillPath: string,
|
||||
): Promise<{ name: string; description: string }> {
|
||||
const content = await readFile(skillPath, 'utf8');
|
||||
const parsed = parseFrontMatter(content);
|
||||
const data = readObjectRecord(parsed.data) ?? {};
|
||||
const fallbackName = path.basename(path.dirname(skillPath));
|
||||
|
||||
return {
|
||||
name: readOptionalString(data.name) ?? fallbackName,
|
||||
description: readOptionalString(data.description) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
//----------------- SESSION SYNCHRONIZER TITLE HELPERS ------------
|
||||
/**
|
||||
@@ -908,98 +522,6 @@ export function normalizeSessionName(rawValue: string | undefined, fallback: str
|
||||
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 ------------
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { parse as parseShellCommand } from 'shell-quote';
|
||||
|
||||
import { parseFrontMatter } from '../shared/frontmatter.js';
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -34,7 +32,7 @@ const BASH_COMMAND_ALLOWLIST = [
|
||||
*/
|
||||
export function parseCommand(content) {
|
||||
try {
|
||||
const parsed = parseFrontMatter(content);
|
||||
const parsed = parseFrontmatter(content);
|
||||
return {
|
||||
data: parsed.data || {},
|
||||
content: parsed.content || '',
|
||||
|
||||
@@ -9,10 +9,10 @@ const frontmatterOptions = {
|
||||
engines: {
|
||||
js: disabledFrontmatterEngine,
|
||||
javascript: disabledFrontmatterEngine,
|
||||
json: disabledFrontmatterEngine,
|
||||
},
|
||||
json: disabledFrontmatterEngine
|
||||
}
|
||||
};
|
||||
|
||||
export function parseFrontMatter(content: string) {
|
||||
export function parseFrontmatter(content) {
|
||||
return matter(content, frontmatterOptions);
|
||||
}
|
||||
107
shared/modelConstants.js
Normal file
107
shared/modelConstants.js
Normal 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 },
|
||||
];
|
||||
95
src/App.tsx
95
src/App.tsx
@@ -1,6 +1,5 @@
|
||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, ProtectedRoute } from './components/auth';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
@@ -10,99 +9,7 @@ import { PluginsProvider } from './contexts/PluginsContext';
|
||||
import AppContent from './components/app/AppContent';
|
||||
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() {
|
||||
const routerBasename = detectRouterBasename();
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeProvider>
|
||||
@@ -112,7 +19,7 @@ export default function App() {
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router basename={routerBasename}>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
|
||||
44
src/components/chat/constants/thinkingModes.ts
Normal file
44
src/components/chat/constants/thinkingModes.ts
Normal 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'
|
||||
}
|
||||
];
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { thinkingModes } from '../constants/thinkingModes';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type {
|
||||
@@ -19,13 +20,14 @@ import type {
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
} 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 { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -40,7 +42,6 @@ interface UseChatComposerStateArgs {
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
opencodeModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
@@ -54,6 +55,8 @@ interface UseChatComposerStateArgs {
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => 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;
|
||||
@@ -75,69 +78,6 @@ interface CommandExecutionResult {
|
||||
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 = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
@@ -171,7 +111,6 @@ export function useChatComposerState({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -185,6 +124,8 @@ export function useChatComposerState({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -203,7 +144,7 @@ export function useChatComposerState({
|
||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
@@ -211,39 +152,40 @@ export function useChatComposerState({
|
||||
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
||||
>(null);
|
||||
const inputValueRef = useRef(input);
|
||||
const selectedProjectId = selectedProject?.projectId;
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
clearMessages();
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
setCommandModalPayload({
|
||||
kind: 'help',
|
||||
data: (data || {}) as HelpCommandData,
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'models':
|
||||
setCommandModalPayload({
|
||||
kind: 'models',
|
||||
data: (data || {}) as ModelCommandData,
|
||||
case 'model':
|
||||
addMessage({
|
||||
type: 'assistant',
|
||||
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;
|
||||
|
||||
case 'cost': {
|
||||
setCommandModalPayload({
|
||||
kind: 'cost',
|
||||
data: (data || {}) as CostCommandData,
|
||||
});
|
||||
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}`;
|
||||
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
setCommandModalPayload({
|
||||
kind: 'status',
|
||||
data: (data || {}) as StatusCommandData,
|
||||
});
|
||||
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}`;
|
||||
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -270,17 +212,30 @@ export function useChatComposerState({
|
||||
onShowSettings?.();
|
||||
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:
|
||||
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 { content, hasBashCommands } = result;
|
||||
|
||||
@@ -311,7 +266,7 @@ export function useChatComposerState({
|
||||
}, [addMessage]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
|
||||
async (command: SlashCommand, rawInput?: string) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
@@ -329,15 +284,7 @@ export function useChatComposerState({
|
||||
projectId: selectedProject.projectId,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'gemini'
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
@@ -368,10 +315,8 @@ export function useChatComposerState({
|
||||
const result = (await response.json()) as CommandExecutionResult;
|
||||
if (result.type === 'builtin') {
|
||||
handleBuiltInCommand(result);
|
||||
if (!options?.preserveInput) {
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
}
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
} else if (result.type === 'custom') {
|
||||
await handleCustomCommand(result);
|
||||
}
|
||||
@@ -391,7 +336,6 @@ export function useChatComposerState({
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
@@ -402,19 +346,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 {
|
||||
slashCommands,
|
||||
slashCommandsCount,
|
||||
@@ -430,7 +361,6 @@ export function useChatComposerState({
|
||||
handleCommandMenuKeyDown,
|
||||
} = useSlashCommands({
|
||||
selectedProject,
|
||||
provider,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
@@ -540,27 +470,14 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 isHelpAlias = commandInput.trim().toLowerCase() === 'help';
|
||||
if (commandInput.startsWith('/') || isHelpAlias) {
|
||||
const firstSpace = commandInput.indexOf(' ');
|
||||
const commandName = isHelpAlias
|
||||
? '/help'
|
||||
: 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') {
|
||||
executeCommand(matchedCommand, isHelpAlias ? '/help' : commandInput);
|
||||
// Intercept slash commands: if input starts with /commandName, execute as command with args
|
||||
const trimmedInput = currentInput.trim();
|
||||
if (trimmedInput.startsWith('/')) {
|
||||
const firstSpace = trimmedInput.indexOf(' ');
|
||||
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
|
||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||
if (matchedCommand) {
|
||||
executeCommand(matchedCommand, trimmedInput);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
@@ -575,7 +492,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[] = [];
|
||||
if (attachedImages.length > 0) {
|
||||
@@ -632,9 +553,13 @@ export function useChatComposerState({
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
// This tracks only that a request is in flight before the provider has
|
||||
// emitted its real session id; routing still waits for session_created.
|
||||
pendingViewSessionRef.current = { startedAt: Date.now() };
|
||||
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);
|
||||
@@ -650,8 +575,6 @@ export function useChatComposerState({
|
||||
? 'codex-settings'
|
||||
: provider === 'gemini'
|
||||
? 'gemini-settings'
|
||||
: provider === 'opencode'
|
||||
? 'opencode-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
@@ -719,20 +642,6 @@ export function useChatComposerState({
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'opencode') {
|
||||
sendMessage({
|
||||
type: 'opencode-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: opencodeModel,
|
||||
sessionSummary,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
@@ -758,6 +667,7 @@ export function useChatComposerState({
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
setIsTextareaExpanded(false);
|
||||
setThinkingMode('none');
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
@@ -774,7 +684,6 @@ export function useChatComposerState({
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
@@ -791,6 +700,7 @@ export function useChatComposerState({
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
thinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -803,27 +713,27 @@ export function useChatComposerState({
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProjectId}`) || '';
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
|
||||
setInput((previous) => {
|
||||
const next = previous === savedInput ? previous : savedInput;
|
||||
inputValueRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [selectedProjectId]);
|
||||
}, [selectedProject?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
if (input !== '') {
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProjectId}`, input);
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProjectId}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
}
|
||||
}, [input, selectedProjectId]);
|
||||
}, [input, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
@@ -944,11 +854,15 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
@@ -966,7 +880,7 @@ export function useChatComposerState({
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
|
||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
@@ -1027,6 +941,8 @@ export function useChatComposerState({
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -1062,8 +978,5 @@ export function useChatComposerState({
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||
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[]
|
||||
* that the existing UI components expect.
|
||||
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
|
||||
// First pass: collect tool results for attachment
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
const toolUseIds = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId) {
|
||||
toolUseIds.add(msg.toolId);
|
||||
}
|
||||
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
|
||||
const toolResult = tr
|
||||
? {
|
||||
content: formatToolResultContent(tr.content),
|
||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||
isError: Boolean(tr.isError),
|
||||
toolUseResult: (tr as any).toolUseResult,
|
||||
}
|
||||
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
||||
break;
|
||||
|
||||
// tool_result is handled via attachment to tool_use above
|
||||
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,
|
||||
});
|
||||
case 'tool_result':
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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 {
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
Project,
|
||||
ProviderModelsCacheInfo,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../types/app';
|
||||
|
||||
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
||||
claude: 'opus',
|
||||
cursor: 'gpt-5.3-codex',
|
||||
codex: 'gpt-5.4',
|
||||
gemini: 'gemini-3.1-pro-preview',
|
||||
opencode: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
|
||||
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
|
||||
if (provider === 'codex') {
|
||||
@@ -24,242 +11,33 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[]
|
||||
if (provider === 'claude') {
|
||||
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
}
|
||||
if (provider === 'opencode') {
|
||||
return ['default'];
|
||||
}
|
||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
};
|
||||
|
||||
interface UseChatProviderStateArgs {
|
||||
selectedSession: ProjectSession | null;
|
||||
selectedProject: Project | null;
|
||||
}
|
||||
|
||||
type ProviderModelsApiResponse = {
|
||||
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) {
|
||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||
const [provider, setProvider] = useState<LLMProvider>(() => {
|
||||
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||
});
|
||||
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>(() => {
|
||||
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
});
|
||||
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>(() => {
|
||||
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;
|
||||
});
|
||||
|
||||
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 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]);
|
||||
|
||||
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(() => {
|
||||
if (!selectedSession?.id) {
|
||||
@@ -329,41 +107,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
}
|
||||
}, [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 {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -375,18 +118,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
||||
selectProviderModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -62,7 +63,6 @@ interface UseChatRealtimeHandlersArgs {
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||
@@ -89,7 +89,6 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -105,7 +104,7 @@ export function useChatRealtimeHandlers({
|
||||
lastProcessedMessageRef.current = latestMessage;
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || null;
|
||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Legacy messages (no `kind` field) — handle and return */
|
||||
@@ -152,12 +151,10 @@ export function useChatRealtimeHandlers({
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
|
||||
if (msg.isProcessing) {
|
||||
onSessionActive?.(statusSessionId);
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
||||
return;
|
||||
}
|
||||
|
||||
onSessionInactive?.(statusSessionId);
|
||||
onSessionNotProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
@@ -238,21 +235,15 @@ export function useChatRealtimeHandlers({
|
||||
if (!currentSessionId) {
|
||||
console.log('Session created with ID:', newSessionId);
|
||||
console.log('Existing session ID:', currentSessionId);
|
||||
sessionStorage.setItem('pendingSessionId', newSessionId);
|
||||
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
||||
pendingViewSessionRef.current.sessionId = newSessionId;
|
||||
}
|
||||
setCurrentSessionId(newSessionId);
|
||||
setPendingPermissionRequests((prev) =>
|
||||
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
||||
);
|
||||
}
|
||||
pendingViewSessionRef.current = null;
|
||||
onSessionActive?.(newSessionId);
|
||||
onSessionProcessing?.(newSessionId);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
onNavigateToSession?.(newSessionId);
|
||||
break;
|
||||
}
|
||||
@@ -275,7 +266,6 @@ export function useChatRealtimeHandlers({
|
||||
setPendingPermissionRequests([]);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
|
||||
// Handle aborted case
|
||||
if (msg.aborted) {
|
||||
@@ -289,10 +279,16 @@ export function useChatRealtimeHandlers({
|
||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||
? msg.actualSessionId
|
||||
: null;
|
||||
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
|
||||
const isVisibleSession =
|
||||
Boolean(
|
||||
sid
|
||||
&& sid === activeViewSessionId,
|
||||
&& (
|
||||
sid === activeViewSessionId
|
||||
|| sid === pendingSessionId
|
||||
|| pendingViewSessionRef.current?.sessionId === sid
|
||||
),
|
||||
);
|
||||
|
||||
if (actualSessionId && sid && actualSessionId !== sid) {
|
||||
@@ -300,6 +296,17 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
if (isVisibleSession) {
|
||||
setCurrentSessionId(actualSessionId);
|
||||
|
||||
if (pendingViewSessionRef.current) {
|
||||
const pendingSession = pendingViewSessionRef.current.sessionId;
|
||||
if (!pendingSession || pendingSession === sid) {
|
||||
pendingViewSessionRef.current.sessionId = actualSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSuccessfully && pendingSessionId === sid) {
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
|
||||
if (isVisibleSession) {
|
||||
@@ -309,6 +316,16 @@ export function useChatRealtimeHandlers({
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear pending session
|
||||
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
|
||||
const resolvedSessionId = actualSessionId || pendingSessionId;
|
||||
setCurrentSessionId(resolvedSessionId);
|
||||
if (actualSessionId) {
|
||||
onNavigateToSession?.(resolvedSessionId, { replace: true });
|
||||
}
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -318,7 +335,6 @@ export function useChatRealtimeHandlers({
|
||||
setClaudeStatus(null);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -383,7 +399,6 @@ export function useChatRealtimeHandlers({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
|
||||
@@ -13,6 +13,7 @@ const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -159,7 +160,7 @@ export function useChatSessionState({
|
||||
* Why this is essential:
|
||||
* - Chat keeps local state that is not fully derived from `selectedSession`:
|
||||
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
|
||||
* pagination/scroll bookkeeping, and provider-specific sessionStorage keys.
|
||||
* pagination/scroll bookkeeping, and pending session IDs in sessionStorage.
|
||||
* - If the user clicks New Session while already on the same route with no
|
||||
* selected session, parent state updates can be idempotent and this local
|
||||
* state would otherwise persist, making the click appear to "do nothing".
|
||||
@@ -176,6 +177,7 @@ export function useChatSessionState({
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
@@ -394,12 +396,6 @@ export function useChatSessionState({
|
||||
// Main session loading effect — store-based
|
||||
useEffect(() => {
|
||||
if (!selectedSession || !selectedProject) {
|
||||
// A new provider run can be in flight before the router has a canonical
|
||||
// selectedSession. Keep the processing banner alive until complete/error.
|
||||
if (pendingViewSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
@@ -541,6 +537,10 @@ export function useChatSessionState({
|
||||
}
|
||||
}, [selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) pendingViewSessionRef.current = null;
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
// Scroll to search target
|
||||
useEffect(() => {
|
||||
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
|
||||
@@ -624,23 +624,19 @@ export function useChatSessionState({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||
|
||||
// Initial token usage fetch for providers with file-backed usage data.
|
||||
// Token usage fetch for Claude
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
if (sessionProvider !== 'claude') return;
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
// Token usage endpoint is now keyed by the DB projectId.
|
||||
const params = new URLSearchParams({ provider: sessionProvider });
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import Fuse from 'fuse.js';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type { LLMProvider, Project } from '../../../types/app';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
const COMMAND_QUERY_DEBOUNCE_MS = 150;
|
||||
|
||||
@@ -12,37 +12,19 @@ export interface SlashCommand {
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: 'built-in' | 'custom' | 'skill' | string;
|
||||
type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseSlashCommandsOptions {
|
||||
selectedProject: Project | null;
|
||||
provider: LLMProvider;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
type ProviderSkill = {
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
scope: string;
|
||||
sourcePath?: string;
|
||||
pluginName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
type ProviderSkillsResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
skills?: ProviderSkill[];
|
||||
};
|
||||
};
|
||||
|
||||
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
|
||||
|
||||
const readCommandHistory = (projectName: string): Record<string, number> => {
|
||||
@@ -66,78 +48,8 @@ const saveCommandHistory = (projectName: string, history: Record<string, number>
|
||||
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
|
||||
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
|
||||
|
||||
const isSkillCommand = (command: SlashCommand) =>
|
||||
command.type === 'skill' || command.metadata?.type === 'skill';
|
||||
|
||||
const dedupeProviderSkills = (skills: ProviderSkill[]): ProviderSkill[] => {
|
||||
const seenCommands = new Set<string>();
|
||||
|
||||
return skills.filter((skill) => {
|
||||
// Multiple physical Claude plugin folders can expose the same invocation.
|
||||
// The slash menu should show each executable command only once.
|
||||
const key = skill.command;
|
||||
if (seenCommands.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenCommands.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const mapSkillToSlashCommand = (skill: ProviderSkill): SlashCommand => ({
|
||||
name: skill.command,
|
||||
description: skill.description,
|
||||
namespace: 'skill',
|
||||
path: skill.sourcePath,
|
||||
type: 'skill',
|
||||
metadata: {
|
||||
type: skill.scope,
|
||||
scope: skill.scope,
|
||||
sourcePath: skill.sourcePath,
|
||||
pluginName: skill.pluginName,
|
||||
pluginId: skill.pluginId,
|
||||
skillName: skill.name,
|
||||
},
|
||||
});
|
||||
|
||||
const filterSlashCommands = (
|
||||
commands: SlashCommand[],
|
||||
query: string,
|
||||
): SlashCommand[] => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
const commandPrefix = normalizedQuery.startsWith('/')
|
||||
? normalizedQuery
|
||||
: `/${normalizedQuery}`;
|
||||
const namePrefixMatches = commands.filter((command) =>
|
||||
command.name.toLowerCase().startsWith(commandPrefix),
|
||||
);
|
||||
|
||||
// Namespaced commands should behave like path completion. Once a provider
|
||||
// namespace is typed, only exact command-prefix matches should stay visible.
|
||||
if (normalizedQuery.includes(':') || namePrefixMatches.length > 0) {
|
||||
return namePrefixMatches;
|
||||
}
|
||||
|
||||
const nameSubstringMatches = commands.filter((command) =>
|
||||
command.name.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
if (nameSubstringMatches.length > 0) {
|
||||
return nameSubstringMatches;
|
||||
}
|
||||
|
||||
return commands.filter((command) =>
|
||||
command.description?.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
};
|
||||
|
||||
export function useSlashCommands({
|
||||
selectedProject,
|
||||
provider,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
@@ -168,8 +80,6 @@ export function useSlashCommands({
|
||||
}, [clearCommandQueryTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchCommands = async () => {
|
||||
if (!selectedProject) {
|
||||
setSlashCommands([]);
|
||||
@@ -178,14 +88,13 @@ export function useSlashCommands({
|
||||
}
|
||||
|
||||
try {
|
||||
const workspacePath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const response = await authenticatedFetch('/api/commands/list', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectPath: workspacePath || selectedProject.path,
|
||||
projectPath: selectedProject.path,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -194,25 +103,11 @@ export function useSlashCommands({
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const skillsParams = new URLSearchParams();
|
||||
if (workspacePath) {
|
||||
skillsParams.set('workspacePath', workspacePath);
|
||||
}
|
||||
|
||||
const skillsResponse = await authenticatedFetch(
|
||||
`/api/providers/${encodeURIComponent(provider)}/skills${skillsParams.toString() ? `?${skillsParams.toString()}` : ''}`,
|
||||
);
|
||||
const skillsData = skillsResponse.ok
|
||||
? ((await skillsResponse.json()) as ProviderSkillsResponse)
|
||||
: null;
|
||||
const skillCommands = dedupeProviderSkills(skillsData?.data?.skills || [])
|
||||
.map(mapSkillToSlashCommand);
|
||||
const allCommands: SlashCommand[] = [
|
||||
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'built-in',
|
||||
})),
|
||||
...skillCommands,
|
||||
...((data.custom || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'custom',
|
||||
@@ -226,22 +121,15 @@ export function useSlashCommands({
|
||||
return commandBUsage - commandAUsage;
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
setSlashCommands(sortedCommands);
|
||||
}
|
||||
setSlashCommands(sortedCommands);
|
||||
} catch (error) {
|
||||
console.error('Error fetching slash commands:', error);
|
||||
if (!cancelled) {
|
||||
setSlashCommands([]);
|
||||
}
|
||||
setSlashCommands([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCommands();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProject, provider]);
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandMenu) {
|
||||
@@ -249,9 +137,36 @@ export function useSlashCommands({
|
||||
}
|
||||
}, [showCommandMenu]);
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
if (!slashCommands.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Fuse(slashCommands, {
|
||||
keys: [
|
||||
{ name: 'name', weight: 2 },
|
||||
{ name: 'description', weight: 1 },
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 1,
|
||||
});
|
||||
}, [slashCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredCommands(filterSlashCommands(slashCommands, commandQuery));
|
||||
}, [commandQuery, slashCommands]);
|
||||
if (!commandQuery) {
|
||||
setFilteredCommands(slashCommands);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fuse) {
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = fuse.search(commandQuery);
|
||||
setFilteredCommands(results.map((result) => result.item));
|
||||
}, [commandQuery, slashCommands, fuse]);
|
||||
|
||||
const frequentCommands = useMemo(() => {
|
||||
if (!selectedProject || slashCommands.length === 0) {
|
||||
@@ -283,63 +198,25 @@ export function useSlashCommands({
|
||||
[selectedProject],
|
||||
);
|
||||
|
||||
const insertCommandIntoInput = useCallback(
|
||||
const selectCommandFromKeyboard = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const currentTextarea = textareaRef.current;
|
||||
const insertionStart = slashPosition >= 0
|
||||
? slashPosition
|
||||
: currentTextarea?.selectionStart ?? input.length;
|
||||
const textBeforeCommand = input.slice(0, insertionStart);
|
||||
const textAfterCommandStart = input.slice(insertionStart);
|
||||
const spaceIndex = textAfterCommandStart.indexOf(' ');
|
||||
const textAfterCommand = slashPosition >= 0 && spaceIndex !== -1
|
||||
? textAfterCommandStart.slice(spaceIndex).trimStart()
|
||||
: input.slice(currentTextarea?.selectionEnd ?? insertionStart);
|
||||
const separator = textBeforeCommand && !/\s$/.test(textBeforeCommand) ? ' ' : '';
|
||||
const newInput = `${textBeforeCommand}${separator}${command.name}${textAfterCommand ? ` ${textAfterCommand}` : ' '}`;
|
||||
const textBeforeSlash = input.slice(0, slashPosition);
|
||||
const textAfterSlash = input.slice(slashPosition);
|
||||
const spaceIndex = textAfterSlash.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';
|
||||
const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;
|
||||
|
||||
setInput(newInput);
|
||||
resetCommandMenuState();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
currentTextarea?.focus();
|
||||
const nextCursorPosition = `${textBeforeCommand}${separator}${command.name} `.length;
|
||||
currentTextarea?.setSelectionRange(nextCursorPosition, nextCursorPosition);
|
||||
});
|
||||
},
|
||||
[input, resetCommandMenuState, setInput, slashPosition, textareaRef],
|
||||
);
|
||||
|
||||
const executeNonSkillCommand = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const executionResult = onExecuteCommand(command);
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.then(
|
||||
() => {
|
||||
resetCommandMenuState();
|
||||
},
|
||||
() => {
|
||||
resetCommandMenuState();
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
},
|
||||
);
|
||||
} else {
|
||||
resetCommandMenuState();
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
}
|
||||
},
|
||||
[onExecuteCommand, resetCommandMenuState],
|
||||
);
|
||||
|
||||
const selectCommandFromKeyboard = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
if (isSkillCommand(command)) {
|
||||
insertCommandIntoInput(command);
|
||||
return;
|
||||
}
|
||||
|
||||
executeNonSkillCommand(command);
|
||||
},
|
||||
[executeNonSkillCommand, insertCommandIntoInput],
|
||||
[input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
|
||||
);
|
||||
|
||||
const handleCommandSelect = useCallback(
|
||||
@@ -354,14 +231,20 @@ export function useSlashCommands({
|
||||
}
|
||||
|
||||
trackCommandUsage(command);
|
||||
if (isSkillCommand(command)) {
|
||||
insertCommandIntoInput(command);
|
||||
return;
|
||||
}
|
||||
const executionResult = onExecuteCommand(command);
|
||||
|
||||
executeNonSkillCommand(command);
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.then(() => {
|
||||
resetCommandMenuState();
|
||||
});
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
} else {
|
||||
resetCommandMenuState();
|
||||
}
|
||||
},
|
||||
[selectedProject, trackCommandUsage, insertCommandIntoInput, executeNonSkillCommand],
|
||||
[selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
|
||||
);
|
||||
|
||||
const handleToggleCommandMenu = useCallback(() => {
|
||||
@@ -393,7 +276,7 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPattern = /^\/(\S*)$/;
|
||||
const slashPattern = /(^|\s)\/(\S*)$/;
|
||||
const match = textBeforeCursor.match(slashPattern);
|
||||
|
||||
if (!match) {
|
||||
@@ -401,8 +284,8 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPos = 0;
|
||||
const query = match[1];
|
||||
const slashPos = (match.index || 0) + match[1].length;
|
||||
const query = match[2];
|
||||
|
||||
setSlashPosition(slashPos);
|
||||
setShowCommandMenu(true);
|
||||
|
||||
@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
||||
|
||||
if (!config.result) return false;
|
||||
|
||||
// Hidden/success-only configs suppress noisy successful output, but errors
|
||||
// still need to be visible so failed tool calls are diagnosable.
|
||||
if (toolResult?.isError) return false;
|
||||
|
||||
// Always hidden
|
||||
if (config.result.hidden) return true;
|
||||
|
||||
// Hide on success only
|
||||
if (config.result.hideOnSuccess && toolResult) {
|
||||
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ import { useSessionStore } from '../../../stores/useSessionStore';
|
||||
|
||||
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
||||
import ChatComposer from './subcomponents/ChatComposer';
|
||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
@@ -72,26 +72,19 @@ function ChatInterface({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
permissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsLoading,
|
||||
providerModelsRefreshing,
|
||||
hardRefreshProviderModels,
|
||||
selectProviderModel,
|
||||
} = useChatProviderState({
|
||||
selectedSession,
|
||||
selectedProject,
|
||||
});
|
||||
|
||||
const {
|
||||
chatMessages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
currentSessionId,
|
||||
@@ -141,6 +134,8 @@ function ChatInterface({
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -175,10 +170,7 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused: _isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
isInputFocused,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -190,7 +182,6 @@ function ChatInterface({
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
@@ -204,6 +195,8 @@ function ChatInterface({
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
@@ -241,7 +234,6 @@ function ChatInterface({
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
@@ -288,8 +280,6 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
@@ -328,10 +318,6 @@ function ChatInterface({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
@@ -368,8 +354,9 @@ function ChatInterface({
|
||||
provider={provider}
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={cyclePermissionMode}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
onShowTokenUsage={showCostModal}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={handleToggleCommandMenu}
|
||||
hasInput={Boolean(input.trim())}
|
||||
@@ -419,8 +406,6 @@ function ChatInterface({
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
@@ -429,17 +414,6 @@ function ChatInterface({
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
|
||||
<CommandResultModal
|
||||
payload={commandModalPayload}
|
||||
onClose={closeCommandModal}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={hardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId || selectedSession?.id || null}
|
||||
onSelectProviderModel={selectProviderModel}
|
||||
/>
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@ import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
Dispatch,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsagePie from './TokenUsagePie';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -23,12 +30,6 @@ import {
|
||||
PromptInputSubmit,
|
||||
} from '../../../../shared/view/ui';
|
||||
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -57,8 +58,9 @@ interface ChatComposerProps {
|
||||
provider: Provider | string;
|
||||
permissionMode: PermissionMode | string;
|
||||
onModeSwitch: () => void;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
onShowTokenUsage: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -111,8 +113,9 @@ export default function ChatComposer({
|
||||
provider,
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
onShowTokenUsage,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
@@ -292,7 +295,6 @@ export default function ChatComposer({
|
||||
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onClick={onTextareaClick}
|
||||
@@ -355,7 +357,11 @@ export default function ChatComposer({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
||||
{provider === 'claude' && (
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
||||
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.showAllCommands') }}
|
||||
@@ -376,7 +382,7 @@ export default function ChatComposer({
|
||||
<PromptInputButton
|
||||
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
||||
onClick={onClearInput}
|
||||
className="hidden sm:flex"
|
||||
className="hidden sm:No-flex"
|
||||
>
|
||||
<XIcon />
|
||||
</PromptInputButton>
|
||||
@@ -393,9 +399,16 @@ export default function ChatComposer({
|
||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||
</div>
|
||||
<PromptInputSubmit
|
||||
onClick={isLoading ? onAbortSession : undefined}
|
||||
disabled={!isLoading && !input.trim()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../../types/app';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
|
||||
@@ -33,10 +26,6 @@ interface ChatMessagesPaneProps {
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
opencodeModel: string;
|
||||
setOpenCodeModel: (model: string) => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelsLoading: boolean;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -82,10 +71,6 @@ export default function ChatMessagesPane({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -169,10 +154,6 @@ export default function ChatMessagesPane({
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
opencodeModel={opencodeModel}
|
||||
setOpenCodeModel={setOpenCodeModel}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelsLoading={providerModelsLoading}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
|
||||
@@ -29,7 +29,6 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
opencode: 'messageTypes.opencode',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
@@ -127,4 +126,4 @@ export default function ClaudeStatus({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Folder,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Star,
|
||||
Terminal,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type CommandMenuCommand = {
|
||||
name: string;
|
||||
@@ -31,92 +21,59 @@ type CommandMenuProps = {
|
||||
frequentCommands?: CommandMenuCommand[];
|
||||
};
|
||||
|
||||
type CommandMenuRow = {
|
||||
command: CommandMenuCommand;
|
||||
commandIndex: number;
|
||||
renderKey: string;
|
||||
};
|
||||
|
||||
const menuBaseStyle: CSSProperties = {
|
||||
maxHeight: '360px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 24px 60px rgba(2, 6, 23, 0.38), 0 0 0 1px rgba(148, 163, 184, 0.12)',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '6px',
|
||||
padding: '8px',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
backdropFilter: 'blur(12px)',
|
||||
};
|
||||
|
||||
const namespaceLabels: Record<string, string> = {
|
||||
frequent: 'Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
skill: 'Skills',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
const namespaceIcons: Record<string, LucideIcon> = {
|
||||
frequent: Star,
|
||||
builtin: Terminal,
|
||||
skill: Sparkles,
|
||||
project: Folder,
|
||||
user: User,
|
||||
other: MessageSquare,
|
||||
const namespaceIcons: Record<string, string> = {
|
||||
frequent: '[*]',
|
||||
builtin: '[B]',
|
||||
project: '[P]',
|
||||
user: '[U]',
|
||||
other: '[O]',
|
||||
};
|
||||
|
||||
const namespaceAccentClasses: Record<string, string> = {
|
||||
frequent: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200',
|
||||
builtin: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200',
|
||||
skill: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200',
|
||||
project: 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-400/10 dark:text-indigo-200',
|
||||
user: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200',
|
||||
other: 'border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-500/20 dark:bg-gray-500/10 dark:text-gray-200',
|
||||
};
|
||||
|
||||
const MENU_EDGE_GAP = 16;
|
||||
const MENU_MAX_HEIGHT = 360;
|
||||
|
||||
const getCommandKey = (command: CommandMenuCommand) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
|
||||
const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
|
||||
|
||||
const getNamespaceIcon = (namespace: string) => namespaceIcons[namespace] || namespaceIcons.other;
|
||||
|
||||
const getNamespaceAccentClass = (namespace: string) =>
|
||||
namespaceAccentClasses[namespace] || namespaceAccentClasses.other;
|
||||
|
||||
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { position: 'fixed', top: '16px', left: '16px' };
|
||||
}
|
||||
if (window.innerWidth < 640) {
|
||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${anchorBottom}px`,
|
||||
bottom: `${position.bottom ?? 90}px`,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||
maxHeight: 'min(50vh, 300px)',
|
||||
};
|
||||
}
|
||||
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
|
||||
const clampedLeft = Math.max(
|
||||
MENU_EDGE_GAP,
|
||||
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
|
||||
);
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${anchorBottom}px`,
|
||||
left: `${clampedLeft}px`,
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: `min(${MENU_MAX_HEIGHT}px, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
|
||||
maxHeight: '300px',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -166,24 +123,7 @@ export default function CommandMenu({
|
||||
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
const commandIndexesByKey = new Map<string, number[]>();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
const commandIndexes = commandIndexesByKey.get(key) ?? [];
|
||||
commandIndexes.push(index);
|
||||
commandIndexesByKey.set(key, commandIndexes);
|
||||
});
|
||||
const frequentCommandOccurrences = new Map<string, number>();
|
||||
const getFrequentCommandIndex = (command: CommandMenuCommand): number => {
|
||||
const key = getCommandKey(command);
|
||||
const occurrence = frequentCommandOccurrences.get(key) ?? 0;
|
||||
frequentCommandOccurrences.set(key, occurrence + 1);
|
||||
|
||||
const commandIndexes = commandIndexesByKey.get(key) ?? [];
|
||||
return commandIndexes[occurrence] ?? commandIndexes[0] ?? -1;
|
||||
};
|
||||
|
||||
const groupedCommands = commands.reduce<Record<string, CommandMenuRow[]>>((groups, command, index) => {
|
||||
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
@@ -191,46 +131,33 @@ export default function CommandMenu({
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push({
|
||||
command,
|
||||
commandIndex: index,
|
||||
renderKey: `${namespace}-${index}-${getCommandKey(command)}`,
|
||||
});
|
||||
groups[namespace].push(command);
|
||||
return groups;
|
||||
}, {});
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands.frequent = frequentCommands
|
||||
.map((command, index) => {
|
||||
const commandIndex = getFrequentCommandIndex(command);
|
||||
return {
|
||||
command,
|
||||
commandIndex,
|
||||
renderKey: `frequent-${index}-${commandIndex}-${getCommandKey(command)}`,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.commandIndex >= 0);
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
}
|
||||
|
||||
const preferredOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'skill', 'project', 'user', 'other']
|
||||
: ['builtin', 'skill', 'project', 'user', 'other'];
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||
|
||||
const commandIndexByKey = new Map<string, number>();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400"
|
||||
style={{
|
||||
...menuBaseStyle,
|
||||
...menuPosition,
|
||||
overflowY: 'hidden',
|
||||
padding: '20px',
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
@@ -242,73 +169,51 @@ export default function CommandMenu({
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100"
|
||||
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
|
||||
className="command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">
|
||||
<span>{namespaceLabels[namespace] || namespace}</span>
|
||||
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
{(groupedCommands[namespace] || []).length}
|
||||
</span>
|
||||
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(groupedCommands[namespace] || []).map(({ command, commandIndex, renderKey }) => {
|
||||
{(groupedCommands[namespace] || []).map((command) => {
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
const NamespaceIcon = getNamespaceIcon(namespace);
|
||||
const accentClass = getNamespaceAccentClass(namespace);
|
||||
return (
|
||||
<div
|
||||
key={renderKey}
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
|
||||
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
|
||||
className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
|
||||
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
|
||||
}`}
|
||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" />
|
||||
)}
|
||||
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
|
||||
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pr-1">
|
||||
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50"
|
||||
title={command.name}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
|
||||
{command.metadata?.type && (
|
||||
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<div
|
||||
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400"
|
||||
title={command.description}
|
||||
>
|
||||
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200">
|
||||
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
)}
|
||||
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CircleHelp,
|
||||
Clipboard,
|
||||
Coins,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Package,
|
||||
Search,
|
||||
Server,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
Timer,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
||||
import type { LLMProvider, ProviderModelsCacheInfo, ProviderModelsDefinition } from '../../../../types/app';
|
||||
import type {
|
||||
CommandModalPayload,
|
||||
CostCommandData,
|
||||
HelpCommandData,
|
||||
ModelCommandData,
|
||||
StatusCommandData,
|
||||
} from '../../hooks/useChatComposerState';
|
||||
|
||||
type CommandResultModalProps = {
|
||||
payload: CommandModalPayload | null;
|
||||
onClose: () => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: (
|
||||
provider: LLMProvider,
|
||||
model: string,
|
||||
sessionId?: string | null,
|
||||
) => Promise<{
|
||||
scope: 'default' | 'session';
|
||||
changed: boolean;
|
||||
model: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CommandEntry = {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
type ModelOption = {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const formatUpdatedAt = (value?: string) => {
|
||||
if (!value) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Not cached yet';
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||
{ name: '/models', description: 'Browse available models for the active provider.' },
|
||||
{ name: '/cost', description: 'Review token usage for the active session.' },
|
||||
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
||||
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
||||
{ name: '/config', description: 'Open settings and configuration.' },
|
||||
{ name: '/help', description: 'Show command documentation and syntax.' },
|
||||
];
|
||||
|
||||
const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') => {
|
||||
if (!provider) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return PROVIDER_LABELS[provider] || provider;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0';
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: typeof Activity;
|
||||
tone?: 'neutral' | 'primary' | 'success';
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'primary'
|
||||
? 'border-primary/35 bg-primary/10 text-primary'
|
||||
: tone === 'success'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
: 'border-border/70 bg-background/75 text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group rounded-2xl border border-border/70 bg-background/75 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`inline-flex rounded-xl border ${compact ? 'mb-2 p-1.5' : 'mb-3 p-2'} ${toneClass}`}>
|
||||
<Icon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
|
||||
</div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
|
||||
<p className={`${compact ? 'mt-0.5 text-[13px]' : 'mt-1 text-sm'} break-all font-semibold text-foreground`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-10 rounded-xl border-border/70 bg-background/75 pl-9 pr-3 shadow-none focus-visible:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpContent({ data }: { data: HelpCommandData }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const commands = (Array.isArray(data.commands) && data.commands.length > 0
|
||||
? data.commands
|
||||
: FALLBACK_COMMANDS) as CommandEntry[];
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return commands;
|
||||
}
|
||||
|
||||
return commands.filter((command) => {
|
||||
const haystack = `${command.name} ${command.description || ''} ${command.namespace || ''}`.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
}, [commands, query]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<SearchField value={query} onChange={setQuery} placeholder="Filter commands..." />
|
||||
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={`${command.namespace || 'builtin'}-${command.name}`}
|
||||
className="settings-content-enter rounded-2xl border border-border/70 bg-background/75 p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/30 hover:bg-muted/25"
|
||||
style={{ animationDelay: `${Math.min(index * 18, 160)}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<code className="rounded-lg border border-primary/20 bg-primary/10 px-2 py-1 text-xs font-semibold text-primary">
|
||||
{command.name}
|
||||
</code>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px] capitalize">
|
||||
{command.namespace || 'builtin'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-5 text-muted-foreground">
|
||||
{command.description || 'No description available.'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No commands match that filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<TerminalSquare className="h-4 w-4 text-primary" />
|
||||
Syntax
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p><code className="text-foreground">/command arg1 arg2</code></p>
|
||||
<p><code className="text-foreground">$ARGUMENTS</code> passes all args.</p>
|
||||
<p><code className="text-foreground">$1</code>, <code className="text-foreground">$2</code> pass positional args.</p>
|
||||
<p><code className="text-foreground">@file</code> includes file contents.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-primary/25 bg-primary/10 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Quick tip
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-muted-foreground">
|
||||
Type <code className="text-foreground">/</code> in the composer to open the command palette, then use arrows and Enter to run a command.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsContent({
|
||||
data,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
onSelectProviderModel,
|
||||
}: {
|
||||
data: ModelCommandData;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||
providerModelsRefreshing: boolean;
|
||||
onHardRefreshProviderModels: () => void;
|
||||
currentSessionId: string | null;
|
||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||
const currentProvider = (data?.current?.provider || 'claude') as LLMProvider;
|
||||
const currentModel = data?.current?.model || 'Unknown';
|
||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||
const liveDefinition = providerModelCatalog[currentProvider];
|
||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||
return liveDefinition.OPTIONS;
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.availableOptions) && data.availableOptions.length > 0) {
|
||||
return data.availableOptions;
|
||||
}
|
||||
|
||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||
return availableModels.map((model) => ({ value: model, label: model }));
|
||||
}, [data, liveDefinition]);
|
||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return availableOptions;
|
||||
}
|
||||
|
||||
return availableOptions.filter((option) => {
|
||||
const haystack = `${option.value} ${option.label || ''} ${option.description || ''}`.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
}, [availableOptions, query]);
|
||||
|
||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||
|
||||
const copyModel = (model: string) => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
||||
}
|
||||
setCopiedModel(model);
|
||||
window.setTimeout(() => {
|
||||
setCopiedModel((current) => (current === model ? null : current));
|
||||
}, 1300);
|
||||
};
|
||||
|
||||
const handleSelectModel = async (model: string) => {
|
||||
setChangingModel(model);
|
||||
try {
|
||||
const result = await onSelectProviderModel(currentProvider, model, currentSessionId);
|
||||
if (result.scope === 'session') {
|
||||
setPendingSessionModel(result.model);
|
||||
setSelectionNotice(`Next response will resume with ${result.model}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSessionModel(null);
|
||||
setSelectionNotice(`Default ${providerLabel} model set to ${result.model}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to change the model right now.';
|
||||
setSelectionNotice(message);
|
||||
} finally {
|
||||
setChangingModel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{providerLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||
{availableOptions.length} models
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
||||
{currentModel}
|
||||
</p>
|
||||
{activeOption?.label && activeOption.label !== currentModel && (
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
||||
)}
|
||||
{activeOption?.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
||||
)}
|
||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Next response: {pendingSessionModel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||
Catalog Refresh
|
||||
</p>
|
||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||
All providers
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||
or when a new model is missing.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onHardRefreshProviderModels}
|
||||
disabled={providerModelsRefreshing}
|
||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||
>
|
||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||
{hasConcreteSessionId
|
||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
||||
{filteredOptions.length} shown
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{filteredOptions.length > 0 ? (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isCurrent = option.value === currentModel;
|
||||
const wasCopied = copiedModel === option.value;
|
||||
const isPendingSelection = option.value === pendingSessionModel;
|
||||
const isChanging = option.value === changingModel;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
||||
isCurrent
|
||||
? 'border-primary/45 bg-primary/10'
|
||||
: isPendingSelection
|
||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectModel(option.value)}
|
||||
disabled={Boolean(changingModel)}
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Use model ${option.value}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</span>
|
||||
{option.label && option.label !== option.value && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||
)}
|
||||
{option.description && (
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
||||
{isPendingSelection && !isCurrent && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||
Next response selection
|
||||
</span>
|
||||
)}
|
||||
{isChanging && (
|
||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Applying...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyModel(option.value)}
|
||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
||||
aria-label={`Copy model id ${option.value}`}
|
||||
>
|
||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No models match that search.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostContent({ data }: { data: CostCommandData }) {
|
||||
const used = Number(data.tokenUsage?.used ?? 0);
|
||||
const total = Number(data.tokenUsage?.total ?? 0);
|
||||
const model = data.model || 'Unknown';
|
||||
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
||||
const hasBreakdown =
|
||||
typeof data.tokenBreakdown?.input === 'number' ||
|
||||
typeof data.tokenBreakdown?.output === 'number';
|
||||
const usageRows = [
|
||||
{ label: 'Total tokens used', value: formatNumber(used), icon: Activity },
|
||||
...(hasBreakdown
|
||||
? [
|
||||
{
|
||||
label: 'Input tokens',
|
||||
value: formatNumber(Number(data.tokenBreakdown?.input ?? 0)),
|
||||
icon: TerminalSquare,
|
||||
},
|
||||
{
|
||||
label: 'Output tokens',
|
||||
value: formatNumber(Number(data.tokenBreakdown?.output ?? 0)),
|
||||
icon: Coins,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Breakdown',
|
||||
value: 'Unavailable',
|
||||
icon: TerminalSquare,
|
||||
},
|
||||
]),
|
||||
...(total > 0
|
||||
? [{ label: 'Context window', value: formatNumber(total), icon: Gauge }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-background/75">
|
||||
{usageRows.map((row) => {
|
||||
const Icon = row.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground">{row.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-sm font-semibold text-foreground">{row.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusContent({ data }: { data: StatusCommandData }) {
|
||||
const memoryRssMb = data.memoryUsage?.rssMb;
|
||||
const rows = [
|
||||
{ label: 'Package', value: data.packageName || 'claude-code-ui', icon: Package },
|
||||
{ label: 'Version', value: data.version || 'Unknown', icon: BadgeCheck, tone: 'success' as const },
|
||||
{ label: 'Uptime', value: data.uptime || 'Unknown', icon: Timer },
|
||||
{ label: 'Provider', value: getProviderLabel(data.provider, data.provider || 'Unknown'), icon: Server, tone: 'primary' as const },
|
||||
{ label: 'Model', value: data.model || 'Unknown', icon: Cpu },
|
||||
{ label: 'Node.js', value: data.nodeVersion || 'Unknown', icon: TerminalSquare },
|
||||
{ label: 'Platform', value: data.platform || 'Unknown', icon: Activity },
|
||||
{ label: 'Memory', value: typeof memoryRssMb === 'number' ? `${memoryRssMb} MB RSS` : 'Unknown', icon: Gauge },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-3xl border border-emerald-500/25 bg-emerald-500/10 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Runtime online</p>
|
||||
<p className="text-xs text-muted-foreground">Process {data.pid ? `#${data.pid}` : 'status'} is responding.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="rounded-full bg-emerald-500 text-white hover:bg-emerald-500">Healthy</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{rows.map((row) => (
|
||||
<MetricCard key={row.label} label={row.label} value={String(row.value)} icon={row.icon} tone={row.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommandResultModal({
|
||||
payload,
|
||||
onClose,
|
||||
providerModelCatalog,
|
||||
providerModelCacheCatalog,
|
||||
providerModelsRefreshing,
|
||||
onHardRefreshProviderModels,
|
||||
currentSessionId,
|
||||
onSelectProviderModel,
|
||||
}: CommandResultModalProps) {
|
||||
const isOpen = Boolean(payload);
|
||||
const kind = payload?.kind;
|
||||
const isModelsModal = kind === 'models';
|
||||
|
||||
const modalMeta = {
|
||||
help: {
|
||||
eyebrow: 'Command center',
|
||||
title: 'Help & Shortcuts',
|
||||
subtitle: 'Search built-ins, syntax patterns, and command usage without leaving the chat.',
|
||||
icon: CircleHelp,
|
||||
},
|
||||
models: {
|
||||
eyebrow: 'Model inventory',
|
||||
title: 'Available Models',
|
||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||
icon: Cpu,
|
||||
},
|
||||
cost: {
|
||||
eyebrow: 'Session telemetry',
|
||||
title: 'Token Usage',
|
||||
subtitle: 'Input, output, and total token counts for this session.',
|
||||
icon: Coins,
|
||||
},
|
||||
status: {
|
||||
eyebrow: 'Runtime health',
|
||||
title: 'System Status',
|
||||
subtitle: 'Version, provider, runtime, and environment details in one place.',
|
||||
icon: Activity,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const activeMeta = kind ? modalMeta[kind] : null;
|
||||
const HeaderIcon = activeMeta?.icon || Sparkles;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="flex h-[min(92dvh,48rem)] w-[calc(100vw-1rem)] max-w-5xl flex-col overflow-hidden rounded-3xl border-border/80 bg-popover/95 p-0 shadow-2xl backdrop-blur-xl sm:w-[min(94vw,64rem)]">
|
||||
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
|
||||
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
|
||||
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
|
||||
}`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
|
||||
|
||||
<div className="relative flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3 sm:items-center">
|
||||
<div
|
||||
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
|
||||
isModelsModal ? 'p-2.5' : 'p-3'
|
||||
}`}
|
||||
>
|
||||
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
|
||||
{activeMeta?.eyebrow}
|
||||
</p>
|
||||
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
|
||||
{activeMeta?.title}
|
||||
</p>
|
||||
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
|
||||
{activeMeta?.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
|
||||
aria-label="Close command result modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
|
||||
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}
|
||||
{payload?.kind === 'models' && (
|
||||
<ModelsContent
|
||||
data={payload.data as ModelCommandData}
|
||||
providerModelCatalog={providerModelCatalog}
|
||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||
providerModelsRefreshing={providerModelsRefreshing}
|
||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectProviderModel={onSelectProviderModel}
|
||||
/>
|
||||
)}
|
||||
{payload?.kind === 'cost' && <CostContent data={payload.data as CostCommandData} />}
|
||||
{payload?.kind === 'status' && <StatusContent data={payload.data as StatusCommandData} />}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t border-border/70 bg-muted/20 px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-3.5 w-3.5" />
|
||||
<span>Esc closes the modal.</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose} className="rounded-xl">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -9,10 +8,10 @@ import type {
|
||||
Provider,
|
||||
} from '../../types/types';
|
||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||
|
||||
import { Markdown } from './Markdown';
|
||||
import MessageCopyControl from './MessageCopyControl';
|
||||
|
||||
@@ -42,9 +41,10 @@ type InteractiveOption = {
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||
const userCopyContent = String(message.content || '');
|
||||
const formattedMessageContent = useMemo(
|
||||
() => formatUsageLimitText(String(message.content || '')),
|
||||
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
!message.isThinking;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = messageRef.current;
|
||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||
@@ -114,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
/* User message bubble on the right */
|
||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
||||
<div className="whitespace-pre-wrap break-words text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
@@ -170,19 +176,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{message.type === 'error'
|
||||
? t('messageTypes.error')
|
||||
: message.type === 'tool'
|
||||
? t('messageTypes.tool')
|
||||
: (provider === 'cursor'
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: provider === 'opencode'
|
||||
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
|
||||
: t('messageTypes.claude'))}
|
||||
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,6 +229,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||
{String(message.toolResult.content || '')}
|
||||
</Markdown>
|
||||
{permissionSuggestion && (
|
||||
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!onGrantToolPermission) return;
|
||||
const result = onGrantToolPermission(permissionSuggestion);
|
||||
if (result?.success) {
|
||||
setPermissionGrantState('granted');
|
||||
} else {
|
||||
setPermissionGrantState('error');
|
||||
}
|
||||
}}
|
||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
>
|
||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? t('permissions.added')
|
||||
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||
</button>
|
||||
{onShowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||
>
|
||||
{t('permissions.openSettings')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||
</div>
|
||||
{permissionGrantState === 'error' && (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
{t('permissions.error')}
|
||||
</div>
|
||||
)}
|
||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||
{t('permissions.retry')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -350,7 +393,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
) : (
|
||||
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Reasoning accordion */}
|
||||
{showThinking && message.reasoning && (
|
||||
<Reasoning className="mb-3" defaultOpen={false}>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import type {
|
||||
ProjectSession,
|
||||
LLMProvider,
|
||||
ProviderModelsDefinition,
|
||||
} from "../../../../types/app";
|
||||
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
CODEX_MODELS,
|
||||
GEMINI_MODELS,
|
||||
PROVIDERS,
|
||||
} from "../../../../../shared/modelConstants";
|
||||
import type { ProjectSession, LLMProvider } from "../../../../types/app";
|
||||
import { NextTaskBanner } from "../../../task-master";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,14 +27,6 @@ import {
|
||||
Card,
|
||||
} from "../../../../shared/view/ui";
|
||||
|
||||
const PROVIDER_META: { id: LLMProvider; name: string }[] = [
|
||||
{ id: "claude", name: "Anthropic" },
|
||||
{ id: "codex", name: "OpenAI" },
|
||||
{ id: "gemini", name: "Google" },
|
||||
{ id: "cursor", name: "Cursor" },
|
||||
{ id: "opencode", name: "OpenCode" },
|
||||
];
|
||||
|
||||
const MOD_KEY =
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
|
||||
|
||||
@@ -48,10 +44,6 @@ type ProviderSelectionEmptyStateProps = {
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
opencodeModel: string;
|
||||
setOpenCodeModel: (model: string) => void;
|
||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||
providerModelsLoading: boolean;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -61,15 +53,20 @@ type ProviderSelectionEmptyStateProps = {
|
||||
type ProviderGroup = {
|
||||
id: LLMProvider;
|
||||
name: string;
|
||||
models: { value: string; label: string; description?: string }[];
|
||||
models: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
function getModelConfig(
|
||||
p: LLMProvider,
|
||||
catalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>,
|
||||
): ProviderModelsDefinition {
|
||||
const entry = catalog[p];
|
||||
return entry ?? { OPTIONS: [], DEFAULT: "" };
|
||||
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
|
||||
id: p.id as LLMProvider,
|
||||
name: p.name,
|
||||
models: p.models.OPTIONS,
|
||||
}));
|
||||
|
||||
function getModelConfig(p: LLMProvider) {
|
||||
if (p === "claude") return CLAUDE_MODELS;
|
||||
if (p === "codex") return CODEX_MODELS;
|
||||
if (p === "gemini") return GEMINI_MODELS;
|
||||
return CURSOR_MODELS;
|
||||
}
|
||||
|
||||
function getCurrentModel(
|
||||
@@ -78,12 +75,10 @@ function getCurrentModel(
|
||||
cu: string,
|
||||
co: string,
|
||||
g: string,
|
||||
o: string,
|
||||
) {
|
||||
if (p === "claude") return c;
|
||||
if (p === "codex") return co;
|
||||
if (p === "gemini") return g;
|
||||
if (p === "opencode") return o;
|
||||
return cu;
|
||||
}
|
||||
|
||||
@@ -91,7 +86,6 @@ function getProviderDisplayName(p: LLMProvider) {
|
||||
if (p === "claude") return "Claude";
|
||||
if (p === "cursor") return "Cursor";
|
||||
if (p === "codex") return "Codex";
|
||||
if (p === "opencode") return "OpenCode";
|
||||
return "Gemini";
|
||||
}
|
||||
|
||||
@@ -109,25 +103,26 @@ export default function ProviderSelectionEmptyState({
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
opencodeModel,
|
||||
setOpenCodeModel,
|
||||
providerModelCatalog,
|
||||
providerModelsLoading,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const visibleProviderGroups = useMemo<ProviderGroup[]>(() => {
|
||||
return PROVIDER_META.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
models: providerModelCatalog[p.id]?.OPTIONS ?? [],
|
||||
}));
|
||||
}, [providerModelCatalog]);
|
||||
const visibleProviderGroups = useMemo(
|
||||
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
|
||||
[isWindowsServer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && provider === "cursor") {
|
||||
setProvider("claude");
|
||||
localStorage.setItem("selected-provider", "claude");
|
||||
}
|
||||
}, [isWindowsServer, provider, setProvider]);
|
||||
|
||||
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||
defaultValue: "Start the next task",
|
||||
@@ -139,16 +134,15 @@ export default function ProviderSelectionEmptyState({
|
||||
cursorModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
);
|
||||
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const config = getModelConfig(provider, providerModelCatalog);
|
||||
const config = getModelConfig(provider);
|
||||
const found = config.OPTIONS.find(
|
||||
(o: { value: string; label: string }) => o.value === currentModel,
|
||||
);
|
||||
return found?.label || currentModel;
|
||||
}, [provider, currentModel, providerModelCatalog]);
|
||||
}, [provider, currentModel]);
|
||||
|
||||
const setModelForProvider = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
@@ -161,15 +155,12 @@ export default function ProviderSelectionEmptyState({
|
||||
} else if (providerId === "gemini") {
|
||||
setGeminiModel(modelValue);
|
||||
localStorage.setItem("gemini-model", modelValue);
|
||||
} else if (providerId === "opencode") {
|
||||
setOpenCodeModel(modelValue);
|
||||
localStorage.setItem("opencode-model", modelValue);
|
||||
} else {
|
||||
setCursorModel(modelValue);
|
||||
localStorage.setItem("cursor-model", modelValue);
|
||||
}
|
||||
},
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -231,9 +222,6 @@ export default function ProviderSelectionEmptyState({
|
||||
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<DialogTitle>Model Selector</DialogTitle>
|
||||
<div className="border-b border-border/60 bg-muted/20 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Choose a model</p>
|
||||
</div>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("providerSelection.searchModels", {
|
||||
@@ -261,32 +249,16 @@ export default function ProviderSelectionEmptyState({
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{group.models.length === 0 && providerModelsLoading ? (
|
||||
<CommandItem disabled className="ml-4 border-l border-border/40 pl-4 text-muted-foreground">
|
||||
{t("providerSelection.loadingModels", { defaultValue: "Loading models…" })}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{group.models.map((model) => {
|
||||
const isSelected = provider === group.id && currentModel === model.value;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${group.id}-${model.value}`}
|
||||
value={`${group.name} ${model.label} ${model.description || ''}`}
|
||||
value={`${group.name} ${model.label}`}
|
||||
onSelect={() => handleModelSelect(group.id, model.value)}
|
||||
className="ml-4 border-l border-border/40 pl-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{model.label}</div>
|
||||
{/*
|
||||
// * Temporarly commented out because the description of models from claude
|
||||
// * was a bit inconsistent. Will return it back when it becomes more consistent.
|
||||
*/}
|
||||
{/* {model.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
<span className="flex-1 truncate">{model.label}</span>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
@@ -315,17 +287,12 @@ export default function ProviderSelectionEmptyState({
|
||||
gemini: t("providerSelection.readyPrompt.gemini", {
|
||||
model: geminiModel,
|
||||
}),
|
||||
opencode: t("providerSelection.readyPrompt.opencode", {
|
||||
model: opencodeModel,
|
||||
defaultValue: "Ready with OpenCode {{model}}",
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
|
||||
<Trans
|
||||
ns="chat"
|
||||
i18nKey="providerSelection.pressToSearch"
|
||||
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
|
||||
components={{
|
||||
|
||||
244
src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
Normal file
244
src/components/chat/view/subcomponents/ThinkingModeSelector.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Brain, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { thinkingModes } from '../../constants/thinkingModes';
|
||||
|
||||
type ThinkingModeSelectorProps = {
|
||||
selectedMode: string;
|
||||
onModeChange: (modeId: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap: Record<string, string> = {
|
||||
'think-hard': 'thinkHard',
|
||||
'think-harder': 'thinkHarder'
|
||||
};
|
||||
// Create translated modes for display
|
||||
const translatedModes = thinkingModes.map(mode => {
|
||||
const modeKey = modeKeyMap[mode.id] || mode.id;
|
||||
return {
|
||||
...mode,
|
||||
name: t(`thinkingMode.modes.${modeKey}.name`),
|
||||
description: t(`thinkingMode.modes.${modeKey}.description`),
|
||||
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
|
||||
};
|
||||
});
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||
const spacing = 8;
|
||||
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||
|
||||
const measuredHeight = dropdown.offsetHeight || 0;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||
const availableHeight = Math.min(
|
||||
window.innerHeight - viewportPadding * 2,
|
||||
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||
);
|
||||
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||
const top = openBelow
|
||||
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: availableHeight,
|
||||
zIndex: 80,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDropdownStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||
const handleViewportChange = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, closeDropdown]);
|
||||
|
||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||
const IconComponent = currentMode.icon || Brain;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||
}`}
|
||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('thinkingMode.selector.title')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDropdown}
|
||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('thinkingMode.selector.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto py-1">
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(mode.id);
|
||||
closeDropdown();
|
||||
}}
|
||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
|
||||
{ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{mode.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{t('thinkingMode.selector.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{mode.description}
|
||||
</p>
|
||||
{mode.prefix && (
|
||||
<code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
|
||||
{mode.prefix}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
54
src/components/chat/view/subcomponents/TokenUsagePie.tsx
Normal file
54
src/components/chat/view/subcomponents/TokenUsagePie.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
type TokenUsagePieProps = {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
|
||||
// Token usage visualization component
|
||||
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
|
||||
if (used == null || total == null || total <= 0) return null;
|
||||
|
||||
const percentage = Math.min(100, (used / total) * 100);
|
||||
const radius = 10;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
// Color based on usage level
|
||||
const getColor = () => {
|
||||
if (percentage < 50) return '#3b82f6'; // blue
|
||||
if (percentage < 75) return '#f59e0b'; // orange
|
||||
return '#ef4444'; // red
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={getColor()}
|
||||
strokeWidth="2"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ActivityIcon } from 'lucide-react';
|
||||
|
||||
type TokenUsageSummaryProps = {
|
||||
usage: Record<string, unknown> | null;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const formatTokenCount = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
||||
}
|
||||
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`;
|
||||
}
|
||||
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const readUsageNumber = (value: unknown) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
|
||||
const breakdown =
|
||||
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||
? usage.breakdown as Record<string, unknown>
|
||||
: null;
|
||||
const inputTokens = readUsageNumber(usage?.inputTokens ?? breakdown?.input);
|
||||
const outputTokens = readUsageNumber(usage?.outputTokens ?? breakdown?.output);
|
||||
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||
aria-label="Show token usage"
|
||||
>
|
||||
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ interface SessionsResponse {
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
}
|
||||
|
||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||
@@ -34,7 +33,6 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
...(data.opencodeSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user