Compare commits

..

15 Commits

Author SHA1 Message Date
Haileyesus
979877e1aa fix: simplify empty-state model selector
Keep the provider empty state focused on the setup action users need there:

choosing a model.

The refresh control, cache timestamp, and refresh explanation made the dialog feel

like a cache-management surface.

That extra action is out of place in the empty state, where the goal is to start

a chat with the selected provider and model.

Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the

now-unused refresh/cache props from the ChatMessagesPane pass-through.

Refresh behavior remains available in the dedicated command result flow.
2026-05-28 11:45:21 +03:00
Haileyesus
59a9858092 docs: move model catalog out of shared
The model catalog is no longer a frontend/backend runtime contract.

Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.

Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.

Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.

Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.
2026-05-25 12:32:25 +03:00
Haileyesus
e956d006f9 feat: add more fallback models for cursor 2026-05-25 11:01:39 +03:00
Haileyesus
fe3a8580dc fix: format opencode model catalog labels
OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.

Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.

This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.
2026-05-22 17:02:03 +03:00
Haileyesus
6ca0d38fa4 fix: clarify model refresh and onboarding providers
OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.

The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.

Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.
2026-05-22 16:49:40 +03:00
Haileyesus
117f7f662d fix: preserve opencode session creation events
OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.

Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.

Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.
2026-05-19 11:09:01 +03:00
Haileyesus
fb4c2d3d43 feat: make command modal more compact 2026-05-18 20:50:56 +03:00
Haileyesus
9aa927002e feat: support session-scoped model overrides
Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.
2026-05-18 16:57:29 +03:00
Haileyesus
bc5e768579 feat(models): resolve active session models through provider adapters
The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved
2026-05-18 14:54:32 +03:00
Haileyesus
556cbd1a03 feat: load models through provider adapters
Provider model selection had outgrown a single hardcoded service.

The old service mixed shared caching with provider catalogs and CLI lookup details.

That made stale model lists more likely as providers changed on separate schedules.

Move model discovery behind each provider so lookup lives next to the integration.

The shared service now focuses on provider resolution, caching, persistence, and dedupe.

Return cache metadata and add bypassCache because model availability changes outside the app.

The UI and /models command can show freshness and let users force a provider refresh.

Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.
2026-05-18 12:40:24 +03:00
Haileyesus
ffaef395e4 fix: format commands.js 2026-05-14 16:57:46 +03:00
Haileyesus
f36c5b6009 fix: improveUI for commands 2026-05-14 13:54:30 +03:00
Haileyesus
dafd28ba76 fix: /models 2026-05-14 13:22:34 +03:00
Haileyesus
57aece12e6 fix: stabilize opencode session startup 2026-05-13 18:44:07 +03:00
Haileyesus
421bdd2f0f feat: add opencode support 2026-05-13 17:43:10 +03:00
211 changed files with 4779 additions and 17003 deletions

1
.gitignore vendored
View File

@@ -134,7 +134,6 @@ tasks/
# Translations
!src/i18n/locales/en/tasks.json
!src/i18n/locales/fr/tasks.json
!src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json
!src/i18n/locales/de/tasks.json

View File

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

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
- **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 zur Laufzeit über `GET /api/providers/:provider/models`)
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
## Schnellstart
@@ -164,14 +164,6 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
| Plugin | Beschreibung |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
### Eigenes Plugin erstellen

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -158,14 +158,6 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
| プラグイン | 説明 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
### 自作する

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
## 빠른 시작
@@ -158,14 +158,6 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
| 플러그인 | 설명 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
### 직접 만들기

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **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 (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
## Quick Start
@@ -163,15 +163,8 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
| Plugin | Description |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
### Build Your Own
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
## Быстрый старт
@@ -164,14 +164,6 @@ CloudCLI UI — это open source UI-слой, на котором постро
| Плагин | Описание |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
### Создать свой

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
---
@@ -62,7 +62,7 @@
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
- **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 `GET /api/providers/:provider/models` API'sine bak)
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
## Hızlı Başlangıç
@@ -164,13 +164,6 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
### Kendi Eklentini Yaz

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js)
## 快速开始
@@ -158,14 +158,6 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
| 插件 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
### 自行构建

View File

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

View File

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

3253
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.34.0",
"productName": "CloudCLI",
"version": "1.32.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -9,10 +8,10 @@
"cloudcli": "dist-server/server/cli.js"
},
"files": [
"electron/",
"server/",
"shared/",
"public/api-docs.html",
"public/modelConstants.js",
"dist/",
"dist-server/",
"scripts/",
@@ -32,10 +31,6 @@
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite",
"desktop": "electron electron/main.js",
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
"desktop:pack": "npm run build && electron-builder --dir",
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
@@ -51,53 +46,6 @@
"prepare": "husky",
"update:platform": "./update-platform.sh"
},
"build": {
"appId": "ai.cloudcli.desktop",
"productName": "CloudCLI",
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
"directories": {
"output": "release"
},
"extraMetadata": {
"main": "electron/main.js"
},
"files": [
"electron/",
"public/",
"dist/",
"dist-server/",
"shared/",
"server/",
"package.json"
],
"protocols": [
{
"name": "CloudCLI",
"schemes": [
"cloudcli"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
],
"extendInfo": {
"CFBundleName": "CloudCLI",
"CFBundleDisplayName": "CloudCLI",
"CFBundleURLTypes": [
{
"CFBundleURLName": "CloudCLI",
"CFBundleURLSchemes": [
"cloudcli"
]
}
]
}
}
},
"keywords": [
"claude code",
"claude-code",
@@ -119,7 +67,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",
@@ -148,7 +96,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",
@@ -194,9 +141,6 @@
"auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"electron": "^38.0.0",
"electron-builder": "^26.15.3",
"eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",

View File

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

841
public/modelConstants.js Normal file
View File

@@ -0,0 +1,841 @@
/**
* 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.7 (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: "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 },
];

View File

@@ -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 (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
## Quick Start

View File

@@ -1,384 +0,0 @@
#!/usr/bin/env node
import './load-env.js';
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
};
type ToolDefinition = {
name: string;
description: string;
inputSchema: Record<string, unknown>;
};
const textResponse = (text: string) => ({
content: [{ type: 'text', text }],
});
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
const readString = (value: unknown, name: string): string => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${name} is required.`);
}
return value.trim();
};
const readOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const readNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
if (!apiToken) {
throw new Error('CLOUDCLI_BROWSER_USE_MCP_TOKEN is not configured.');
}
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
signal: AbortSignal.timeout(API_TIMEOUT_MS),
});
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) {
throw new Error(data.error || `Browser API request failed (${response.status})`);
}
return data.data;
}
const sessionIdSchema = {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Browser session id.' },
},
required: ['sessionId'],
};
const tools: ToolDefinition[] = [
{
name: 'browser_create_session',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
inputSchema: {
type: 'object',
properties: {
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
},
},
},
{
name: 'browser_list_sessions',
description: 'List Browser sessions currently available to agents.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browser_snapshot',
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_take_screenshot',
description: 'Capture the latest screenshot for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_navigate',
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
url: { type: 'string' },
},
required: ['sessionId', 'url'],
},
},
{
name: 'browser_click',
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
x: { type: 'number' },
y: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_type',
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
submit: { type: 'boolean' },
},
required: ['sessionId', 'text'],
},
},
{
name: 'browser_fill_form',
description: 'Fill multiple form fields using CSS selectors.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
fields: {
type: 'array',
items: {
type: 'object',
properties: {
selector: { type: 'string' },
value: { type: 'string' },
},
required: ['selector', 'value'],
},
},
},
required: ['sessionId', 'fields'],
},
},
{
name: 'browser_press_key',
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
key: { type: 'string' },
},
required: ['sessionId', 'key'],
},
},
{
name: 'browser_select_option',
description: 'Select option values in a select element found by CSS selector.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
values: { type: 'array', items: { type: 'string' } },
},
required: ['sessionId', 'selector', 'values'],
},
},
{
name: 'browser_wait_for',
description: 'Wait for visible text, a URL pattern, or a short timeout.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
text: { type: 'string' },
url: { type: 'string' },
timeoutMs: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_tabs',
description: 'List, open, select, or close tabs in a Browser session.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
index: { type: 'number' },
url: { type: 'string' },
},
required: ['sessionId'],
},
},
{
name: 'browser_close_session',
description: 'Stop a Browser session controlled by agents.',
inputSchema: sessionIdSchema,
},
];
async function callTool(name: string, args: Record<string, unknown>) {
switch (name) {
case 'browser_create_session':
return jsonResponse(await callBrowserUseApi(name, {
profileName: readOptionalString(args.profileName),
}));
case 'browser_list_sessions':
return jsonResponse(await callBrowserUseApi(name, {}));
case 'browser_snapshot':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
case 'browser_take_screenshot': {
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
}
case 'browser_navigate':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
url: readString(args.url, 'url'),
}));
case 'browser_click':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readOptionalString(args.text),
x: readNumber(args.x),
y: readNumber(args.y),
}));
case 'browser_type':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readString(args.text, 'text'),
submit: args.submit === true,
}));
case 'browser_fill_form': {
const fields = Array.isArray(args.fields)
? args.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: readString(record.selector, 'field.selector'),
value: readString(record.value, 'field.value'),
};
})
: [];
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
fields,
}));
}
case 'browser_press_key':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
key: readString(args.key, 'key'),
}));
case 'browser_select_option':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readString(args.selector, 'selector'),
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
}));
case 'browser_wait_for':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
text: readOptionalString(args.text),
url: readOptionalString(args.url),
timeoutMs: readNumber(args.timeoutMs),
}));
case 'browser_tabs':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
? args.action
: undefined,
index: readNumber(args.index),
url: readOptionalString(args.url),
}));
case 'browser_close_session':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMessage(message: JsonRpcRequest) {
if (message.method === 'initialize') {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
};
}
if (message.method === 'tools/list') {
return { tools };
}
if (message.method === 'tools/call') {
const params = message.params || {};
const name = readString(params.name, 'name');
const args = (params.arguments && typeof params.arguments === 'object'
? params.arguments
: {}) as Record<string, unknown>;
return callTool(name, args);
}
if (message.method.startsWith('notifications/')) {
return undefined;
}
throw new Error(`Unsupported method: ${message.method}`);
}
function writeMessage(message: Record<string, unknown>) {
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
// no embedded newlines). This is NOT the LSP Content-Length framing.
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id: string | number | null | undefined, result: unknown) {
if (id === undefined) {
return;
}
writeMessage({ jsonrpc: '2.0', id, result });
}
function sendError(id: string | number | null | undefined, error: unknown) {
if (id === undefined) {
return;
}
writeMessage({
jsonrpc: '2.0',
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
});
}
let buffer = '';
process.stdin.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!rawMessage) {
continue;
}
void (async () => {
let request: JsonRpcRequest;
try {
request = JSON.parse(rawMessage) as JsonRpcRequest;
} catch (error) {
sendError(null, error);
return;
}
try {
const result = await handleMessage(request);
sendResult(request.id, result);
} catch (error) {
sendError(request.id, error);
}
})();
}
});

View File

@@ -28,14 +28,10 @@ import {
} 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 { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
const abortedSessionIds = new Set();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
@@ -208,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below
@@ -289,75 +285,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 directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
const cacheTokens = cacheCreationTokens + cacheReadTokens;
const inputTokens = directInputTokens + cacheTokens;
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
// 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,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
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
};
}
@@ -720,10 +684,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' }));
}
}
}
@@ -735,18 +705,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (!wasAborted) {
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
}
// Send completion event
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: wasAborted ? 'aborted' : 'completed'
stopReason: 'completed'
});
// Complete
@@ -761,22 +727,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
// caused by interrupt() is expected noise, not a user-facing error.
return;
}
// Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket, then the terminal complete
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -803,10 +761,6 @@ async function abortClaudeSDKSession(sessionId) {
try {
console.log(`Aborting SDK session: ${sessionId}`);
// Mark before interrupting so the run loop knows not to emit its own
// terminal complete (the abort handler sends the aborted one).
abortedSessionIds.add(sessionId);
// Call interrupt() on the query instance
await session.instance.interrupt();
@@ -822,8 +776,6 @@ async function abortClaudeSDKSession(sessionId) {
return true;
} catch (error) {
console.error(`Error aborting session ${sessionId}:`, error);
// The run keeps going; let it emit its own terminal complete.
abortedSessionIds.delete(sessionId);
return false;
}
}

View File

@@ -8,7 +8,6 @@
* (no args) - Start the server (default)
* start - Start the server
* sandbox - Manage Docker sandbox environments
* browser-use-mcp - Run Browser MCP stdio server
* status - Show configuration and data locations
* help - Show help information
* version - Show version information
@@ -155,13 +154,12 @@ Usage:
cloudcli [command] [options]
Commands:
start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
browser-use-mcp Run the Browser MCP stdio server
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
@@ -457,7 +455,7 @@ async function sandboxCommand(args) {
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
@@ -556,7 +554,7 @@ async function sandboxCommand(args) {
// Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
@@ -607,10 +605,6 @@ async function startServer() {
await import('./index.js');
}
async function startBrowserUseMcp() {
await import('./browser-use-mcp.js');
}
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
@@ -664,9 +658,6 @@ async function main() {
case 'sandbox':
await sandboxCommand(remainingArgs || []);
break;
case 'browser-use-mcp':
await startBrowserUseMcp();
break;
case 'status':
case 'info':
showStatus();

View File

@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
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 { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -34,10 +34,6 @@ async function spawnCursor(command, options = {}, ws) {
let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false;
let settled = false;
// The unified lifecycle contract requires exactly one terminal `complete`
// per run. Cursor surfaces completion twice (the `result` JSON line and
// the process close), so the first emission wins.
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -201,15 +197,15 @@ async function spawnCursor(command, options = {}, ws) {
break;
case 'result': {
// Session complete — terminal lifecycle event for this run
if (!completeSent) {
completeSent = true;
ws.send(createCompleteMessage({
provider: 'cursor',
sessionId: capturedSessionId || sessionId || null,
exitCode: response.subtype === 'success' ? 0 : 1,
}));
}
// Session complete — send stream end + lifecycle complete with result payload
const resultText = typeof response.result === 'string' ? response.result : '';
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: response.subtype === 'success' ? 0 : 1,
resultText,
isError: response.subtype !== 'success',
sessionId: capturedSessionId || sessionId, provider: 'cursor',
}));
break;
}
@@ -275,12 +271,7 @@ async function spawnCursor(command, options = {}, ws) {
return;
}
// Terminal complete — unless the `result` line already sent it, or the
// run was aborted (abort-session sent the aborted complete).
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
if (code === 0) {
notifyTerminalState({ code });
@@ -306,10 +297,6 @@ async function spawnCursor(command, options = {}, ws) {
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
}
notifyTerminalState({ error });
settleOnce(() => reject(error));
@@ -327,9 +314,6 @@ function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`Aborting Cursor session: ${sessionId}`);
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -10,7 +10,7 @@ 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 { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -129,9 +129,6 @@ async function spawnGemini(command, options = {}, ws) {
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
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -489,12 +486,7 @@ async function spawnGemini(command, options = {}, ws) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
@@ -574,10 +566,6 @@ async function spawnGemini(command, options = {}, ws) {
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
@@ -602,9 +590,6 @@ function abortGeminiSession(sessionId) {
if (geminiProc) {
try {
// The abort handler sends the terminal complete (aborted: true);
// flag the process so its close handler does not emit a second one.
geminiProc.aborted = true;
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {

View File

@@ -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() {

View File

@@ -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';
@@ -22,24 +21,35 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions,
resolveToolApproval,
getPendingApprovalsForSession,
reconnectSessionWriter,
} from './claude-sdk.js';
import {
spawnCursor,
abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions,
} from './cursor-cli.js';
import {
queryCodex,
abortCodexSession,
isCodexSessionActive,
getActiveCodexSessions,
} from './openai-codex.js';
import {
spawnGemini,
abortGeminiSession,
isGeminiSessionActive,
getActiveGeminiSessions,
} from './gemini-cli.js';
import {
spawnOpenCode,
abortOpenCodeSession,
isOpenCodeSessionActive,
getActiveOpenCodeSessions,
} from './opencode-cli.js';
import sessionManager from './sessionManager.js';
import {
@@ -61,11 +71,8 @@ import userRoutes from './routes/user.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.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';
@@ -76,17 +83,9 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
const app = express();
const server = http.createServer(app);
@@ -97,35 +96,32 @@ const wss = createWebSocketServer(server, {
authenticateWebSocket,
},
chat: {
spawnFns: {
claude: queryClaudeSDK,
cursor: spawnCursor,
codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode,
},
abortFns: {
claude: abortClaudeSDKSession,
cursor: abortCursorSession,
codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession,
},
queryClaudeSDK,
spawnCursor,
queryCodex,
spawnGemini,
spawnOpenCode,
abortClaudeSDKSession,
abortCursorSession,
abortCodexSession,
abortGeminiSession,
abortOpenCodeSession,
resolveToolApproval,
isClaudeSDKSessionActive,
isCursorSessionActive,
isCodexSessionActive,
isGeminiSessionActive,
isOpenCodeSessionActive,
reconnectSessionWriter,
getPendingApprovalsForSession,
getActiveClaudeSDKSessions,
getActiveCursorSessions,
getActiveCodexSessions,
getActiveGeminiSessions,
getActiveOpenCodeSessions,
},
shell: {
resolveProviderSessionId: (sessionId, provider) => {
const dbSession = sessionsDb.getSessionById(sessionId);
const legacyGeminiSession =
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
if (dbSession) {
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
}
return legacyGeminiSession?.cliSessionId;
},
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
stripAnsiSequences,
normalizeDetectedUrl,
extractUrlsFromText,
@@ -196,12 +192,6 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Browser MCP bridge API (local token protected)
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
// Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes);
@@ -901,27 +891,27 @@ const uploadFilesHandler = async (req, res) => {
}
}),
limits: {
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
files: MAX_FILE_UPLOAD_COUNT
fileSize: 50 * 1024 * 1024, // 50MB limit
files: 20 // Max 20 files at once
}
});
// Use multer middleware
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
uploadMiddleware.array('files', 20)(req, res, async (err) => {
if (err) {
console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
}
return res.status(500).json({ error: err.message });
}
try {
const { projectId } = req.params;
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads)
let filePaths = [];
@@ -945,11 +935,6 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' });
}
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
? parsedRequestedFileCount
: req.files.length;
// Resolve the project directory through the DB using the new projectId.
const projectRoot = await projectsDb.getProjectPathById(projectId);
if (!projectRoot) {
@@ -1028,10 +1013,8 @@ const uploadFilesHandler = async (req, res) => {
res.json({
success: true,
files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
});
} catch (error) {
console.error('Error uploading files:', error);
@@ -1144,6 +1127,7 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
try {
const { projectId, sessionId } = req.params;
const { provider = 'claude' } = req.query;
const homeDir = os.homedir();
// Allow only safe characters in sessionId
@@ -1152,144 +1136,38 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
return res.status(400).json({ error: 'Invalid sessionId' });
}
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
// are keyed by the provider-native session id, while the caller sends
// the app-facing id. Resolve provider and id mapping from the indexed
// session row so the frontend does not choose provider-specific paths.
const sessionRow = sessionsDb.getSessionById(safeSessionId);
if (!sessionRow) {
return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId });
}
const provider = sessionRow.provider || 'claude';
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
// Handle Cursor sessions - they use SQLite and don't have token usage info
if (provider === 'cursor') {
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'
});
}
// OpenCode token totals are surfaced through provider history reads.
// This legacy endpoint only knows file-backed session formats.
if (provider === 'opencode') {
const dbPath = getOpenCodeDatabasePath();
if (!fs.existsSync(dbPath)) {
return res.status(404).json({ error: 'OpenCode database not found' });
}
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const columns = db.prepare('PRAGMA table_info(session)').all();
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return res.json({
used: 0,
inputTokens: 0,
outputTokens: 0,
breakdown: { input: 0, output: 0 },
unsupported: true,
message: 'Token usage tracking is not available in this OpenCode database schema'
});
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(providerNativeSessionId);
if (!row) {
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
}
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
const outputTokens = Number(row.outputTokens || 0);
const totalUsed = Number(row.inputTokens || 0)
+ outputTokens
+ Number(row.reasoningTokens || 0)
+ Number(row.cacheReadTokens || 0)
+ Number(row.cacheWriteTokens || 0);
return res.json({
used: totalUsed,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens
}
});
} finally {
db.close();
}
return res.json({
used: 0,
total: 0,
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
unsupported: true,
message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
});
}
// Handle Codex sessions
@@ -1305,7 +1183,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
if (entry.isDirectory()) {
const found = await findSessionFile(fullPath);
if (found) return found;
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) {
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
return fullPath;
}
}
@@ -1332,8 +1210,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
@@ -1346,9 +1222,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;
@@ -1363,13 +1237,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
});
}
@@ -1389,19 +1257,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
// Prefer the indexed transcript path (already produced by the trusted
// session synchronizer); fall back to the conventional location
// derived from the provider-native session id.
let jsonlPath = sessionRow?.jsonl_path;
if (!jsonlPath) {
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
// Constrain the constructed path to projectDir (the id is
// caller-influenced in this fallback branch).
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Read and parse the JSONL file
@@ -1419,9 +1280,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 cacheReadTokens = 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--) {
@@ -1433,11 +1293,9 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const usage = entry.message.usage;
// Use token counts from latest assistant message only
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
inputTokens = usage.input_tokens || 0;
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
cacheReadTokens = usage.cache_read_input_tokens || 0;
break; // Stop after finding the latest assistant message
}
@@ -1447,20 +1305,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
}
}
const totalUsed = inputTokens + outputTokens;
const cacheTokens = cacheReadTokens + cacheCreationTokens;
// Calculate total context usage (excluding output_tokens, as per ccusage)
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
res.json({
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens
cacheCreation: cacheCreationTokens,
cacheRead: cacheReadTokens
}
});
} catch (error) {
@@ -1526,133 +1380,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;
@@ -1713,21 +1508,12 @@ async function startServer() {
await closeSessionsWatcher();
// Clean up plugin processes on shutdown
const shutdownRuntimeServices = async () => {
try {
await browserUseService.stopAllSessions();
} catch (err) {
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
}
try {
await stopAllPlugins();
} catch (err) {
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
}
const shutdownPlugins = async () => {
await stopAllPlugins();
process.exit(0);
};
process.on('SIGTERM', () => void shutdownRuntimeServices());
process.on('SIGINT', () => void shutdownRuntimeServices());
process.on('SIGTERM', () => void shutdownPlugins());
process.on('SIGINT', () => void shutdownPlugins());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);

View File

@@ -1,120 +0,0 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readBearerToken(header: unknown): string | null {
if (typeof header !== 'string') {
return null;
}
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
return match?.[1]?.trim() || null;
}
router.use((req, res, next) => {
const expected = browserUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
if (!token || token !== expected) {
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
return;
}
next();
});
router.post('/tools/:toolName', async (req, res) => {
try {
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
const toolName = req.params.toolName;
let result: unknown;
switch (toolName) {
case 'browser_create_session':
result = await browserUseService.createAgentSession({
profileName: typeof input.profileName === 'string' ? input.profileName : null,
});
break;
case 'browser_list_sessions':
result = await browserUseService.listAgentSessions();
break;
case 'browser_snapshot':
case 'browser_take_screenshot':
result = await browserUseService.agentSnapshot(sessionId);
break;
case 'browser_navigate':
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
break;
case 'browser_click':
result = await browserUseService.agentClick(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: typeof input.text === 'string' ? input.text : undefined,
x: typeof input.x === 'number' ? input.x : undefined,
y: typeof input.y === 'number' ? input.y : undefined,
});
break;
case 'browser_type':
result = await browserUseService.agentType(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: String(input.text || ''),
submit: input.submit === true,
});
break;
case 'browser_fill_form':
result = await browserUseService.agentFillForm(
sessionId,
Array.isArray(input.fields)
? input.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: String(record.selector || ''),
value: String(record.value || ''),
};
})
: [],
);
break;
case 'browser_press_key':
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
break;
case 'browser_select_option':
result = await browserUseService.agentSelectOption(
sessionId,
String(input.selector || ''),
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
);
break;
case 'browser_wait_for':
result = await browserUseService.agentWaitFor(sessionId, {
text: typeof input.text === 'string' ? input.text : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
});
break;
case 'browser_tabs':
result = await browserUseService.agentTabs(sessionId, {
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
? input.action
: undefined,
index: typeof input.index === 'number' ? input.index : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
});
break;
case 'browser_close_session':
result = await browserUseService.agentStopSession(sessionId);
break;
default:
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
return;
}
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
});
}
});
export default router;

View File

@@ -1,96 +0,0 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await browserUseService.getStatus() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
});
}
});
router.get('/settings', async (_req, res) => {
try {
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
});
}
});
router.put('/settings', async (req, res) => {
try {
const settings = await browserUseService.updateSettings(req.body || {});
res.json({ success: true, data: { settings } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
});
}
});
router.post('/runtime/install', async (_req, res) => {
try {
const result = await browserUseService.installRuntime();
res.status(result.success ? 200 : 500).json({
success: result.success,
data: result,
error: result.success ? undefined : result.message,
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
});
}
});
router.get('/sessions', async (_req, res) => {
try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) {
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req, res) => {
try {
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
});
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete browser session.',
});
}
});
export default router;

View File

@@ -1,836 +0,0 @@
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
const require = createRequire(import.meta.url);
const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
}
function readSettings(): BrowserUseSettings {
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
}
}
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser is disabled in settings.';
}
if (!readiness.playwrightInstalled) {
return 'Install Playwright and Chromium to use browser sessions.';
}
if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
return readiness.installMessage || 'Browser runtime is not ready.';
}
function getPlaywright(): any | null {
try {
return require('playwright');
} catch {
return null;
}
}
function getMcpCommand(): { command: string; args: string[] } {
const serverDir = path.resolve(__dirname, '..', '..');
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
if (fs.existsSync(mcpScriptPath)) {
return {
command: process.execPath,
args: [mcpScriptPath],
};
}
return {
command: 'cloudcli',
args: ['browser-use-mcp'],
};
}
function getMcpApiUrl(): string {
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
}
async function removeMcpServerFromAllProviders(name: string) {
const results = await providerMcpService.removeMcpServerFromAllProviders({
name,
scope: 'user',
});
return results.map((result) => ({ ...result, name }));
}
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 80);
}
function getProfilePath(profileName: string): string {
const safeName = profileName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'default';
return path.join(PROFILE_ROOT, safeName);
}
function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright();
const readiness: RuntimeProbe = {
playwright,
playwrightInstalled: Boolean(playwright),
chromiumInstalled: false,
chromiumExecutablePath: null,
};
if (!playwright) {
return readiness;
}
try {
const executablePath = playwright.chromium.executablePath();
readiness.chromiumExecutablePath = executablePath;
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
} catch {
readiness.chromiumInstalled = false;
}
return readiness;
}
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
const now = Date.now();
const cachedProbe = runtimeProbeCache;
const canUseCache = !options.force
&& !installPromise
&& cachedProbe
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
const probe = canUseCache ? cachedProbe.value : probeRuntime();
if (!canUseCache && !installPromise) {
runtimeProbeCache = { value: probe, updatedAt: now };
}
return {
...probe,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10,
);
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const output: string[] = [];
let settled = false;
const finish = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(() => reject(new Error(
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
)));
}, INSTALL_COMMAND_TIMEOUT_MS);
timer.unref?.();
child.stdout.on('data', (chunk) => output.push(String(chunk)));
child.stderr.on('data', (chunk) => output.push(String(chunk)));
child.on('error', (error) => finish(() => reject(error)));
child.on('close', (code) => finish(() => {
if (code === 0) {
resolve();
return;
}
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
}));
});
}
function formatInstallError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('sudo') && message.includes('password')) {
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
}
return message || 'Failed to install Browser runtime.';
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
if (installPromise) {
return installPromise;
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
runtimeProbeCache = null;
installPromise = (async () => {
try {
lastInstallMessage = 'Installing Playwright package...';
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
if (process.platform === 'linux') {
lastInstallMessage = 'Installing Chromium system dependencies...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
}
lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = formatInstallError(error);
return { success: false, message: lastInstallMessage };
}
})();
try {
return await installPromise;
} finally {
installPromise = null;
runtimeProbeCache = null;
}
}
function normalizeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('URL is required.');
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only http and https URLs are supported.');
}
return parsed.toString();
}
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
const { ownerId: _ownerId, ...publicFields } = session;
return publicFields;
}
function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId);
handles.delete(sessionId);
await handle?.context?.close?.().catch(() => undefined);
await handle?.browser?.close().catch(() => undefined);
}
async function expireStaleSessions(now = Date.now()): Promise<void> {
await Promise.all([...sessions.values()].map(async (session) => {
if (session.status !== 'ready') {
return;
}
const updatedAt = Date.parse(session.updatedAt);
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
return;
}
await closeHandle(session.id);
session.status = 'stopped';
session.updatedAt = new Date(now).toISOString();
session.lastAction = 'expire';
session.message = 'Browser session expired after inactivity.';
}));
}
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
session.title = await page.title().catch(() => null);
session.url = page.url() || session.url;
session.viewport = page.viewportSize?.() || session.viewport;
session.updatedAt = new Date().toISOString();
}
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
if (typeof input.x === 'number' && typeof input.y === 'number') {
return { x: input.x, y: input.y };
}
const locator = input.selector
? page.locator(input.selector).first()
: input.text
? page.getByText(input.text, { exact: false }).first()
: null;
if (!locator) {
return null;
}
const box = await locator.boundingBox().catch(() => null);
if (!box) {
return null;
}
return {
x: Math.round(box.x + box.width / 2),
y: Math.round(box.y + box.height / 2),
};
}
export const browserUseService = {
async getSettings() {
return readSettings();
},
async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = readSettings();
const nextSettings = {
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
};
const next = writeSettings(nextSettings);
if (next.enabled) {
await this.registerAgentMcp();
} else if (current.enabled) {
await this.unregisterAgentMcp();
await this.stopAllSessions();
}
return next;
},
async getStatus() {
const settings = readSettings();
const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
return {
enabled: settings.enabled,
runtime: getRuntime(),
available,
playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
message: available
? 'Browser runtime is available.'
: getSetupMessage(settings, readiness),
};
},
async registerAgentMcp() {
const { command, args } = getMcpCommand();
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME,
scope: 'user',
transport: 'stdio',
command,
args,
env: {
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
},
});
return { name: MCP_SERVER_NAME, command, args, results };
},
getMcpToken() {
return getOrCreateMcpToken();
},
async unregisterAgentMcp() {
const results = (await Promise.all(
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
)).flat();
return { name: MCP_SERVER_NAME, results };
},
async installRuntime() {
const result = await installRuntime();
return {
...result,
status: await this.getStatus(),
};
},
async listSessions() {
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async createAgentSession(options?: { profileName?: string | null }) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
await expireStaleSessions();
const profileName = normalizeProfileName(options?.profileName);
const now = new Date().toISOString();
const session: BrowserUseSession = {
id: randomUUID(),
ownerId: AGENT_OWNER_ID,
createdBy: 'agent',
runtime: getRuntime(),
status: 'unavailable',
url: null,
title: null,
screenshotDataUrl: null,
createdAt: now,
updatedAt: now,
lastAction: 'create',
message: null,
profileName,
viewport: { width: 1440, height: 900 },
cursor: null,
};
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
}
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
}
let browser: any | undefined;
let context: any | undefined;
let page: any;
const launchOptions = {
headless: true,
args: ['--disable-dev-shm-usage'],
};
const contextOptions = {
viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
browser = await readiness.playwright.chromium.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
session.status = 'ready';
session.message = 'Browser session is ready.';
sessions.set(session.id, session);
handles.set(session.id, { browser, context, page });
await captureSession(session, page);
return publicSession(session);
},
async listAgentSessions() {
const settings = readSettings();
if (!settings.enabled) {
return [];
}
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async getAgentSession(sessionId: string) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
return session;
},
async agentNavigate(sessionId: string, rawUrl: string) {
await this.getAgentSession(sessionId);
await expireStaleSessions();
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
if (session.status !== 'ready') {
throw new Error(session.message || 'Browser session is not available.');
}
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const url = normalizeUrl(rawUrl);
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`;
session.cursor = null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSnapshot(sessionId: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await captureSession(session, handle.page);
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
return {
session: publicSession(session),
text: text.slice(0, 30_000),
};
},
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const point = await getActionPoint(handle.page, input);
if (input.selector) {
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
} else if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
await handle.page.mouse.click(input.x, input.y);
} else {
throw new Error('Provide selector, text, or x/y coordinates.');
}
session.lastAction = 'click';
session.cursor = point ? { ...point, actor: 'agent' } : null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
if (input.selector) {
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
session.cursor = await getActionPoint(handle.page, input).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
} else {
await handle.page.keyboard.type(input.text);
}
if (input.submit) {
await handle.page.keyboard.press('Enter');
}
session.lastAction = 'type';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
for (const field of fields) {
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
}
session.lastAction = 'fill_form';
if (fields[0]) {
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
}
await captureSession(session, handle.page);
return publicSession(session);
},
async agentPressKey(sessionId: string, key: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.keyboard.press(key);
session.lastAction = `press_key:${key}`;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
session.lastAction = 'select_option';
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
await captureSession(session, handle.page);
return publicSession(session);
},
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
} else if (input.url) {
await handle.page.waitForURL(input.url, { timeout });
} else {
await handle.page.waitForTimeout(timeout);
}
session.lastAction = 'wait_for';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.context || !handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const action = input.action || 'list';
if (action === 'new') {
const page = await handle.context.newPage();
handles.set(sessionId, { ...handle, page });
if (input.url) {
await this.agentNavigate(sessionId, input.url);
}
} else if (action === 'select') {
const page = handle.context.pages()[input.index || 0];
if (!page) {
throw new Error('Tab not found.');
}
handles.set(sessionId, { ...handle, page });
} else if (action === 'close') {
const pages = handle.context.pages();
const page = pages[input.index ?? pages.indexOf(handle.page)];
if (!page) {
throw new Error('Tab not found.');
}
await page.close();
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
}
const updatedHandle = handles.get(sessionId);
await captureSession(session, updatedHandle?.page || handle.page);
return {
session: publicSession(session),
tabs: handle.context.pages().map((page: any, index: number) => ({
index,
url: page.url(),
active: page === (updatedHandle?.page || handle.page),
})),
};
},
async stopSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { stopped: false };
}
await closeHandle(sessionId);
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'stop';
session.message = 'Browser session stopped. Create a new session to continue browsing.';
return { stopped: true, session: publicSession(session) };
},
async deleteSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { deleted: false };
}
await closeHandle(sessionId);
sessions.delete(sessionId);
return { deleted: true, sessionId };
},
async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId);
return this.stopSession(sessionId);
},
async stopAllSessions() {
await Promise.all([...sessions.keys()].map(async (sessionId) => {
await closeHandle(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'shutdown';
session.message = 'Browser session stopped during server shutdown.';
}
}));
},
};
process.once('beforeExit', () => {
void browserUseService.stopAllSessions();
});

View File

@@ -1,10 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
test('browser monitor list starts empty without agent sessions', async () => {
const sessions = await browserUseService.listSessions();
assert.deepEqual(sessions, []);
});

View File

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

View File

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

View File

@@ -70,15 +70,3 @@ test('createSession reactivates archived rows when the session becomes active ag
assert.equal(restoredSession?.isArchived, 0);
});
});
test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project');
const row = sessionsDb.getSessionById('session-timezone');
assert.ok(row?.created_at.endsWith('Z'));
assert.ok(row?.updated_at.endsWith('Z'));
assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
});
});

View File

@@ -5,7 +5,6 @@ import { normalizeProjectPath } from '@/shared/utils.js';
type SessionRow = {
session_id: string;
provider: string;
provider_session_id: string | null;
project_path: string | null;
jsonl_path: string | null;
custom_name: string | null;
@@ -14,22 +13,15 @@ type SessionRow = {
updated_at: string;
};
const SESSION_ROW_COLUMNS =
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
type SessionMetadataLookupRow = Pick<
SessionRow,
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
>;
function normalizeTimestamp(value?: string): string | null {
if (!value) return null;
// SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
// Normalize it here so every session reader returns canonical ISO strings
// and the sidebar never interprets fresh rows as local-time "hours old".
const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
? `${value.replace(' ', 'T')}Z`
: value;
const parsed = new Date(normalizedValue);
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
@@ -37,38 +29,14 @@ function normalizeTimestamp(value?: string): string | null {
return parsed.toISOString();
}
function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
if (!row) {
return row;
}
return {
...row,
created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
};
}
function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
return rows.map((row) => normalizeSessionRow(row) as SessionRow);
}
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
void provider;
return normalizeProjectPath(projectPath);
}
export const sessionsDb = {
/**
* Upserts one session row discovered on disk by a provider synchronizer.
*
* The given id is the provider-native session id. Rows are keyed by
* `provider_session_id` so a session that was first created by the app
* (with an app-allocated `session_id`) is updated in place once its
* transcript shows up on disk, instead of producing a duplicate row.
*/
createSession(
providerSessionId: string,
sessionId: string,
provider: string,
projectPath: string,
customName?: string,
@@ -85,54 +53,19 @@ export const sessionsDb = {
// since it's a foreign key in the sessions table.
projectsDb.createProjectPath(normalizedProjectPath);
const existing = db
.prepare(
`SELECT session_id FROM sessions
WHERE provider_session_id = ? AND provider = ?
LIMIT 1`
)
.get(providerSessionId, provider) as { session_id: string } | undefined;
if (existing) {
db.prepare(
`UPDATE sessions SET
provider = ?,
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
project_path = ?,
jsonl_path = ?,
isArchived = 0,
custom_name = COALESCE(?, custom_name)
WHERE session_id = ?`
).run(
provider,
updatedAtValue,
normalizedProjectPath,
jsonlPath ?? null,
customName ?? null,
existing.session_id
);
return existing.session_id;
}
// Sessions created outside the app (directly via the provider CLI) are
// keyed by the provider-native id for both columns. The ON CONFLICT path
// covers legacy rows that predate the provider_session_id mapping.
db.prepare(
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider,
provider_session_id = excluded.provider_session_id,
updated_at = excluded.updated_at,
project_path = excluded.project_path,
jsonl_path = excluded.jsonl_path,
isArchived = 0,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
).run(
providerSessionId,
sessionId,
provider,
providerSessionId,
customName ?? null,
normalizedProjectPath,
jsonlPath ?? null,
@@ -140,77 +73,9 @@ export const sessionsDb = {
updatedAtValue
);
return providerSessionId;
},
/**
* Inserts one app-allocated session row before any provider run happens.
*
* The session gateway uses this when the frontend starts a brand-new chat:
* `session_id` is the stable app-facing id, while `provider_session_id`
* stays NULL until the provider runtime announces its own id and
* `assignProviderSessionId` records the mapping.
*/
createAppSession(sessionId: string, provider: string, projectPath: string): string {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
projectsDb.createProjectPath(normalizedProjectPath);
db.prepare(
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
).run(sessionId, provider, normalizedProjectPath);
return sessionId;
},
/**
* Records the provider-native session id for one app-allocated session.
*
* If the filesystem watcher indexed the provider transcript before this
* mapping was recorded (a duplicate row keyed by the provider id exists),
* the duplicate is merged into the app row: its transcript path and name
* are adopted and the duplicate row is removed. Runs in a transaction so
* the sidebar can never observe both rows at once.
*/
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
const db = getConnection();
const merge = db.transaction(() => {
const duplicate = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
WHERE (session_id = ? OR provider_session_id = ?)
AND session_id <> ?
LIMIT 1`
)
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
if (duplicate) {
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
db.prepare(
`UPDATE sessions SET
provider_session_id = ?,
jsonl_path = COALESCE(jsonl_path, ?),
custom_name = COALESCE(custom_name, ?),
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?`
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
return;
}
db.prepare(
`UPDATE sessions SET
provider_session_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?`
).run(providerSessionId, sessionId);
});
merge();
},
updateSessionCustomName(sessionId: string, customName: string): void {
const db = getConnection();
db.prepare(
@@ -220,91 +85,30 @@ export const sessionsDb = {
).run(customName, sessionId);
},
getSessionById(sessionId: string): SessionRow | null {
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
const db = getConnection();
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE session_id = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.get(sessionId) as SessionRow | undefined;
.get(sessionId) as SessionMetadataLookupRow | undefined;
return normalizeSessionRow(row) ?? null;
},
/**
* Resolves one session row through the provider-native id.
*
* The filesystem watcher only knows provider ids (they come from transcript
* file names), so it uses this lookup to translate disk artifacts back to
* the app-facing session row before broadcasting sidebar updates.
*/
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
const db = getConnection();
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE provider_session_id = ?
ORDER BY updated_at DESC
LIMIT 1`
)
.get(providerSessionId) as SessionRow | undefined;
return normalizeSessionRow(row) ?? null;
},
/**
* Finds the newest app-created session for a project that is still waiting
* for its provider-native id to be recorded.
*
* Primary intention: OpenCode can expose a new session in its shared
* `opencode.db` before the websocket runtime reports that same provider id
* back to our app. At that moment the sidebar already has an optimistic
* app-owned session row, but the watcher only knows the provider-native id.
*
* Without this lookup, the synchronizer would insert a second row keyed by
* the provider id, then `assignProviderSessionId()` would merge it a moment
* later. That eventually self-heals, but on slow networks the user can still
* briefly see two sidebar sessions for the same conversation.
*
* This helper lets the synchronizer claim the pending app row first, so the
* provider id is attached before any watcher-created row exists. The result
* is simpler than frontend dedupe and keeps the race resolved at the source.
*/
findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
const row = db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
FROM sessions
WHERE provider = ?
AND project_path = ?
AND provider_session_id IS NULL
AND isArchived = 0
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
LIMIT 1`
)
.get(provider, normalizedProjectPath) as SessionRow | undefined;
return normalizeSessionRow(row) ?? null;
return row ?? null;
},
getAllSessions(): SessionRow[] {
const db = getConnection();
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE isArchived = 0`
)
.all() as SessionRow[];
return normalizeSessionRows(rows);
},
/**
@@ -313,31 +117,27 @@ export const sessionsDb = {
*/
getArchivedSessions(): SessionRow[] {
const db = getConnection();
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE isArchived = 1
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
)
.all() as SessionRow[];
return normalizeSessionRows(rows);
},
getSessionsByProjectPath(projectPath: string): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE project_path = ?
AND isArchived = 0`
)
.all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
},
/**
@@ -347,23 +147,21 @@ export const sessionsDb = {
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE project_path = ?`
)
.all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
},
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const rows = db
return db
.prepare(
`SELECT ${SESSION_ROW_COLUMNS}
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
FROM sessions
WHERE project_path = ?
AND isArchived = 0
@@ -371,8 +169,6 @@ export const sessionsDb = {
LIMIT ? OFFSET ?`
)
.all(normalizedProjectPath, limit, offset) as SessionRow[];
return normalizeSessionRows(rows);
},
countSessionsByProjectPath(projectPath: string): number {

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,10 @@ type ProjectApiView = {
isArchived: boolean;
isStarred: boolean;
sessions: [];
cursorSessions: [];
codexSessions: [];
geminiSessions: [];
opencodeSessions: [];
sessionMeta: {
hasMore: false;
total: 0;
@@ -78,6 +82,10 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
isArchived: Boolean(projectRow.isArchived),
isStarred: Boolean(projectRow.isStarred),
sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: {
hasMore: false,
total: 0,

View File

@@ -9,12 +9,13 @@ import { AppError } from '@/shared/utils.js';
type SessionSummary = {
id: string;
provider: string;
summary: string;
messageCount: number;
lastActivity: string;
};
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
type SessionRepositoryRow = {
provider: string;
session_id: string;
@@ -30,6 +31,10 @@ export type ProjectListItem = {
fullPath: string;
isStarred: boolean;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
@@ -59,7 +64,7 @@ type SessionPaginationOptions = {
};
type ProjectSessionsPageResult = {
sessions: SessionSummary[];
sessionsByProvider: SessionsByProvider;
total: number;
hasMore: boolean;
};
@@ -67,6 +72,10 @@ type ProjectSessionsPageResult = {
export type ProjectSessionsPageApiView = {
projectId: string;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
@@ -120,18 +129,39 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
return {
id: row.session_id,
provider: row.provider,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
};
}
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
codex: [],
gemini: [],
opencode: [],
};
for (const row of rows) {
const provider = row.provider as keyof SessionsByProvider;
const bucket = byProvider[provider];
if (!bucket) {
continue;
}
bucket.push(mapSessionRowToSummary(row));
}
return byProvider;
}
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
return {
sessions: rows.map(mapSessionRowToSummary),
sessionsByProvider: bucketSessionRowsByProvider(rows),
total: rows.length,
hasMore: false,
};
@@ -153,17 +183,16 @@ function readProjectSessionsPageByPath(
const total = sessionsDb.countSessionsByProjectPath(projectPath);
return {
sessions: rows.map(mapSessionRowToSummary),
sessionsByProvider: bucketSessionRowsByProvider(rows),
total,
hasMore: pagination.offset + rows.length < total,
};
}
// Broadcast progress to all connected WebSocket clients.
// Uses the unified `kind` envelope like every other websocket frame.
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress: ProgressUpdate) {
const message = JSON.stringify({
kind: 'loading_progress',
type: 'loading_progress',
...progress,
});
@@ -175,7 +204,7 @@ function broadcastProgress(progress: ProgressUpdate) {
}
/**
* Reads all projects from DB and returns normalized session summaries.
* Reads all projects from DB and returns provider-bucketed session summaries.
*/
export async function getProjectsWithSessions(
options: GetProjectsWithSessionsOptions = {}
@@ -223,7 +252,11 @@ export async function getProjectsWithSessions(
displayName,
fullPath: projectPath,
isStarred: Boolean(row.isStarred),
sessions: sessionsPage.sessions,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
@@ -276,7 +309,11 @@ export async function getArchivedProjectsWithSessions(
fullPath: row.project_path,
isStarred: Boolean(row.isStarred),
isArchived: true,
sessions: sessionsPage.sessions,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
@@ -305,7 +342,11 @@ export async function getProjectSessionsPage(
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
return {
projectId: projectRow.project_id,
sessions: sessionsPage.sessions,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,

View File

@@ -83,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
- 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 the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
- 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.

View File

@@ -1,6 +1,5 @@
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
export { providerSkillsService } from './services/skills.service.js';
export { providerMcpService } from './services/mcp.service.js';
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';

View File

@@ -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 };
}
}
}

View File

@@ -1,10 +1,14 @@
import { readFile } from 'node:fs/promises';
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
import { sessionsDb } from '@/modules/database/index.js';
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
@@ -15,39 +19,17 @@ import {
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{
value: 'default',
label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
},
{
value: 'haiku',
label: 'Haiku',
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
},
{ value: 'default', label: 'Default (recommended)' },
{ value: 'sonnet[1m]', label: 'Sonnet (1M context)' },
{ value: 'opus', label: 'Opus' },
{ value: 'opus[1m]', label: 'Opus (1M context)' },
{ value: 'haiku', label: 'Haiku' },
{ value: 'sonnet', label: 'sonnet' },
],
DEFAULT: 'default',
};
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
type ClaudeInitEvent = {
sessionId?: string;
session_id?: string;
@@ -67,6 +49,46 @@ const ANSI_PATTERN = new RegExp(
'g',
);
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
env: { ...process.env },
pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH),
permissionMode: 'default',
});
const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({
value: model.value,
label: model.displayName || model.value,
description: model.description || undefined,
});
const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = [];
const seenValues = new Set<string>();
for (const model of models) {
const mappedModel = mapClaudeModel(model);
if (seenValues.has(mappedModel.value)) {
continue;
}
seenValues.add(mappedModel.value);
options.push(mappedModel);
}
if (options.length === 0) {
return CLAUDE_FALLBACK_MODELS;
}
const defaultValue = options.find((option) => option.value === 'default')?.value
?? options[0]?.value
?? CLAUDE_FALLBACK_MODELS.DEFAULT;
return {
OPTIONS: options,
DEFAULT: defaultValue,
};
};
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
const eventSessionId = event.sessionId ?? event.session_id;
if (eventSessionId && eventSessionId !== sessionId) {
@@ -159,18 +181,25 @@ const readClaudeSessionModelFromJsonl = async (
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;
let queryInstance: ReturnType<typeof query> | null = null;
try {
// The SDK exposes its runtime model catalog on the initialized query
// instance, so we create a lightweight query and immediately close it
// after reading the control-plane metadata.
queryInstance = query({
prompt: 'Get supported models',
options: buildClaudeQueryOptions(),
});
const supportedModels = await queryInstance.supportedModels();
return buildClaudeModelsDefinition(supportedModels);
} catch {
return CLAUDE_FALLBACK_MODELS;
} finally {
queryInstance?.close();
}
}
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {

View File

@@ -25,21 +25,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'claude' as const;
private readonly claudeHome = path.join(os.homedir(), '.claude');
/**
* Returns true when a JSONL file is a subagent transcript rather than a
* top-level session.
*
* Claude stores subagent transcripts under a `subagents/` directory, e.g.
* `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
* Those files repeat the parent session's `sessionId`, so indexing them as
* standalone sessions overwrites the parent row's `jsonl_path` and corrupts
* the main session record. The recursive scan in `synchronize()` reaches
* them, so both entry points must skip them.
*/
private isSubagentTranscript(filePath: string): boolean {
return path.normalize(filePath).split(path.sep).includes('subagents');
}
/**
* Scans ~/.claude/projects and upserts discovered sessions into DB.
*/
@@ -53,10 +38,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
let processed = 0;
for (const filePath of files) {
if (this.isSubagentTranscript(filePath)) {
continue;
}
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
continue;
@@ -85,9 +66,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
if (!filePath.endsWith('.jsonl')) {
return null;
}
if (this.isSubagentTranscript(filePath)) {
return null;
}
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const parsed = await this.processSessionFile(filePath, nameMap);
@@ -133,10 +111,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
return null;
}
// App-created sessions are keyed by an app id, so disk-discovered provider
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {

View File

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

View File

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

View File

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

View File

@@ -21,563 +21,27 @@ import {
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",
},
{ 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: "composer-2.5-fast",
DEFAULT: 'composer-2-fast',
};
type CursorModelRow = {
@@ -817,4 +281,3 @@ export class CursorProviderModels implements IProviderModels {
return writeProviderSessionActiveModelChange('cursor', input);
}
}

View File

@@ -9,7 +9,6 @@ import {
generateMessageId,
readObjectRecord,
sanitizeLeafDirectoryName,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'cursor';
@@ -364,32 +363,42 @@ export class CursorSessionsProvider implements IProviderSessions {
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
* the most recent page, matching every other provider.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
// The store.db folder on disk is named after the provider-native id, not
// the app-facing session id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
try {
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
const total = renderableMessages.length;
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
if (limit !== null) {
const start = offset;
const page = limit === 0
? []
: renderableMessages.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total,
hasMore,
offset,
limit,
};
}
return {
messages: page,
messages: renderableMessages,
total,
hasMore,
offset,
limit,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View File

@@ -12,14 +12,17 @@ import {
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-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
{ 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-flash-preview',
DEFAULT: 'gemini-3.1-pro-preview',
};
export class GeminiProviderModels implements IProviderModels {

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
@@ -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,
},
};
}
@@ -518,9 +525,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
// Tail pagination via the shared contract: offset 0 returns the most
// recent page, matching every other provider.
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
const messages = pageLimit === null
? normalized.slice(start)
: normalized.slice(start, start + pageLimit);
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -529,9 +536,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
}
return {
messages: page,
messages,
total,
hasMore,
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
offset: start,
limit: pageLimit,
tokenUsage: result.tokenUsage,

View File

@@ -112,21 +112,7 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
}
const fallbackTitle = 'Untitled OpenCode Session';
const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId)
?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath);
if (pendingAppSession && !pendingAppSession.provider_session_id) {
// Slow networks can let the sqlite watcher index opencode.db before the
// runtime reports its provider id back through the websocket mapping.
// Bind that id to the fresh app row first so the watcher does not create
// a temporary provider-id sidebar entry for the same session.
sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId);
}
// App-created sessions are keyed by an app id, so disk-discovered provider
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId);
const existingSession = sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
@@ -134,9 +120,7 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
// 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.
// Return the canonical stored row id so watcher-triggered sidebar updates
// stay on the app session once provider_session_id has already been mapped.
return sessionsDb.createSession(
sessionsDb.createSession(
sessionId,
this.provider,
projectPath,
@@ -145,6 +129,8 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
null,
);
return sessionId;
}
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {

View File

@@ -12,7 +12,6 @@ import {
readObjectRecord,
readJsonRecord,
readOptionalString,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
@@ -29,9 +28,9 @@ type OpenCodeHistoryRow = {
type OpenCodeTokenTotals = {
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
cacheCreationTokens: number;
reasoningTokens: number;
};
const openOpenCodeDatabase = (): Database.Database | null => {
@@ -107,13 +106,11 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
}
const inputTokens = totals.inputTokens;
const displayInputTokens = inputTokens + totals.cacheReadTokens;
const outputTokens = totals.outputTokens;
const used = inputTokens
+ outputTokens
+ totals.reasoningTokens
+ totals.cacheReadTokens
+ totals.cacheWriteTokens;
const cacheReadTokens = totals.cacheReadTokens;
const cacheCreationTokens = totals.cacheCreationTokens;
const reasoningTokens = totals.reasoningTokens;
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
if (used <= 0) {
return undefined;
@@ -121,50 +118,14 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
return {
used,
inputTokens: displayInputTokens,
total: used,
inputTokens,
outputTokens,
breakdown: {
input: displayInputTokens,
output: outputTokens,
},
cacheReadTokens,
cacheCreationTokens,
};
};
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
@@ -174,18 +135,13 @@ 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;
let cacheCreationTokens = 0;
let reasoningTokens = 0;
for (const row of rows) {
const info = readJsonRecord(row.data);
@@ -203,15 +159,15 @@ const aggregateOpenCodeSessionTokenUsage = (
reasoningTokens += Number(tokens.reasoning ?? 0);
const cache = readObjectRecord(tokens.cache);
cacheReadTokens += Number(cache?.read ?? 0);
cacheWriteTokens += Number(cache?.write ?? 0);
cacheCreationTokens += Number(cache?.write ?? 0);
}
return buildTokenUsage({
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
cacheCreationTokens,
reasoningTokens,
});
};
@@ -326,9 +282,6 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
// OpenCode's shared sqlite database keys messages by the provider-native
// session id, not the app-facing id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
const db = openOpenCodeDatabase();
if (!db) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
@@ -353,20 +306,27 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
m.id,
COALESCE(p.time_created, 0),
p.id
`).all(providerSessionId) as OpenCodeHistoryRow[];
`).all(sessionId) as OpenCodeHistoryRow[];
const normalized = this.normalizeHistoryRows(rows, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
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 { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, total - normalizedOffset - normalizedLimit),
Math.max(0, total - normalizedOffset),
);
return {
messages: page,
messages,
total,
hasMore,
hasMore: normalizedLimit === null
? false
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
offset: normalizedOffset,
limit: normalizedLimit,
tokenUsage,

View File

@@ -1,7 +1,6 @@
import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
@@ -383,51 +382,7 @@ router.post(
}),
);
router.get(
'/capabilities',
asyncHandler(async (_req: Request, res: Response) => {
res.json(createApiSuccessResponse({
providers: providerCapabilitiesService.listAllProviderCapabilities(),
}));
}),
);
router.get(
'/:provider/capabilities',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
res.json(createApiSuccessResponse(
providerCapabilitiesService.getProviderCapabilities(provider),
));
}),
);
// ----------------- Session routes -----------------
/**
* Session gateway entry point: allocates the stable app-facing session id for
* a brand-new chat. The frontend must call this before the first `chat.send`
* so the session id in the URL, the store, and the websocket all agree from
* the very first message — there is no client-visible session-id handoff.
*/
router.post(
'/sessions',
asyncHandler(async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const provider = parseProvider(body.provider);
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
const result = sessionsService.createAppSession(provider, projectPath);
res.status(201).json(createApiSuccessResponse(result));
}),
);
router.get(
'/sessions/running',
asyncHandler(async (_req: Request, res: Response) => {
const sessions = sessionsService.listRunningSessions();
res.json(createApiSuccessResponse({ sessions }));
}),
);
router.get(
'/sessions/archived',
asyncHandler(async (_req: Request, res: Response) => {
@@ -504,7 +459,7 @@ router.get(
limit,
offset,
});
res.json(createApiSuccessResponse(result));
res.json(result);
}),
);

View File

@@ -1,7 +1,18 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import 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 });
@@ -80,30 +91,4 @@ export const providerMcpService = {
return results;
},
/**
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
* by iterating the live provider registry, so callers stay in sync with which
* providers exist instead of maintaining their own provider list.
*/
async removeMcpServerFromAllProviders(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders();
for (const provider of providers) {
try {
const result = await provider.mcp.removeServer(input);
results.push({ provider: provider.id, removed: result.removed });
} catch (error) {
results.push({
provider: provider.id,
removed: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
};

View File

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

View File

@@ -17,7 +17,6 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
@@ -233,42 +232,10 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
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) {

View File

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

View File

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

View File

@@ -341,7 +341,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 ? 5 : 4);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -356,8 +357,10 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
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({

View File

@@ -85,12 +85,6 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
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
);
@@ -130,10 +124,9 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
);
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
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'open-session-1',
'project-1',
@@ -144,11 +137,6 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
1_700_000_000_000,
1_700_000_004_000,
null,
10,
20,
7,
3,
2,
);
const userMessageData = JSON.stringify({
@@ -272,55 +260,6 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr
}
});
test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath);
sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1');
const synchronizer = new OpenCodeSessionSynchronizer();
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
assert.equal(sessionId, 'app-session-1');
assert.equal(sessionsDb.getAllSessions().length, 1);
assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1');
});
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await createOpenCodeDatabase(tempRoot, workspacePath);
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath);
const synchronizer = new OpenCodeSessionSynchronizer();
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
assert.equal(sessionId, 'app-session-race');
assert.equal(sessionsDb.getAllSessions().length, 1);
assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1');
});
});
} 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({
@@ -363,13 +302,12 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
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,
used: 35,
total: 35,
inputTokens: 10,
outputTokens: 20,
breakdown: {
input: 13,
output: 20,
},
cacheReadTokens: 3,
cacheCreationTokens: 2,
});
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });

View File

@@ -130,37 +130,6 @@ test('provider models are cached for the three-day ttl', async () => {
}
});
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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
});
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);
}
});

View File

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

View File

@@ -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] ??

View File

@@ -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;

View File

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

View File

@@ -18,39 +18,11 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
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 { createCompleteMessage, createNormalizedMessage } from './shared/utils.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
@@ -344,34 +316,27 @@ 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' }));
}
}
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
if (!runAborted) {
sendMessage(ws, createCompleteMessage({
// Send completion event
if (!terminalFailure) {
sendMessage(ws, createNormalizedMessage({
kind: 'complete',
actualSessionId: capturedSessionId || thread.id || sessionId || null,
sessionId: capturedSessionId || sessionId || null,
provider: 'codex'
}));
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
actualSessionId: capturedSessionId || thread.id || sessionId || null,
exitCode: terminalFailure ? 1 : 0,
}));
if (!terminalFailure) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: 'completed'
});
}
sessionName: sessionSummary,
stopReason: 'completed'
});
}
} catch (error) {
@@ -391,11 +356,6 @@ export async function queryCodex(command, options = {}, ws) {
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createCompleteMessage({
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
exitCode: 1,
}));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,

View File

@@ -1,14 +1,12 @@
import { spawn } from 'child_process';
import fsSync from 'node:fs';
import crossSpawn from 'cross-spawn';
import Database from 'better-sqlite3';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
import { createNormalizedMessage } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -22,66 +20,6 @@ function readOpenCodeSessionId(event) {
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;
@@ -92,9 +30,6 @@ async function spawnOpenCode(command, options = {}, ws) {
let stdoutLineBuffer = '';
let terminalNotificationSent = false;
let opencodeProcess = null;
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) {
@@ -194,10 +129,6 @@ async function spawnOpenCode(command, options = {}, ws) {
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
const args = ['run', '--format', 'json'];
// OpenCode's `run` command owns workspace selection through `--dir`.
// Relying on the child-process cwd alone is not enough on Linux, where
// the CLI can still resolve the session under the server install dir.
args.push('--dir', workingDir);
if (sessionId) {
args.push('--session', sessionId);
}
@@ -252,23 +183,13 @@ async function spawnOpenCode(command, options = {}, ws) {
stdoutLineBuffer = '';
}
const tokenBudget = readOpenCodeTokenUsage(finalSessionId);
if (tokenBudget) {
ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: finalSessionId,
provider: 'opencode',
}));
}
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code }));
}
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: code,
isNewSession: !sessionId && !!command,
sessionId: finalSessionId,
provider: 'opencode',
}));
if (code === 0) {
notifyTerminalState({ code });
@@ -308,10 +229,6 @@ async function spawnOpenCode(command, options = {}, ws) {
sessionId: finalSessionId,
provider: 'opencode',
}));
if (!completeSent && !opencodeProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
});
@@ -325,9 +242,6 @@ function abortOpenCodeSession(sessionId) {
return false;
}
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeOpenCodeProcesses.delete(sessionId);
return true;

View File

@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
@@ -12,11 +12,6 @@ const findEnvKey = (name) =>
async function createFakeOpenCodeExecutable(binDir) {
const scriptPath = path.join(binDir, 'opencode.js');
await writeFile(scriptPath, `
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
if (capturePath) {
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
}
const events = [
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
{ type: 'step_finish', sessionID: 'open-live-1' },
@@ -40,12 +35,10 @@ for (const event of events) {
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 argsCapturePath = path.join(tempRoot, 'opencode-args.json');
const pathKey = findEnvKey('PATH');
const pathExtKey = findEnvKey('PATHEXT');
const previousPath = process.env[pathKey];
const previousPathExt = process.env[pathExtKey];
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
const messages = [];
const writer = {
userId: null,
@@ -61,7 +54,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
try {
await createFakeOpenCodeExecutable(tempRoot);
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
if (process.platform === 'win32') {
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
? previousPathExt
@@ -85,11 +77,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
assert.equal(streamEnd?.sessionId, 'open-live-1');
assert.equal(complete?.sessionId, 'open-live-1');
assert.equal(messages.some((message) => message.kind === 'error'), false);
const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
assert.ok(Array.isArray(launchedArgs));
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
assert.equal(launchedArgs[4], tempRoot);
} finally {
if (previousPath === undefined) {
delete process.env[pathKey];
@@ -103,12 +90,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
process.env[pathExtKey] = previousPathExt;
}
if (previousArgsCapture === undefined) {
delete process.env.OPENCODE_ARGS_CAPTURE;
} else {
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
}
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -592,14 +592,12 @@ class ResponseCollector {
}
}
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
return {
inputTokens,
inputTokens: totalInput,
outputTokens: totalOutput,
cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation,
totalTokens: inputTokens + totalOutput
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
};
}
}
@@ -646,7 +644,7 @@ class ResponseCollector {
*
* @param {string} model - (Optional) Model identifier for providers.
*
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',

View File

@@ -174,7 +174,7 @@ const builtInCommands = [
},
{
name: "/cost",
description: "Display token usage information",
description: "Display token usage and cost information",
namespace: "builtin",
metadata: { type: "builtin" },
},
@@ -258,7 +258,7 @@ Custom commands can be created in:
const catalog = (await providerModelsService.getProviderModels(provider)).models;
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
const reportedUsed =
const used =
Number(
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
) || 0;
@@ -266,50 +266,53 @@ Custom commands can be created in:
Number(
tokenUsage.total ??
tokenUsage.contextWindow ??
parseInt(process.env.CONTEXT_WINDOW || "160000", 10),
) || 160000;
const percentage =
total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
const inputTokensRaw =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.promptTokens ??
0,
) || 0;
const normalizedInputValue =
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens;
const directInputTokens =
Number(
normalizedInputValue ??
tokenUsage.input_tokens ??
0
) || 0;
const cacheReadTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cache_read_input_tokens ??
tokenUsage.cacheReadInputTokens ??
0,
) || 0;
const cacheCreationTokens =
Number(
tokenUsage.cacheCreationTokens ??
tokenUsage.cache_creation_input_tokens ??
tokenUsage.cacheCreationInputTokens ??
0,
) || 0;
const inputTokens = normalizedInputValue == null
? directInputTokens + cacheReadTokens + cacheCreationTokens
: directInputTokens;
const outputTokens =
Number(
tokenUsage.outputTokens ??
tokenUsage.output ??
tokenUsage.output_tokens ??
tokenUsage.cumulativeOutputTokens ??
tokenUsage.breakdown?.output ??
tokenUsage.completionTokens ??
0,
) || 0;
const computedUsed = inputTokens + outputTokens;
const hasTokenBreakdown = computedUsed > 0;
const used = Math.max(reportedUsed, computedUsed);
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",
@@ -318,15 +321,18 @@ Custom commands can be created in:
tokenUsage: {
used,
total,
percentage,
},
tokenBreakdown: {
input: inputTokens,
output: outputTokens,
cache: cacheTokens,
},
cost: {
input: inputCost.toFixed(4),
output: outputCost.toFixed(4),
total: totalCost.toFixed(4),
},
...(hasTokenBreakdown
? {
tokenBreakdown: {
input: inputTokens,
output: outputTokens,
},
}
: {}),
provider,
model,
},

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
/**
* One selectable model row in a provider model catalog.
* One selectable model row (matches the documentation `public/modelConstants.js` option shape).
*/
export type ProviderModelOption = {
value: string;
@@ -175,30 +175,6 @@ export type MessageKind =
| 'interactive_prompt'
| 'task_notification';
/**
* Event kinds added by the chat gateway layer on top of provider message kinds.
*
* These are app-level realtime events (subscription acks, sidebar deltas,
* project loading progress, protocol failures) that are not produced by any
* provider adapter. Together with `MessageKind` they form the complete set of
* `kind` values a websocket client can receive, so the frontend only ever
* needs one kind-based switch.
*/
export type GatewayEventKind =
| 'chat_subscribed'
| 'session_upserted'
| 'loading_progress'
| 'protocol_error';
/**
* Complete set of `kind` values emitted to websocket clients.
*
* Every server-to-client websocket frame carries a `kind` from this union.
* Provider runtimes emit `MessageKind` values; gateway services emit
* `GatewayEventKind` values.
*/
export type ServerEventKind = MessageKind | GatewayEventKind;
/**
* Provider-neutral message envelope used in REST responses and realtime channels.
*
@@ -211,13 +187,6 @@ export type NormalizedMessage = {
timestamp: string;
provider: LLMProvider;
kind: MessageKind;
/**
* Monotonic per-run sequence number assigned by the chat run registry when a
* live event is forwarded to the websocket. History messages loaded over
* REST do not carry it. Clients use it with `chat.subscribe` to replay only
* the live events they missed across websocket reconnects.
*/
seq?: number;
role?: 'user' | 'assistant';
content?: string;
/**
@@ -268,18 +237,11 @@ export type NormalizedMessage = {
*
* Consumers should pass provider-specific lookup hints (`projectPath`) only
* when the selected provider requires them.
*
* `providerSessionId` is the provider-native session id from the sessions
* index (transcript file name / provider database key). Provider adapters
* must use it — never the app-facing session id they were called with — when
* matching transcript rows on disk, because app-created sessions use an
* app-allocated id that the provider has never seen.
*/
export type FetchHistoryOptions = {
projectPath?: string;
limit?: number | null;
offset?: number;
providerSessionId?: string;
};
/**

View File

@@ -346,84 +346,6 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali
};
}
/**
* Build the unified terminal `complete` lifecycle message.
*
* Contract: every provider run ends with exactly one `complete` (the
* abort-session handler emits it on behalf of cancelled runs, so aborted runs
* must NOT emit their own). The frontend treats `complete` as the only
* terminal signal and never needs provider-specific handling:
*
* - `sessionId` — the id the client knows this run by ('' if never discovered)
* - `actualSessionId` — canonical id after the run; equals `sessionId` unless
* the provider rewrote it mid-run
* - `exitCode` — 0 on success; a missing/null code (e.g. killed process)
* is reported as failure
* - `success` — exitCode === 0 and not aborted
* - `aborted` — run was cancelled by the user
*/
export function createCompleteMessage(opts: {
provider: NormalizedMessage['provider'];
sessionId?: string | null;
actualSessionId?: string | null;
exitCode?: number | null;
aborted?: boolean;
}): NormalizedMessage {
const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1;
const aborted = Boolean(opts.aborted);
return createNormalizedMessage({
kind: 'complete',
provider: opts.provider,
sessionId: opts.sessionId || null,
actualSessionId: opts.actualSessionId || opts.sessionId || null,
exitCode,
success: exitCode === 0 && !aborted,
aborted,
});
}
// ---------------------------
//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------
/**
* Slices one page from the END of a chronologically ordered message list.
*
* This is the single pagination contract for conversation history across all
* providers: `offset = 0` returns the most recent `limit` items, increasing
* offsets walk backwards in time (for "scroll up to load older" UIs), and a
* `null` limit returns everything. Items must already be sorted oldest-first;
* the returned page preserves that order.
*
* Every provider history reader must use this helper instead of slicing
* manually so `offset`/`limit` query params behave identically regardless of
* which provider produced the session.
*/
export function sliceTailPage<T>(
items: T[],
limit: number | null,
offset: number,
): { page: T[]; hasMore: boolean } {
const total = items.length;
const normalizedOffset = Math.max(0, offset);
if (limit === null) {
// A null limit returns the full list; offset still trims newest entries
// so "everything before the page I already have" stays expressible.
const end = Math.max(0, total - normalizedOffset);
return {
page: items.slice(0, end),
hasMore: false,
};
}
const end = Math.max(0, total - normalizedOffset);
const start = Math.max(0, end - Math.max(0, limit));
return {
page: items.slice(start, end),
hasMore: start > 0,
};
}
// ---------------------------
//----------------- MCP CONFIG PARSING UTILITIES ------------
/**

View File

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

View File

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

View File

@@ -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 />} />

View File

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

View File

@@ -1 +0,0 @@
export { default as BrowserUsePanel } from './view/BrowserUsePanel';

View File

@@ -1,536 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Bot,
Clock3,
Download,
Expand,
ExternalLink,
Loader2,
MonitorPlay,
RefreshCw,
Settings,
Square,
Trash2,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types';
type BrowserUseStatus = {
enabled: boolean;
available: boolean;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
installInProgress: boolean;
sessionCount: number;
message: string;
};
type BrowserUseSession = {
id: string;
status: 'ready' | 'stopped' | 'unavailable';
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
createdBy: 'agent';
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type BrowserUsePanelProps = {
isVisible: boolean;
onShowSettings?: (tab?: SettingsMainTab) => void;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
function formatRelativeTime(value: string | null): string {
if (!value) return 'Never';
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return 'Unknown';
const elapsedSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (elapsedSeconds < 10) return 'Just now';
if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`;
const elapsedMinutes = Math.round(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
const elapsedHours = Math.round(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h ago`;
return `${Math.round(elapsedHours / 24)}d ago`;
}
function getDomain(url: string | null): string {
if (!url) return 'No page loaded';
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function formatAction(action: string | null): string {
if (!action) return 'Waiting';
return action.replace(/_/g, ' ').replace(/:/g, ': ');
}
function getStatusTone(status: BrowserUseSession['status']): string {
if (status === 'ready') {
return 'border-primary/30 bg-primary/5 text-foreground';
}
if (status === 'stopped') {
return 'border-border bg-muted text-muted-foreground';
}
return 'border-border bg-background text-muted-foreground';
}
function getRuntimeTone(status: BrowserUseStatus | null, installing: boolean): string {
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
return 'border-border bg-background text-muted-foreground';
}
function getStatusDot(status: BrowserUseSession['status']): string {
if (status === 'ready') return 'bg-primary';
if (status === 'stopped') return 'bg-muted-foreground/50';
return 'bg-border';
}
const PROMPTS = [
'Use Browser to inspect the checkout flow and report any broken UI states.',
'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
];
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) {
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
[selectedSessionId, sessions],
);
const activeSessions = sessions.filter((session) => session.status === 'ready');
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled));
const runtimeLabel = !status?.enabled
? 'Disabled'
: status.available
? 'Ready'
: status.installInProgress || isInstalling
? 'Installing'
: 'Setup required';
const cursorStyle = selectedSession?.cursor && selectedSession.viewport
? {
left: `${(selectedSession.cursor.x / selectedSession.viewport.width) * 100}%`,
top: `${(selectedSession.cursor.y / selectedSession.viewport.height) * 100}%`,
}
: null;
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/status'),
authenticatedFetch('/api/browser-use/sessions'),
]);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
const nextSessions = sessionsData.data.sessions;
setStatus(statusData.data);
setSessions(nextSessions);
setSelectedSessionId((current) => (
current && nextSessions.some((session) => session.id === current)
? current
: nextSessions[0]?.id || null
));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Browser');
} finally {
setIsRefreshing(false);
}
}, []);
useEffect(() => {
if (!isVisible) return;
void refresh();
}, [isVisible, refresh]);
const runAction = useCallback(async (action: () => Promise<void>) => {
setIsBusy(true);
setError(null);
try {
await action();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Browser action failed');
} finally {
setIsBusy(false);
}
}, [refresh]);
const stopSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
await readJson(response);
});
const deleteSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
await readJson(response);
setIsFullscreen(false);
});
const installBrowserBinaries = () => runAction(async () => {
setIsInstalling(true);
try {
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
await readJson(response);
} finally {
setIsInstalling(false);
}
});
const renderSessionItem = (session: BrowserUseSession) => {
const isSelected = selectedSession?.id === session.id;
return (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'group w-full rounded-md border px-3 py-2.5 text-left transition-colors',
isSelected
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<div className="truncate text-sm font-medium">{session.title || getDomain(session.url)}</div>
</div>
<div className="mt-1 truncate pl-3.5 text-xs text-muted-foreground">{getDomain(session.url)}</div>
</div>
<Badge variant="outline" className="shrink-0 border-border bg-background text-[10px] text-muted-foreground">
{session.status}
</Badge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Clock3 className="h-3 w-3" />
<span>{formatRelativeTime(session.updatedAt)}</span>
<span className="truncate">- {formatAction(session.lastAction)}</span>
</div>
</button>
);
};
const renderEmptyState = () => (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="w-full max-w-2xl rounded-md border border-border bg-card/40 p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<MonitorPlay className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{status?.enabled ? 'No browser sessions yet' : 'Browser is disabled'}
</div>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
{status?.enabled
? 'Agent browser sessions appear here while an AI task is using Browser.'
: 'Enable Browser in settings to let agents open monitored browser sessions.'}
</p>
</div>
</div>
{needsBrowserBinaries && (
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
<div className="text-sm font-medium text-foreground">Runtime setup required</div>
<p className="mt-1 text-sm text-muted-foreground">{status?.message}</p>
<Button
type="button"
size="sm"
className="mt-3"
onClick={installBrowserBinaries}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing...' : 'Install Runtime'}
</Button>
</div>
)}
<div className="mt-5 grid gap-2 sm:grid-cols-2">
{PROMPTS.map((prompt) => (
<div key={prompt} className="rounded-md border border-border/70 bg-background/70 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Prompt
</div>
<p className="text-sm leading-6 text-foreground">{prompt}</p>
</div>
))}
</div>
</div>
</div>
);
const renderBrowserSurface = (fullscreen = false) => (
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
{selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full">
<img
src={selectedSession.screenshotDataUrl}
alt="Browser session screenshot"
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[72vh] w-auto max-w-full object-contain'}
/>
{cursorStyle && (
<div
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-primary/80 shadow-[0_0_0_6px_hsl(var(--primary)/0.18)]"
style={cursorStyle}
>
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
</div>
)}
</div>
) : (
<div className="px-6 text-center">
<MonitorPlay className="mx-auto h-9 w-9 text-neutral-500" />
<div className="mt-3 text-sm font-medium text-neutral-100">{selectedSession?.message || 'Waiting for screenshot'}</div>
<p className="mt-1 text-xs text-neutral-400">The next agent browser snapshot will render here.</p>
</div>
)}
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<MonitorPlay className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Browser</h3>
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p>
</div>
<div className="flex items-center gap-1.5">
{onShowSettings && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onShowSettings('browser')}
title="Open Browser settings"
aria-label="Open Browser settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => void refresh()}
disabled={isRefreshing || isBusy}
title="Refresh browser sessions"
aria-label="Refresh browser sessions"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
{error && (
<div className="border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{sessions.length > 0 && (
<div className="border-b border-border/60 bg-muted/20 px-3 py-2 lg:hidden">
<div className="flex gap-2 overflow-x-auto">
{sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={cn(
'flex min-w-[180px] items-center gap-2 rounded-md border px-2.5 py-2 text-left',
selectedSession?.id === session.id
? 'border-primary/40 bg-primary/5'
: 'border-border bg-background',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusDot(session.status))} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{session.title || getDomain(session.url)}
</span>
</button>
))}
</div>
</div>
)}
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<main className="flex min-h-0 flex-col overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
<div className="min-w-0 truncate">
{activeSessions.length} active
<span className="px-1.5">/</span>
{sessions.length} total
</div>
<div className="min-w-0 truncate">
Updated {formatRelativeTime(selectedSession?.updatedAt || null)}
</div>
</div>
{sessions.length === 0 ? (
renderEmptyState()
) : (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<Badge variant="outline" className={selectedSession ? cn('text-[10px]', getStatusTone(selectedSession.status)) : 'text-[10px]'}>
{selectedSession?.status || 'empty'}
</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{selectedSession?.title || getDomain(selectedSession?.url || null)}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
</div>
</div>
<div className="hidden text-xs text-muted-foreground md:block">
{formatAction(selectedSession?.lastAction || null)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
<Expand className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session">
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{renderBrowserSurface()}
</div>
</div>
)}
</main>
<aside className="hidden min-h-0 flex-col border-l border-border/60 bg-background lg:flex">
<div className="border-b border-border/60 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-foreground">Sessions</div>
<div className="mt-0.5 text-xs text-muted-foreground">{sessions.length} total</div>
</div>
<Badge variant="outline" className="text-[10px]">{activeSessions.length} active</Badge>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{sessions.length > 0 ? (
<div className="space-y-2">{sessions.map(renderSessionItem)}</div>
) : (
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
No agent browser sessions.
</div>
)}
</div>
<div className="border-t border-border/60 p-3">
<div className="rounded-md border border-border/70 bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
Selected
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3">
<span>Status</span>
<span className="font-medium text-foreground">{selectedSession?.status || 'None'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Last action</span>
<span className="truncate font-medium text-foreground">{formatAction(selectedSession?.lastAction || null)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Profile</span>
<span className="truncate font-medium text-foreground">{selectedSession?.profileName || 'Temporary'}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
</aside>
</div>
{isFullscreen && selectedSession && (
<div className="fixed inset-0 z-50 bg-black/90 p-6">
<div className="flex h-full flex-col rounded-md border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
<div className="min-w-0 truncate">{selectedSession.title || selectedSession.url || 'Browser session'}</div>
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
<X className="h-4 w-4" />
Close
</Button>
</div>
{renderBrowserSurface(true)}
</div>
</div>
)}
</div>
);
}

View File

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

View File

@@ -12,14 +12,13 @@ import type {
import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import type {
ChatMessage,
PendingPermissionRequest,
PermissionMode,
SessionEstablishedContext,
} from '../types/types';
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting';
@@ -27,6 +26,10 @@ import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = {
startedAt: number;
};
interface UseChatComposerStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
@@ -44,20 +47,17 @@ interface UseChatComposerStateArgs {
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionProcessing?: MarkSessionProcessing;
/**
* Invoked with the freshly allocated session id when the user sends the
* first message of a brand-new conversation. The backend allocates the id
* via POST /api/providers/sessions BEFORE the websocket send, so the id is
* stable for the conversation's whole lifetime — the consumer navigates to
* /session/:id and records it as the current session.
*/
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void;
addMessage: (msg: ChatMessage) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
}
@@ -97,10 +97,17 @@ export type CostCommandData = {
tokenUsage?: {
used?: number;
total?: number;
percentage?: number;
};
cost?: {
input?: string;
output?: string;
total?: string;
};
tokenBreakdown?: {
input?: number;
output?: number;
cache?: number;
};
provider?: string;
model?: string;
@@ -178,13 +185,17 @@ export function useChatComposerState({
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onSessionEstablished,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
addMessage,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
}: UseChatComposerStateArgs) {
@@ -200,6 +211,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 [thinkingMode, setThinkingMode] = useState('none');
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -308,7 +320,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;
}
@@ -365,10 +377,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);
}
@@ -399,19 +409,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,
@@ -572,7 +569,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) {
@@ -606,54 +607,8 @@ export function useChatComposerState({
}
}
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
// The conversation always has a stable backend-allocated session id
// BEFORE the first websocket send: brand-new chats allocate one here
// via the session gateway. There is no client-visible session-id
// handoff later — this id stays valid for the conversation's lifetime.
let targetSessionId = selectedSession?.id || currentSessionId || null;
if (!targetSessionId) {
try {
const response = await authenticatedFetch('/api/providers/sessions', {
method: 'POST',
body: JSON.stringify({
provider,
projectPath: resolvedProjectPath,
}),
});
if (!response.ok) {
throw new Error(`Failed to create session (${response.status})`);
}
const body = await response.json();
targetSessionId = body?.data?.sessionId || null;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Session creation failed:', error);
addMessage({
type: 'error',
content: `Failed to start a new session: ${message}`,
timestamp: new Date(),
});
return;
}
if (!targetSessionId) {
addMessage({
type: 'error',
content: 'Failed to start a new session: no session id returned.',
timestamp: new Date(),
});
return;
}
onSessionEstablished?.(targetSessionId, {
provider,
project: selectedProject,
summary: sessionSummary,
});
}
const effectiveSessionId =
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
const userMessage: ChatMessage = {
type: 'user',
@@ -663,17 +618,27 @@ export function useChatComposerState({
};
addMessage(userMessage);
// Mark this request as processing in the per-session activity map (the
// single source of truth the indicator derives from). The id is always
// concrete at this point — no pending placeholder exists anymore.
onSessionProcessing?.(targetSessionId, {
statusText: null,
canInterrupt: true,
setIsLoading(true); // Processing banner starts
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
setIsUserScrolledUp(false);
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 (effectiveSessionId) {
onSessionActive?.(effectiveSessionId);
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => {
try {
const settingsKey =
@@ -702,35 +667,87 @@ export function useChatComposerState({
};
const toolsSettings = getToolsSettings();
const model =
provider === 'cursor'
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
// session row; `options` only carries composer-level preferences.
sendMessage({
type: 'chat.send',
sessionId: targetSessionId,
content: messageContent,
options: {
model,
// Codex has no plan mode; downgrade rather than sending an
// unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
images: uploadedImages,
},
});
if (provider === 'cursor') {
sendMessage({
type: 'cursor-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: cursorModel,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
toolsSettings,
},
});
} else if (provider === 'codex') {
sendMessage({
type: 'codex-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: codexModel,
sessionSummary,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
sessionSummary,
permissionMode,
toolsSettings,
},
});
} else 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',
command: messageContent,
options: {
projectPath: resolvedProjectPath,
cwd: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
toolsSettings,
permissionMode,
model: claudeModel,
sessionSummary,
images: uploadedImages,
},
});
}
setInput('');
inputValueRef.current = '';
@@ -739,6 +756,7 @@ export function useChatComposerState({
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
@@ -757,17 +775,22 @@ export function useChatComposerState({
geminiModel,
opencodeModel,
isLoading,
onSessionActive,
onSessionProcessing,
onSessionEstablished,
pendingViewSessionRef,
permissionMode,
provider,
resetCommandMenuState,
scrollToBottom,
selectedProject,
sendMessage,
setCanAbortSession,
addMessage,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
slashCommands,
thinkingMode,
],
);
@@ -921,19 +944,29 @@ export function useChatComposerState({
return;
}
const targetSessionId = selectedSession?.id || currentSessionId || null;
const cursorSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
const candidateSessionIds = [
currentSessionId,
provider === 'cursor' ? cursorSessionId : null,
selectedSession?.id || null,
];
const targetSessionId =
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
if (!targetSessionId) {
console.warn('Abort requested but no session ID is available.');
console.warn('Abort requested but no concrete session ID is available yet.');
return;
}
// The backend resolves the provider from the session row, so no provider
// field is needed here.
sendMessage({
type: 'chat.abort',
type: 'abort-session',
sessionId: targetSessionId,
provider,
});
}, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]);
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => {
@@ -958,7 +991,7 @@ export function useChatComposerState({
validIds.forEach((requestId) => {
sendMessage({
type: 'chat.permission-response',
type: 'claude-permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,
@@ -967,11 +1000,15 @@ export function useChatComposerState({
});
});
setPendingPermissionRequests((previous) =>
previous.filter((request) => !validIds.includes(request.requestId)),
);
setPendingPermissionRequests((previous) => {
const next = previous.filter((request) => !validIds.includes(request.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
},
[sendMessage, setPendingPermissionRequests],
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const [isInputFocused, setIsInputFocused] = useState(false);
@@ -990,6 +1027,8 @@ export function useChatComposerState({
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -1027,6 +1066,5 @@ export function useChatComposerState({
isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
};
}

View File

@@ -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;

View File

@@ -17,35 +17,17 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
opencode: 'anthropic/claude-sonnet-4-5',
};
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
* source of truth; this mirror exists so the composer renders sensibly on
* first paint and when the capabilities request fails.
*/
const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
};
type ProviderCapabilities = {
provider: LLMProvider;
permissionModes: string[];
defaultPermissionMode: string;
supportsImages: boolean;
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
};
type ProviderCapabilitiesApiResponse = {
success?: boolean;
data?: {
providers?: ProviderCapabilities[];
};
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
if (provider === 'codex') {
return ['default', 'acceptEdits', 'bypassPermissions'];
}
if (provider === 'claude') {
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
}
if (provider === 'opencode') {
return ['default'];
}
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
};
interface UseChatProviderStateArgs {
@@ -94,17 +76,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
* mode picker (and is the extension point for future per-provider UI
* differences) so the frontend stays free of hardcoded provider branching.
* Null until `/api/providers/capabilities` resolves; the static fallback
* map covers that window.
*/
const [providerCapabilities, setProviderCapabilities] = useState<
Partial<Record<LLMProvider, ProviderCapabilities>> | null
>(null);
const [providerModelCatalog, setProviderModelCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsDefinition>>
>({});
@@ -210,41 +181,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
void loadProviderModels();
}, [loadProviderModels]);
useEffect(() => {
let cancelled = false;
const loadCapabilities = async () => {
try {
const response = await authenticatedFetch('/api/providers/capabilities');
const body = (await response.json()) as ProviderCapabilitiesApiResponse;
if (cancelled || !body.success || !Array.isArray(body.data?.providers)) {
return;
}
const byProvider: Partial<Record<LLMProvider, ProviderCapabilities>> = {};
for (const capabilities of body.data.providers) {
byProvider[capabilities.provider] = capabilities;
}
setProviderCapabilities(byProvider);
} catch (error) {
console.error('Error loading provider capabilities:', error);
}
};
void loadCapabilities();
return () => {
cancelled = true;
};
}, []);
const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => {
const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes;
if (capabilityModes && capabilityModes.length > 0) {
return capabilityModes as PermissionMode[];
}
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
@@ -333,7 +269,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
}, [selectedSession?.id, provider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -391,7 +327,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
if (selectedSession?.id) {
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
}
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
}, [permissionMode, provider, selectedSession?.id]);
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,

View File

@@ -1,34 +1,71 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest } from '../types/types';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = {
startedAt: number;
};
type LatestChatMessage = {
type?: string;
kind?: string;
data?: any;
message?: any;
delta?: string;
sessionId?: string;
session_id?: string;
requestId?: string;
toolName?: string;
input?: unknown;
context?: unknown;
error?: string;
tool?: any;
toolId?: string;
result?: any;
exitCode?: number;
isProcessing?: boolean;
actualSessionId?: string;
event?: string;
status?: any;
isNewSession?: boolean;
resultText?: string;
isError?: boolean;
success?: boolean;
reason?: string;
provider?: string;
content?: string;
text?: string;
tokens?: number;
canInterrupt?: boolean;
tokenBudget?: unknown;
newSessionId?: string;
aborted?: boolean;
[key: string]: any;
};
interface UseChatRealtimeHandlersArgs {
subscribe: (listener: (event: ServerEvent) => void) => () => void;
latestMessage: LatestChatMessage | null;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
/**
* Highest live `seq` observed per session. Essential for reconnect catch-up:
* `chat.subscribe` sends this value as `lastSeq` so the server replays only
* the events this client actually missed. Written here on every sequenced
* frame; read wherever a `chat.subscribe` is sent (session open, reconnect).
*/
lastSeqRef: MutableRefObject<Map<string, number>>;
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
onSessionProcessing?: MarkSessionProcessing;
onSessionIdle?: MarkSessionIdle;
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;
onWebSocketReconnect?: () => void;
sessionStore: SessionStore;
}
@@ -37,263 +74,321 @@ interface UseChatRealtimeHandlersArgs {
/* Hook */
/* ------------------------------------------------------------------ */
/**
* Routes server events into the session store and processing-state map.
*
* This is intentionally a thin reducer over the unified `kind`-based
* protocol: every frame is keyed by the stable app session id, so there is
* no session-id handoff, no provider branching, and no navigation here.
* Sidebar events (`session_upserted`, `loading_progress`) are handled by
* `useProjectsState`, not in this hook.
*/
export function useChatRealtimeHandlers({
subscribe,
latestMessage,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
return;
}
if (!latestMessage) return;
if (lastProcessedMessageRef.current === latestMessage) return;
lastProcessedMessageRef.current = latestMessage;
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
const activeViewSessionId =
selectedSession?.id || currentSessionId || null;
// Record replay progress for every sequenced live event.
if (sid && typeof msg.seq === 'number') {
const known = lastSeqRef.current.get(sid) ?? 0;
if (msg.seq > known) {
lastSeqRef.current.set(sid, msg.seq);
}
}
/* ---------------------------------------------------------------- */
/* Legacy messages (no `kind` field) — handle and return */
/* ---------------------------------------------------------------- */
switch (msg.kind) {
case 'websocket_reconnected':
const msg = latestMessage as any;
if (!msg.kind) {
const messageType = String(msg.type || '');
switch (messageType) {
case 'websocket-reconnected':
onWebSocketReconnect?.();
return;
case 'chat_subscribed': {
// Ack for chat.subscribe: authoritative processing state plus any
// pending tool-permission prompts for the run.
if (!sid) return;
case 'pending-permissions-response': {
const permSessionId = msg.sessionId;
const isCurrentPermSession =
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
if (permSessionId && !isCurrentPermSession) return;
setPendingPermissionRequests(msg.data || []);
return;
}
case 'session-status': {
const statusSessionId = msg.sessionId;
if (!statusSessionId) return;
const status = msg.status;
if (status) {
const statusInfo = {
text: status.text || 'Working...',
tokens: status.tokens || 0,
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
};
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
return;
}
// Legacy isProcessing format from check-session-status
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing) {
onSessionProcessing?.(sid);
} else {
// Idle ack: ignore it if a newer request started after the
// subscribe was sent — the ack describes the older state.
onSessionIdle?.(sid, {
ifStartedBefore: statusCheckSentAtRef.current.get(sid),
});
onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId);
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
return;
}
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
return;
}
case 'protocol_error': {
console.error('[Chat] Protocol error:', msg.code, msg.error);
if (sid) {
// Surface the failure in the conversation and stop the spinner —
// the run never started (or was rejected), so no `complete` follows.
onSessionIdle?.(sid);
sessionStore.appendRealtime(sid, {
id: `protocol_error_${Date.now()}`,
sessionId: sid,
timestamp: new Date().toISOString(),
provider,
kind: 'error',
content: String(msg.error || 'Request failed'),
} as NormalizedMessage);
}
return;
}
// Sidebar/global events — owned by useProjectsState.
case 'session_upserted':
case 'loading_progress':
return;
default:
break;
// Unknown legacy message type — ignore
return;
}
}
/* ---------------------------------------------------------------- */
/* NormalizedMessage handling (has `kind` field) */
/* ---------------------------------------------------------------- */
const sid = msg.sessionId || activeViewSessionId;
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = msg.content || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
return;
}
if (msg.kind === 'stream_end') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
}
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'session_created'
&& msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'session_created': {
const newSessionId = msg.newSessionId;
if (!newSessionId) break;
// We no longer synthesize client-side placeholder IDs. Until the provider
// announces `session_created`, the active id is expected to be null.
if (!currentSessionId) {
console.log('Session created with ID:', newSessionId);
console.log('Existing session ID:', currentSessionId);
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;
}
/* -------------------------------------------------------------- */
/* Provider NormalizedMessage handling */
/* -------------------------------------------------------------- */
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = (msg.content as string) || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
}
return;
}
if (msg.kind === 'stream_end') {
case 'complete': {
// Flush any remaining streaming state
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
setPendingPermissionRequests([]);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
// Handle aborted case
if (msg.aborted) {
// Abort was requested — the complete event confirms it
// No special UI action needed beyond clearing loading state above
// The backend already sent any abort-related messages
break;
}
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
: null;
const isVisibleSession =
Boolean(
sid
&& sid === activeViewSessionId,
);
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
}
if (isVisibleSession) {
onNavigateToSession?.(actualSessionId, { replace: true });
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
break;
}
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
case 'error': {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
onSessionInactive?.(sid);
onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
break;
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'complete': {
// Flush any remaining streaming state
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
// `complete` is the unified terminal event — every provider run ends
// with exactly one, regardless of success, failure, or abort. The
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
if (sid === activeViewSessionId) {
setPendingPermissionRequests([]);
}
if (msg.aborted) {
// Abort was requested — the complete event confirms it. No
// further UI action is needed beyond clearing the entry above.
break;
}
// Celebrate only successful runs (failed runs end with success: false).
if (msg.success !== false) {
showCompletionTitleIndicator();
void playChatCompletionSound();
}
// The session id is stable for the whole conversation (allocated
// before the first send), so the only follow-up is syncing the
// viewed conversation with the now-persisted transcript.
if (sid && sid === activeViewSessionId) {
void sessionStore.refreshFromServer(sid);
}
break;
}
// 'error' is an informational message row, not a terminal event —
// providers emit it for mid-run stderr output too. Run teardown is
// always signalled by the unified 'complete' that follows.
case 'permission_request': {
if (!msg.requestId) break;
if (sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
}
if (sid) {
onSessionProcessing?.(sid);
}
break;
}
case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text && sid) {
onSessionProcessing?.(sid, {
statusText: msg.text as string,
canInterrupt: msg.canInterrupt !== false,
});
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
case 'permission_request': {
if (!msg.requestId) break;
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId,
toolName: msg.toolName || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
break;
}
};
return subscribe(handleEvent);
case 'permission_cancelled': {
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
setClaudeStatus({
text: msg.text,
tokens: msg.tokens || 0,
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
});
setIsLoading(true);
setCanAbortSession(msg.canInterrupt !== false);
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
}
}, [
subscribe,
latestMessage,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
paletteOps,
]);
}

View File

@@ -2,10 +2,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage } from '../types/types';
import type { ChatMessage, Provider } from '../types/types';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages';
@@ -13,6 +12,10 @@ import { normalizedToChatMessages } from './useChatMessages';
const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100;
type PendingViewSession = {
startedAt: number;
};
interface UseChatSessionStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
@@ -21,13 +24,9 @@ interface UseChatSessionStateArgs {
autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: SessionActivityMap;
onSessionIdle?: MarkSessionIdle;
processingSessions?: Set<string>;
resetStreamingState: () => void;
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
/** Highest live seq observed per session; sent as `lastSeq` on subscribe. */
lastSeqRef: MutableRefObject<Map<string, number>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
sessionStore: SessionStore;
}
@@ -100,20 +99,21 @@ export function useChatSessionState({
externalMessageUpdate,
newSessionTrigger,
processingSessions,
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
lastSeqRef,
pendingViewSessionRef,
sessionStore,
}: UseChatSessionStateArgs) {
const [isLoading, setIsLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
const [hasMoreMessages, setHasMoreMessages] = useState(false);
const [totalMessages, setTotalMessages] = useState(0);
const [canAbortSession, setCanAbortSession] = useState(false);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
@@ -170,8 +170,13 @@ export function useChatSessionState({
* - No coupling to unrelated external update signals.
*/
resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null);
setPendingUserMessage(null);
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
@@ -199,30 +204,13 @@ export function useChatSessionState({
clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = null;
}
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
/* ---------------------------------------------------------------- */
/* Derive processing state for the viewed session */
/* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
// The activity indicator always reflects the latest status of the session
// being viewed — never stale local UI state from the last time it was
// open. Session ids are concrete before any send, so no pending
// placeholder entry exists anymore.
const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null;
const isProcessing = sessionActivity !== null;
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
// Ref mirror so effects can read the latest map without re-running on
// every activity transition.
const processingSessionsRef = useRef(processingSessions);
processingSessionsRef.current = processingSessions;
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
/* ---------------------------------------------------------------- */
/* Derive chatMessages from the store */
/* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
@@ -328,12 +316,18 @@ export function useChatSessionState({
if (allMessagesLoadedRef.current) return false;
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
const sessionProvider = selectedSession.__provider || 'claude';
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
try {
const slot = await sessionStore.fetchMore(selectedSession.id, {
provider: sessionProvider as LLMProvider,
// DB-assigned projectId replaces the legacy folder-derived name.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE,
});
if (!slot || slot.serverMessages.length === 0) return false;
@@ -389,61 +383,30 @@ export function useChatSessionState({
setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]);
// Initial scroll to bottom — robust to lazy content reflow.
// The previous implementation fired one scrollToBottom() at +200ms and
// cleared the pending flag. When markdown blocks, code highlighting, or
// images finished rendering after that window, scrollHeight grew but
// nothing re-anchored the viewport, leaving the chat tab visually
// "scrolled way up" with the latest assistant message off-screen.
//
// This version re-scrolls every animation frame while scrollHeight is
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
// frames. Cancels cleanly on session change via the pending flag.
// Initial scroll to bottom
useEffect(() => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
const container = scrollContainerRef.current;
let frame = 0;
let lastHeight = 0;
let stableCount = 0;
let rafId = 0;
const tick = () => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
container.scrollTop = container.scrollHeight;
if (container.scrollHeight === lastHeight) {
stableCount++;
} else {
stableCount = 0;
lastHeight = container.scrollHeight;
}
frame++;
if (stableCount < 3 && frame < 60) {
rafId = requestAnimationFrame(tick);
} else {
pendingInitialScrollRef.current = false;
}
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId) cancelAnimationFrame(rafId);
};
pendingInitialScrollRef.current = false;
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
// Main session loading effect — store-based
useEffect(() => {
if (!selectedSession || !selectedProject) {
// A freshly created session can be mid-run before the router has a
// canonical selectedSession (the URL effect synthesizes one on the
// next render). Keep the active view intact instead of wiping it.
if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) {
// 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);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
@@ -452,33 +415,20 @@ export function useChatSessionState({
return;
}
const selectedSessionId = selectedSession.id;
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
const subscribeToSelectedSession = () => {
if (!ws) {
return;
}
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSessionId,
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
}],
});
};
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
// Skip if already loaded and fresh
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
subscribeToSelectedSession();
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
return;
}
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
}
// Reset pagination/scroll state
@@ -497,22 +447,27 @@ export function useChatSessionState({
if (sessionChanged) {
setTokenBudget(null);
setIsLoading(false);
}
setCurrentSessionId(selectedSessionId);
setCurrentSessionId(selectedSession.id);
if (provider === 'cursor') {
sessionStorage.setItem('cursorSessionId', selectedSession.id);
}
// Subscribe to the session's live run (if any): the ack reconciles the
// processing indicator, re-attaches a mid-flight stream to this socket,
// and replays any live events missed since `lastSeq`. Recording the send
// time lets the ack handler discard idle acks that a newer request has
// since outdated.
subscribeToSelectedSession();
// Check session status
if (ws) {
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
}
lastLoadedSessionKeyRef.current = sessionKey;
// Fetch from server → store updates → chatMessages re-derives automatically
setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSessionId, {
sessionStore.fetchFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE,
offset: 0,
}).then(slot => {
@@ -526,12 +481,11 @@ export function useChatSessionState({
setIsLoadingSessionMessages(false);
});
}, [
pendingViewSessionRef,
resetStreamingState,
selectedProject,
selectedSession?.id,
sendMessage,
statusCheckSentAtRef,
lastSeqRef,
ws,
sessionStore,
]);
@@ -542,9 +496,15 @@ export function useChatSessionState({
const reloadExternalMessages = async () => {
try {
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
// Skip store refresh during active streaming
if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id);
if (!isLoading) {
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
if (Boolean(autoScrollToBottom) && isNearBottom()) {
setTimeout(() => scrollToBottom(), 200);
@@ -564,7 +524,7 @@ export function useChatSessionState({
selectedProject,
selectedSession,
sessionStore,
isProcessing,
isLoading,
]);
// Search navigation target
@@ -590,9 +550,13 @@ export function useChatSessionState({
const scrollToTarget = async () => {
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
const sessionProvider = selectedSession.__provider || 'claude';
try {
// Load all messages into the store for search navigation
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
provider: sessionProvider as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null,
offset: 0,
});
@@ -660,15 +624,18 @@ 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') return;
const fetchInitialTokenUsage = async () => {
try {
// The backend resolves the provider from the indexed session row.
// Token usage endpoint is now keyed by the DB projectId.
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
const response = await authenticatedFetch(url);
if (response.ok) {
@@ -681,7 +648,7 @@ export function useChatSessionState({
}
};
fetchInitialTokenUsage();
}, [selectedProject, selectedSession?.id]);
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
const visibleMessages = useMemo(() => {
if (chatMessages.length <= visibleMessageCount) return chatMessages;
@@ -720,6 +687,16 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
useEffect(() => {
const activeViewSessionId = selectedSession?.id || currentSessionId;
if (!activeViewSessionId || !processingSessions) return;
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && !isLoading) {
setIsLoading(true);
setCanAbortSession(true);
}
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
// "Load all" overlay
const prevLoadingRef = useRef(false);
useEffect(() => {
@@ -741,6 +718,8 @@ export function useChatSessionState({
const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return;
if (isLoadingAllMessages) return;
const sessionProvider = selectedSession.__provider || 'claude';
const requestSessionId = selectedSession.id;
allMessagesLoadedRef.current = true;
isLoadingMoreRef.current = true;
@@ -753,6 +732,9 @@ export function useChatSessionState({
try {
const slot = await sessionStore.fetchFromServer(requestSessionId, {
provider: sessionProvider as LLMProvider,
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null,
offset: 0,
});
@@ -796,15 +778,16 @@ export function useChatSessionState({
addMessage,
clearMessages,
rewindMessages,
sessionActivity,
isProcessing,
canAbortSession,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
@@ -817,6 +800,8 @@ export function useChatSessionState({
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,

View File

@@ -393,8 +393,7 @@ export function useSlashCommands({
return;
}
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
const slashPattern = /(?:^|\s)(\/\S*)$/;
const slashPattern = /^\/(\S*)$/;
const match = textBeforeCursor.match(slashPattern);
if (!match) {
@@ -402,9 +401,8 @@ export function useSlashCommands({
return;
}
// Compute actual position of / in the full input string.
const slashPos = match.index! + (match[0].length - match[1].length);
const query = match[1].slice(1); // strip leading /
const slashPos = 0;
const query = match[1];
setSlashPosition(slashPos);
setShowCommandMenu(true);

View File

@@ -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;
}

View File

@@ -1,9 +1,4 @@
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type {
MarkSessionIdle,
MarkSessionProcessing,
SessionActivityMap,
} from '../../../hooks/useSessionProtection';
export type Provider = LLMProvider;
@@ -107,24 +102,20 @@ export type SessionNavigationOptions = {
replace?: boolean;
};
export type SessionEstablishedContext = {
provider: LLMProvider;
project: Project;
summary?: string | null;
};
export interface ChatInterfaceProps {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
latestMessage: any;
onFileOpen?: (filePath: string, diffInfo?: any) => void;
onInputFocusChange?: (focused: boolean) => void;
onSessionProcessing?: MarkSessionProcessing;
onSessionIdle?: MarkSessionIdle;
processingSessions?: SessionActivityMap;
onSessionActive?: (sessionId?: string | null) => void;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
processingSessions?: Set<string>;
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings?: () => void;
autoExpandTools?: boolean;
showRawParameters?: boolean;

View File

@@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
import type { LLMProvider } from '../../../types/app';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
@@ -17,18 +17,24 @@ import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
type PendingViewSession = {
startedAt: number;
};
function ChatInterface({
selectedProject,
selectedSession,
ws,
sendMessage,
latestMessage,
onFileOpen,
onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
processingSessions,
onNavigateToSession,
onSessionEstablished,
onShowSettings,
autoExpandTools,
showRawParameters,
@@ -40,19 +46,12 @@ function ChatInterface({
onShowAllTasks,
}: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { subscribe } = useWebSocket();
const { t } = useTranslation('chat');
const sessionStore = useSessionStore();
const streamTimerRef = useRef<number | null>(null);
const accumulatedStreamRef = useRef('');
// When each session's `chat.subscribe` was last sent; idle acks older than
// a later local request are discarded as stale.
const statusCheckSentAtRef = useRef(new Map<string, number>());
// Highest live `seq` observed per session. Written by the realtime handler
// on every sequenced frame, read whenever a `chat.subscribe` is sent so the
// server replays only the events this client actually missed.
const lastSeqRef = useRef(new Map<string, number>());
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
@@ -93,15 +92,16 @@ function ChatInterface({
const {
chatMessages,
addMessage,
sessionActivity,
isProcessing,
canAbortSession,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
@@ -114,6 +114,8 @@ function ChatInterface({
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,
@@ -128,28 +130,19 @@ function ChatInterface({
externalMessageUpdate,
newSessionTrigger,
processingSessions,
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
lastSeqRef,
pendingViewSessionRef,
sessionStore,
});
// Brand-new conversation: the composer allocated a stable session id via
// the session gateway before the first send. Record it locally and put it
// in the URL — this id never changes again, so there is no later handoff.
const handleSessionEstablished = useCallback<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((sessionId, context) => {
setCurrentSessionId(sessionId);
onSessionEstablished?.(sessionId, context);
onNavigateToSession?.(sessionId);
}, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]);
const {
input,
setInput,
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -187,7 +180,6 @@ function ChatInterface({
isInputFocused: _isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
} = useChatComposerState({
selectedProject,
selectedSession,
@@ -200,58 +192,66 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
isLoading: isProcessing,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onSessionEstablished: handleSessionEstablished,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
addMessage,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-subscribe — the
// `chat_subscribed` ack restores or clears the activity indicator, replays
// missed live events, and re-attaches a still-running stream to this socket.
// On WebSocket reconnect, re-fetch the current session's messages from the server
// so missed streaming events are shown. Also reset isLoading.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
await sessionStore.refreshFromServer(selectedSession.id);
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSession.id,
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
}],
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || providerVal) as LLMProvider,
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
setIsLoading(false);
setCanAbortSession(false);
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
useChatRealtimeHandlers({
subscribe,
latestMessage,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setPendingPermissionRequests,
pendingViewSessionRef,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionInactive,
onSessionActive,
onSessionProcessing,
onSessionIdle,
onSessionNotProcessing,
onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
sessionStore,
});
useEffect(() => {
if (!canAbortSession) {
if (!isLoading || !canAbortSession) {
return;
}
@@ -268,7 +268,7 @@ function ChatInterface({
return () => {
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
};
}, [canAbortSession, handleAbortSession]);
}, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => {
return () => {
@@ -315,7 +315,6 @@ function ChatInterface({
onWheel={handleScroll}
onTouchMove={handleScroll}
isLoadingSessionMessages={isLoadingSessionMessages}
isProcessing={isProcessing}
chatMessages={chatMessages}
selectedSession={selectedSession}
currentSessionId={currentSessionId}
@@ -364,13 +363,15 @@ function ChatInterface({
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
activity={sessionActivity}
isLoading={isProcessing}
claudeStatus={claudeStatus}
isLoading={isLoading}
onAbortSession={handleAbortSession}
provider={provider}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}

View File

@@ -1,80 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shimmer } from '../../../../shared/view/ui';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
};
const ACTION_KEYS = [
'claudeStatus.actions.thinking',
'claudeStatus.actions.processing',
'claudeStatus.actions.analyzing',
'claudeStatus.actions.working',
'claudeStatus.actions.computing',
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
/**
* Minimal response-in-progress indicator, in the spirit of the inline status
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
* elapsed time, and an interrupt affordance. Rendered only while the viewed
* session has an entry in the processing map; it disappears the instant that
* entry is removed.
*/
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [startedAt]);
if (!activity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
return (
<div className="animate-in fade-in mb-2 w-full duration-300">
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer>
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
{activity.canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
>
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
esc
</kbd>
</button>
)}
</div>
</div>
);
}

View File

@@ -2,17 +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 { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
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,
@@ -24,12 +30,6 @@ import {
PromptInputSubmit,
} from '../../../../shared/view/ui';
import CommandMenu from './CommandMenu';
import ActivityIndicator from './ActivityIndicator';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary';
interface MentionableFile {
name: string;
path: string;
@@ -52,13 +52,15 @@ interface ChatComposerProps {
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
activity: SessionActivity | null;
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean;
onAbortSession: () => void;
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;
@@ -105,13 +107,15 @@ export default function ChatComposer({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
activity,
claudeStatus,
isLoading,
onAbortSession,
provider,
permissionMode,
onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
@@ -172,7 +176,12 @@ export default function ChatComposer({
return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
)}
{pendingPermissionRequests.length > 0 && (
@@ -286,7 +295,6 @@ export default function ChatComposer({
<PromptInputTextarea
ref={textareaRef}
dir="auto"
value={input}
onChange={onInputChange}
onClick={onTextareaClick}
@@ -349,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') }}
@@ -370,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>
@@ -387,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>

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