diff --git a/README.ja.md b/README.ja.md index 17a3fe72..2a629b9f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -70,6 +70,18 @@ > これはすべてのアクセス方法で機能します — デスクトップ、リモートブラウザ、IMボット — CodeMux が動作する場所であれば、画像入力も使えます。 +### 5. 開発ワークフローツール + +CodeMux はチャットにとどまりません — 開発ワークフローを直接インターフェースから管理する統合ツールを提供します。 + +- **スケジュールタスク**:定期的なエージェントタスクを自動化 — 毎朝のコードレビュー、インターバルでのレポート生成、週次のイシュー一括処理。手動トリガー、インターバル(5分〜12時間)、日次、週次スケジューリングに対応し、アプリ再起動時に実行漏れを自動補完します。 + +- **Git Worktree 並列セッション**:`git stash` なしで複数ブランチの同時作業が可能。サイドバーから隔離されたワークツリーを作成し、それぞれが独自のディレクトリ、ブランチ、AIセッションを持ちます。merge、squash、rebase から選択してマージバック — すべてUI内で完結します。 + +- **ファイルエクスプローラーとGit変更監視**:折りたたみ可能なツリーでプロジェクトファイルを閲覧し、シンタックスハイライト付きでコードをプレビュー、Git変更をリアルタイムに追跡。「変更」タブで変更ファイルを行レベルの追加/削除数と共に表示し、インラインdiffビューアーでCodeMuxを離れずにすべての変更を確認できます。 + +- **スラッシュコマンドとエンジンスキル**:入力欄で `/` を入力すると、オートコンプリートでエンジンネイティブのコマンドとスキルを呼び出せます — `/cancel`、`/status`、`/mode`、`/model` など。各エンジンは独自のコマンドを公開; Copilot はプロジェクトレベルおよび個人スキルを、Claude Code はユーザーインストール済みスキルを、OpenCode は SDK コマンドをパススルーします — すべて統一されたオートコンプリート UI で操作できます。 + ### その他の機能 - **エージェントモード切替**:Build / Plan / Autopilot モードをエンジンごとに切り替え — それぞれ異なる動作とプロンプトスタイル @@ -77,6 +89,7 @@ - **パーミッション承認**:シェルやファイル編集などの機密操作をインラインで承認/拒否 — 信頼済みパターンには「常に許可」オプション - **インタラクティブ質問**:エンジンが単一/複数選択の質問を提示可能、説明文とカスタム入力をサポート - **エンジンごとのモデル選択**:エンジンごとに異なるモデルを選択可能; Copilot と Claude Code はカスタムモデル ID の手動入力をサポート +- **トークン使用量追跡**:入力、出力、キャッシュトークンの消費量をエンジンごとのコスト内訳と共に監視 #### ブラウザリモートアクセス @@ -262,15 +275,23 @@ codemux/ │ │ ├── engines/ # エンジンアダプター (OpenCode, Copilot, Claude Code) │ │ ├── gateway/ # WebSocket サーバー + エンジンルーティング │ │ ├── channels/ # IM ボットチャネル(Feishu、DingTalk、Telegram、WeCom、Teams) -│ │ └── services/ # 認証、デバイスストア、トンネル、セッション +│ │ │ └── streaming/ # クロスチャネルストリーミング基盤 +│ │ ├── services/ # 認証、デバイスストア、トンネル、セッション、ファイルサービス、トレイなど +│ │ └── utils/ # 共有ユーティリティ(ID生成など) │ └── preload/ ├── src/ # SolidJS レンダラー │ ├── pages/ # Chat, Settings, Devices, Entry │ ├── components/ # UIコンポーネント + コンテンツレンダラー │ ├── stores/ # リアクティブステート (session, message, config) │ ├── lib/ # Gateway クライアント、認証、i18n、テーマ +│ ├── locales/ # i18n翻訳ファイル (en, zh, ru) │ └── types/ # 統一型システム + ツールマッピング -├── scripts/ # セットアップ、バイナリアップデーター +├── shared/ # 共有バックエンドモジュール(認証、JWT、デバイスストアベース) +├── tests/ # ユニットテスト、E2Eテスト(Playwright)、ベンチマーク +├── docs/ # チャネル設定ガイド + 設計ドキュメント +├── website/ # プロジェクトウェブサイト(SolidJS + Vite) +├── scripts/ # セットアップ、バイナリアップデーター、CIヘルパー +├── homebrew/ # macOS Homebrew 配布用フォーミュラ ├── electron.vite.config.ts └── electron-builder.yml ``` @@ -279,7 +300,7 @@ codemux/ ## コントリビューション -コントリビューションを歓迎します!以下の規約に従ってください: +コントリビューションを歓迎します!詳細なガイドラインは [CONTRIBUTING.md](CONTRIBUTING.md) をご覧ください。 **コードスタイル**: TypeScript strict モード、SolidJS リアクティブパターン、Tailwind によるスタイリング diff --git a/README.ko.md b/README.ko.md index ce17f20b..afe8b41f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -70,6 +70,18 @@ API 키를 바꿔 끼우는 채팅 래퍼가 아닙니다. CodeMux는 **프로 > 이것은 모든 접속 방법에서 작동합니다 — 데스크톱, 원격 브라우저, IM 봇 — CodeMux가 실행되는 곳이라면 어디서든 이미지 입력이 가능합니다. +### 5. 개발 워크플로 도구 + +CodeMux는 채팅을 넘어 — 개발 워크플로를 인터페이스에서 직접 관리할 수 있는 통합 도구를 제공합니다. + +- **스케줄 작업**: 반복적인 에이전트 작업을 자동화 — 매일 아침 코드 리뷰, 인터벌 기반 보고서 생성, 주간 이슈 일괄 처리. 수동 트리거, 인터벌(5분~12시간), 일간, 주간 스케줄링을 지원하며, 앱 재시작 시 놓친 실행을 자동 보완합니다. + +- **Git Worktree 병렬 세션**: `git stash` 없이 여러 브랜치에서 동시 작업이 가능합니다. 사이드바에서 격리된 워크트리를 생성하면, 각각 독립된 디렉토리, 브랜치, AI 세션을 갖습니다. merge, squash, rebase 중 선택하여 머지 — UI를 벗어나지 않고 모든 작업을 완료합니다. + +- **파일 탐색기 & Git 변경 모니터링**: 접을 수 있는 트리로 프로젝트 파일을 탐색하고, 구문 강조 지원 코드 미리보기, Git 변경 사항을 실시간 추적합니다. "변경" 탭에서 라인 수준 추가/삭제 카운트와 함께 수정된 파일을 보여주며, 인라인 diff 뷰어로 CodeMux를 벗어나지 않고 모든 변경을 검토할 수 있습니다. + +- **슬래시 명령 & 엔진 스킬**: 입력창에서 `/`를 입력하면 자동완성으로 엔진 네이티브 명령과 스킬을 호출할 수 있습니다 — `/cancel`, `/status`, `/mode`, `/model` 등. 각 엔진은 고유 명령을 노출합니다; Copilot은 프로젝트 및 개인 스킬을, Claude Code는 사용자 설치 스킬을, OpenCode는 SDK 명령을 전달합니다 — 모두 통합된 자동완성 UI로 조작합니다. + ### 더 많은 기능 - **에이전트 모드 전환**: 엔진별로 Build / Plan / Autopilot 모드 전환 — 각각 고유한 동작과 프롬프트 스타일 @@ -77,6 +89,7 @@ API 키를 바꿔 끼우는 채팅 래퍼가 아닙니다. CodeMux는 **프로 - **권한 승인**: 민감한 작업(셸, 파일 편집)을 인라인으로 승인 또는 거부 — 신뢰할 수 있는 패턴에 대한 "항상 허용" 옵션 - **대화형 질문**: 엔진이 단일/다중 선택 질문을 제시, 설명 텍스트와 사용자 정의 입력 지원 - **엔진별 모델 선택**: 각 엔진에 대해 독립적으로 다른 모델 선택 가능; Copilot과 Claude Code는 사용자 정의 모델 ID 입력 지원 +- **토큰 사용량 추적**: 입력, 출력 및 캐시 토큰 소비량을 엔진별 비용 분석과 함께 모니터링 #### 브라우저 원격 접속 @@ -262,15 +275,23 @@ codemux/ │ │ ├── engines/ # 엔진 어댑터 (OpenCode, Copilot, Claude Code) │ │ ├── gateway/ # WebSocket 서버 + 엔진 라우팅 │ │ ├── channels/ # IM 봇 채널 (Feishu, DingTalk, Telegram, WeCom, Teams) -│ │ └── services/ # 인증, 기기 저장소, 터널, 세션 +│ │ │ └── streaming/ # 크로스 채널 스트리밍 인프라 +│ │ ├── services/ # 인증, 기기 저장소, 터널, 세션, 파일 서비스, 트레이 등 +│ │ └── utils/ # 공유 유틸리티 (ID 생성 등) │ └── preload/ ├── src/ # SolidJS 렌더러 │ ├── pages/ # Chat, Settings, Devices, Entry │ ├── components/ # UI 컴포넌트 + 콘텐츠 렌더러 │ ├── stores/ # 반응형 상태 (session, message, config) │ ├── lib/ # Gateway 클라이언트, 인증, i18n, 테마 +│ ├── locales/ # i18n 번역 파일 (en, zh, ru) │ └── types/ # 통합 타입 시스템 + 도구 매핑 -├── scripts/ # 설정, 바이너리 업데이터 +├── shared/ # 공유 백엔드 모듈 (인증, JWT, 기기 저장소 베이스) +├── tests/ # 단위 테스트, E2E 테스트 (Playwright), 벤치마크 +├── docs/ # 채널 설정 가이드 + 설계 문서 +├── website/ # 프로젝트 웹사이트 (SolidJS + Vite) +├── scripts/ # 설정, 바이너리 업데이터, CI 헬퍼 +├── homebrew/ # macOS Homebrew 배포 포뮬러 ├── electron.vite.config.ts └── electron-builder.yml ``` @@ -279,7 +300,7 @@ codemux/ ## 기여하기 -기여를 환영합니다! 다음 컨벤션을 따라주세요: +기여를 환영합니다! 자세한 가이드라인은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요. **코드 스타일**: TypeScript strict 모드, SolidJS 반응형 패턴, Tailwind을 사용한 스타일링 diff --git a/README.md b/README.md index 44d950f6..f6c35137 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ Paste a screenshot, drag in a design mockup, or upload an error image — all th > This works across all access methods — desktop, remote browser, and IM bots — wherever CodeMux runs, image input follows. +### 5. Developer Workflow Tools + +CodeMux goes beyond chat — it provides integrated tools to manage your development workflow directly from the interface. + +- **Scheduled Tasks**: Automate recurring agent tasks — run code reviews every morning, generate reports on an interval, or batch-process issues weekly. Supports manual trigger, interval (5 min – 12 hours), daily, and weekly scheduling with missed-run catch-up when the app restarts. + +- **Git Worktree Parallel Sessions**: Work on multiple branches simultaneously without `git stash`. Create isolated worktrees from the sidebar, each with its own directory, branch, and AI sessions. Merge back with your choice of merge, squash, or rebase — all without leaving the UI. + +- **File Explorer & Git Monitoring**: Browse project files with a collapsible tree, preview code with syntax highlighting, and track git changes in real time. A "Changes" tab shows modified files with line-level add/remove counts, and an inline diff viewer lets you inspect every change without leaving CodeMux. + +- **Slash Commands & Engine Skills**: Type `/` in the input to invoke engine-native commands and skills with autocomplete — `/cancel`, `/status`, `/mode`, `/model`, and more. Each engine exposes its own commands; Copilot surfaces project-level and personal skills, Claude Code surfaces user-installed skills, and OpenCode passes through its SDK commands — all through a unified autocomplete UI. + ### And More - **Agent mode switching**: Toggle between Build / Plan / Autopilot modes per engine — each with its own behavior and prompt style @@ -77,6 +89,7 @@ Paste a screenshot, drag in a design mockup, or upload an error image — all th - **Permission approvals**: Approve or deny sensitive operations (shell, file edits) inline — with "always allow" for trusted patterns - **Interactive questions**: Engines can ask single/multi-select questions with descriptions and custom input - **Per-engine model selection**: Pick different models for each engine independently; Copilot and Claude Code support custom model ID input +- **Token usage tracking**: Monitor input, output, and cache token consumption with per-engine cost breakdowns #### Browser Remote Access @@ -264,15 +277,23 @@ codemux/ │ │ ├── engines/ # Engine adapters (OpenCode, Copilot, Claude Code) │ │ ├── gateway/ # WebSocket server + engine routing │ │ ├── channels/ # IM bot channels (Feishu, DingTalk, Telegram, WeCom, Teams) -│ │ └── services/ # Auth, device store, tunnel, sessions +│ │ │ └── streaming/ # Cross-channel streaming infrastructure +│ │ ├── services/ # Auth, device store, tunnel, sessions, file service, tray, etc. +│ │ └── utils/ # Shared utilities (ID generation, etc.) │ └── preload/ ├── src/ # SolidJS renderer │ ├── pages/ # Chat, Settings, Devices, Entry │ ├── components/ # UI components + content renderers │ ├── stores/ # Reactive state (session, message, config) │ ├── lib/ # Gateway client, auth, i18n, theme +│ ├── locales/ # i18n translation files (en, zh, ru) │ └── types/ # Unified type system + tool mapping -├── scripts/ # Setup, binary updaters +├── shared/ # Shared backend modules (auth, JWT, device store base) +├── tests/ # Unit tests, e2e tests (Playwright), benchmarks +├── docs/ # Channel setup guides + design documents +├── website/ # Project website (SolidJS + Vite) +├── scripts/ # Setup, binary updaters, CI helpers +├── homebrew/ # Homebrew formula for macOS distribution ├── electron.vite.config.ts └── electron-builder.yml ``` @@ -281,7 +302,7 @@ codemux/ ## Contributing -Contributions are welcome! Please follow these conventions: +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. **Code Style**: TypeScript strict mode, SolidJS reactive patterns, Tailwind for styling diff --git a/README.ru.md b/README.ru.md index 6cebf60f..49cc1a03 100644 --- a/README.ru.md +++ b/README.ru.md @@ -70,6 +70,18 @@ > Это работает при любом способе доступа — десктоп, удалённый браузер, IM-боты — где бы ни работал CodeMux, ввод изображений доступен. +### 5. Инструменты рабочего процесса разработки + +CodeMux выходит за рамки чата — предоставляет интегрированные инструменты для управления рабочим процессом разработки прямо из интерфейса. + +- **Запланированные задачи**: Автоматизируйте регулярные задачи агентов — ежедневные обзоры кода, генерация отчётов по интервалу, еженедельная пакетная обработка задач. Поддерживаются ручной запуск, интервал (5 мин – 12 часов), ежедневное и еженедельное расписание с автоматическим выполнением пропущенных запусков при перезапуске приложения. + +- **Параллельные сессии Git Worktree**: Работайте над несколькими ветками одновременно без `git stash`. Создавайте изолированные рабочие деревья прямо из боковой панели, каждое со своим каталогом, веткой и AI-сессиями. Слияние обратно с выбором стратегии: merge, squash или rebase — не покидая интерфейса. + +- **Проводник файлов и мониторинг Git**: Просматривайте файлы проекта в сворачиваемом дереве, предпросмотр кода с подсветкой синтаксиса и отслеживание изменений Git в реальном времени. Вкладка «Изменения» показывает модифицированные файлы с построчными счётчиками добавлений/удалений, а встроенный просмотрщик diff позволяет проверять каждое изменение, не покидая CodeMux. + +- **Слеш-команды и навыки движков**: Введите `/` в поле ввода для вызова нативных команд и навыков движков с автодополнением — `/cancel`, `/status`, `/mode`, `/model` и другие. Каждый движок предоставляет собственные команды; Copilot — навыки проекта и персональные, Claude Code — пользовательские навыки, OpenCode — команды SDK — всё через единый интерфейс автодополнения. + ### Дополнительные возможности - **Переключение режимов агента**: Переключайтесь между режимами Build / Plan / Autopilot для каждого движка — каждый со своим поведением и стилем промптов @@ -77,6 +89,7 @@ - **Одобрение разрешений**: Одобряйте или отклоняйте чувствительные операции (терминал, редактирование файлов) прямо в интерфейсе — с опцией «Всегда разрешать» для доверенных шаблонов - **Интерактивные вопросы**: Движки могут задавать вопросы с единичным/множественным выбором, описаниями и пользовательским вводом - **Выбор модели для каждого движка**: Выбирайте разные модели для каждого движка независимо; Copilot и Claude Code поддерживают ввод произвольного ID модели +- **Отслеживание использования токенов**: Мониторинг потребления входных, выходных и кеш-токенов с разбивкой затрат по движкам #### Удалённый доступ через браузер @@ -265,15 +278,23 @@ codemux/ │ │ ├── engines/ # Адаптеры движков (OpenCode, Copilot, Claude Code) │ │ ├── gateway/ # WebSocket-сервер + маршрутизация движков │ │ ├── channels/ # Каналы IM-ботов (Feishu, DingTalk, Telegram, WeCom, Teams) -│ │ └── services/ # Авторизация, хранилище устройств, туннель, сессии +│ │ │ └── streaming/ # Кросс-канальная стриминговая инфраструктура +│ │ ├── services/ # Авторизация, хранилище устройств, туннель, сессии, файловый сервис, трей и др. +│ │ └── utils/ # Общие утилиты (генерация ID и др.) │ └── preload/ ├── src/ # SolidJS рендерер │ ├── pages/ # Chat, Settings, Devices, Entry │ ├── components/ # UI-компоненты + рендереры контента │ ├── stores/ # Реактивное состояние (session, message, config) │ ├── lib/ # Gateway-клиент, авторизация, i18n, тема +│ ├── locales/ # Файлы переводов i18n (en, zh, ru) │ └── types/ # Унифицированная система типов + маппинг инструментов -├── scripts/ # Настройка, обновление бинарников +├── shared/ # Общие модули бэкенда (авторизация, JWT, базовое хранилище устройств) +├── tests/ # Юнит-тесты, E2E-тесты (Playwright), бенчмарки +├── docs/ # Руководства по настройке каналов + проектная документация +├── website/ # Веб-сайт проекта (SolidJS + Vite) +├── scripts/ # Настройка, обновление бинарников, CI-хелперы +├── homebrew/ # Формула Homebrew для дистрибуции на macOS ├── electron.vite.config.ts └── electron-builder.yml ``` @@ -282,7 +303,7 @@ codemux/ ## Участие в проекте -Мы приветствуем вклад! Пожалуйста, следуйте этим соглашениям: +Мы приветствуем вклад! Подробные рекомендации см. в [CONTRIBUTING.md](CONTRIBUTING.md). **Стиль кода**: TypeScript strict mode, реактивные паттерны SolidJS, Tailwind для стилизации diff --git a/README.zh-CN.md b/README.zh-CN.md index 04352112..2773ecde 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,6 +70,18 @@ > 这在所有访问方式中都有效 —— 桌面端、远程浏览器和 IM 机器人 —— CodeMux 运行在哪里,图片输入就跟到哪里。 +### 5. 开发工作流工具 + +CodeMux 不只是聊天 —— 它提供集成工具,让你直接在界面中管理开发工作流。 + +- **定时任务**:自动化定期执行的 Agent 任务 —— 每天早上跑代码审查、按间隔生成报告、每周批量处理 Issue。支持手动触发、间隔(5 分钟 – 12 小时)、每日和每周调度,应用重启时自动补执行错过的任务。 + +- **Git Worktree 并行会话**:无需 `git stash` 即可同时在多个分支上工作。从侧边栏创建隔离的 Worktree,每个都有独立的目录、分支和 AI 会话。支持 merge、squash 或 rebase 三种方式合并回主分支 —— 全程不离开界面。 + +- **文件浏览器与 Git 变更监听**:通过可折叠的文件树浏览项目文件,带语法高亮的代码预览,实时追踪 Git 变更。"变更"标签页展示修改文件及逐行增删统计,内联 diff 查看器让你无需离开 CodeMux 即可检视每一处改动。 + +- **斜杠命令与引擎技能**:在输入框中键入 `/` 即可通过自动补全调用引擎原生命令和技能 —— `/cancel`、`/status`、`/mode`、`/model` 等。每个引擎暴露各自的命令;Copilot 提供项目级和个人技能,Claude Code 提供用户安装的技能,OpenCode 透传 SDK 命令 —— 全部通过统一的自动补全界面操作。 + ### 更多特性 - **Agent 模式切换**:在 Build / Plan / Autopilot 等模式间切换 —— 每种模式有不同的行为和提示风格 @@ -77,6 +89,7 @@ - **权限审批**:内联审批或拒绝敏感操作(Shell、文件编辑) —— 支持"始终允许"以简化可信操作 - **交互式问答**:引擎可发起单选/多选问题,支持描述文字和自定义输入 - **每引擎独立选模型**:为每个引擎独立选择模型;Copilot 和 Claude Code 支持手动输入自定义模型 ID +- **Token 用量追踪**:监控输入、输出和缓存 Token 消耗,支持按引擎分类的成本统计 #### 浏览器远程访问 @@ -264,15 +277,23 @@ codemux/ │ │ ├── engines/ # 引擎适配器(OpenCode、Copilot、Claude Code) │ │ ├── gateway/ # WebSocket 服务器 + 引擎路由 │ │ ├── channels/ # IM 机器人渠道(飞书、钉钉、Telegram、企业微信、Teams) -│ │ └── services/ # 认证、设备存储、隧道、会话 +│ │ │ └── streaming/ # 跨渠道流式传输基础设施 +│ │ ├── services/ # 认证、设备存储、隧道、会话、文件服务、托盘等 +│ │ └── utils/ # 共享工具函数(ID 生成等) │ └── preload/ ├── src/ # SolidJS 渲染层 │ ├── pages/ # Chat、Settings、Devices、Entry │ ├── components/ # UI 组件 + 内容渲染器 │ ├── stores/ # 响应式状态(session、message、config) │ ├── lib/ # 网关客户端、认证、国际化、主题 +│ ├── locales/ # 国际化翻译文件(en、zh、ru) │ └── types/ # 统一类型系统 + 工具映射 -├── scripts/ # 安装脚本、二进制文件更新器 +├── shared/ # 共享后端模块(认证、JWT、设备存储基类) +├── tests/ # 单元测试、端到端测试(Playwright)、性能基准 +├── docs/ # 渠道配置指南 + 设计文档 +├── website/ # 项目网站(SolidJS + Vite) +├── scripts/ # 安装脚本、二进制文件更新器、CI 辅助工具 +├── homebrew/ # macOS Homebrew 分发配方 ├── electron.vite.config.ts └── electron-builder.yml ``` @@ -281,7 +302,7 @@ codemux/ ## 贡献 -欢迎贡献!请遵循以下规范: +欢迎贡献!详细指南请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。 **代码风格**:TypeScript 严格模式,SolidJS 响应式模式,使用 Tailwind 进行样式编写 diff --git a/docs/channels/README.md b/docs/channels/README.md index 3ca8544a..6b8fc985 100644 --- a/docs/channels/README.md +++ b/docs/channels/README.md @@ -6,7 +6,7 @@ Connect CodeMux to your favorite messaging platforms to interact with AI coding | Platform | Connection | Streaming | Group Creation | Rich Content | Guide | |----------|-----------|-----------|----------------|--------------|-------| -| [Feishu (飞书)](feishu/README.md) | WebSocket SDK | ✅ Edit-in-place | ✅ Auto-create | Interactive Cards | [→ Setup](feishu/README.md) | +| [Feishu / Lark](feishu/README.md) | WebSocket SDK | ✅ Edit-in-place | ✅ Auto-create | Interactive Cards | [→ Setup](feishu/README.md) | | [DingTalk (钉钉)](dingtalk/README.md) | Stream mode (WS) | ✅ AI Card | ✅ Scene groups | ActionCard / Markdown | [→ Setup](dingtalk/README.md) | | [Telegram](telegram/README.md) | Webhook / Long Polling | ✅ Draft / Edit | ❌ P2P only | MarkdownV2 + Buttons | [→ Setup](telegram/README.md) | | [WeCom (企业微信)](wecom/README.md) | HTTP Callback (AES XML) | ❌ Batch mode | ✅ App group chat | Markdown | [→ Setup](wecom/README.md) | @@ -16,14 +16,14 @@ Connect CodeMux to your favorite messaging platforms to interact with AI coding | Type | Platforms | Tunnel Required | How It Works | |------|-----------|----------------|--------------| -| **Direct Connect** | Feishu, DingTalk, Telegram (polling) | No | Platform SDK maintains persistent connection from CodeMux to the platform's servers | +| **Direct Connect** | Feishu / Lark, DingTalk, Telegram (polling) | No | Platform SDK maintains persistent connection from CodeMux to the platform's servers | | **Webhook** | WeCom, Teams, Telegram (webhook) | Yes | Platform sends HTTP requests to your CodeMux instance via [Cloudflare Tunnel](../../README.md) | ## Session Models | Model | Platforms | Flow | |-------|-----------|------| -| **One Group = One Session** | Feishu, DingTalk, WeCom | P2P chat → select project → group auto-created → all messages in that group go to one CodeMux session | +| **One Group = One Session** | Feishu / Lark, DingTalk, WeCom | P2P chat → select project → group auto-created → all messages in that group go to one CodeMux session | | **P2P Direct** | Telegram, Teams | Interact directly in private chat with temporary sessions. In group chats, @mention the bot | ## Common Features @@ -56,7 +56,7 @@ All channels support: ## Architecture ``` -IM Platform (Feishu/DingTalk/Telegram/WeCom/Teams) +IM Platform (Feishu/Lark/DingTalk/Telegram/WeCom/Teams) ↕ Messages (SDK WebSocket or HTTP Webhook) Channel Adapter (in CodeMux) ↕ WebSocket (internal) diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md index 437cf8ca..2c94c3a4 100644 --- a/docs/channels/feishu/README.md +++ b/docs/channels/feishu/README.md @@ -1,6 +1,6 @@ -# Feishu (飞书 / Lark) Channel Setup +# Feishu / Lark Channel Setup -Connect CodeMux to Feishu using the official WebSocket SDK — no public URL or Cloudflare Tunnel required. +Connect CodeMux to Feishu or Lark using the official WebSocket SDK — no public URL or Cloudflare Tunnel required. ## Overview @@ -15,12 +15,16 @@ Connect CodeMux to Feishu using the official WebSocket SDK — no public URL or ## Prerequisites -- A Feishu organization account (企业版 or team edition) -- Admin access to the [Feishu Open Platform](https://open.feishu.cn/) +- A Feishu or Lark organization account with a self-built/private app +- Admin access to the matching developer console: + - Feishu: [open.feishu.cn](https://open.feishu.cn/) + - Lark: [open.larksuite.com](https://open.larksuite.com/) -## Step 1: Create a Feishu App +## Step 1: Create a Feishu or Lark App -1. Go to [Feishu Open Platform](https://open.feishu.cn/app) and log in +1. Go to the matching developer console and log in: + - Feishu: [open.feishu.cn/app](https://open.feishu.cn/app) + - Lark: [open.larksuite.com/app](https://open.larksuite.com/app) 2. Click **Create Custom App** (创建企业自建应用) 3. Fill in: - **App Name**: e.g., "CodeMux" @@ -39,7 +43,7 @@ Connect CodeMux to Feishu using the official WebSocket SDK — no public URL or 1. Go to **Event Configuration** (事件配置) 2. **Important**: Select **Use Long Connection** (使用长连接接收事件) - > CodeMux uses Feishu's WebSocket SDK to receive events. Do **not** use HTTP callback mode. + > CodeMux uses the Feishu / Lark WebSocket SDK to receive events. Do **not** use HTTP callback mode. 3. Subscribe to these events: | Event | Name | Required | Purpose | @@ -57,11 +61,13 @@ Connect CodeMux to Feishu using the official WebSocket SDK — no public URL or Go to **Permissions & Scopes** (权限管理) and request the following scopes. -> **Tip**: You can import all scopes at once using the JSON file at [`feishu-scopes.json`](feishu-scopes.json). In the Feishu developer console, go to **Permissions & Scopes** → **Batch Enable** (批量开通), and paste the scope list from the JSON file. +> **Tip**: +> - **Feishu**: use [`feishu-scopes.json`](feishu-scopes.json). In the Feishu developer console, go to **Permissions & Scopes** → **Batch Enable** (批量开通), and paste the JSON payload there. +> - **Lark**: use [`lark-scopes.json`](lark-scopes.json) if your Lark tenant exposes a bulk paste/import field. Lark uses the default template shape `{"scopes":{"tenant":[...],"user":[...]}}`, not the Feishu batch-enable helper format. CodeMux currently needs only **tenant** scopes, so the `user` array stays empty. If your Lark console only shows checkboxes, enable the same scopes manually from the tables below. ### API Call Permissions -These scopes are required for the bot to call Feishu APIs (send messages, manage groups, etc.): +These scopes are required for the bot to call Feishu / Lark APIs (send messages, manage groups, etc.): | Scope | Description | Purpose | Required | |-------|-------------|---------|----------| @@ -93,6 +99,11 @@ These scopes control **which events** the bot can receive. They are separate fro After adding all permissions, click **Submit for Approval** (提交审核). For enterprise internal apps, approval is usually instant. +The Feishu and Lark helper files contain the same effective permissions, but the wrapper format is different: + +- `feishu-scopes.json`: richer helper object for the Feishu batch-enable dialog +- `lark-scopes.json`: Lark default template with `scopes.tenant` and `scopes.user` + ## Step 5: Configure Bot Custom Menu The bot custom menu provides clickable quick-action buttons in the chat input area. This is the primary way for users to navigate projects and sessions from their mobile devices. @@ -121,13 +132,14 @@ When a user clicks a menu item, Feishu sends an `application.bot.menu_v6` event 2. Click **Create Version** (创建版本) 3. Set the **Availability Scope** (可用范围) — choose which departments/users can use the bot 4. Submit for review - > Internal apps (企业自建应用) are typically auto-approved + > Internal or private self-built apps are typically auto-approved more quickly than store apps ## Step 7: Configure in CodeMux 1. Open CodeMux → go to the remote access page → **Channels** tab -2. Click **Configure** on the Feishu card +2. Click **Configure** on the Feishu / Lark card 3. Enter: + - **Platform**: Select **Feishu** for `open.feishu.cn` apps or **Lark** for `open.larksuite.com` apps - **App ID**: From Step 1 - **App Secret**: From Step 1 4. Click **Save** — the channel will start automatically @@ -136,7 +148,7 @@ When a user clicks a menu item, Feishu sends an `application.bot.menu_v6` event ### Getting Started -1. Open Feishu and search for your bot name in the contact list +1. Open Feishu or Lark and search for your bot name in the contact list 2. Send any message to the bot in a **P2P chat** (private conversation) 3. The bot will show a list of available projects — select one 4. Choose to create a **new session** or use an existing one @@ -186,13 +198,14 @@ Update throttle is 1.5 seconds by default to stay within Feishu's API rate limit | Bot menu clicks not working | Event not subscribed | Ensure `application.bot.menu_v6` event is subscribed in Event Configuration | | Bot doesn't appear in contacts | App not published | Go to Version Management → create and publish a version | | Messages truncated | Content exceeds 25KB | Normal behavior — long responses are truncated with a notice | -| Permission errors on startup | App not approved | Check version approval status in Feishu Open Platform | +| Permission errors on startup | App not approved | Check version approval status in the matching Feishu or Lark developer console | +| `system busy` / `PingInterval` on startup | Platform mismatch or incomplete WS config response | Verify the CodeMux platform selector matches your tenant (`open.feishu.cn` vs `open.larksuite.com`) and **Use Long Connection** is enabled | | Duplicate messages | Normal deduplication | Bot uses message ID deduplication (LRU, max 1000 IDs) — safe to ignore | ## Technical Details - **SDK**: `@larksuiteoapi/node-sdk` (official Lark Node.js SDK) -- **Connection**: WebSocket (WSClient) — persistent connection from CodeMux to Feishu cloud +- **Connection**: WebSocket (WSClient) — persistent connection from CodeMux to Feishu or Lark cloud - **Message Format**: Interactive Cards with Markdown content - **Persistence**: Group-session bindings saved to `~/.channels/feishu-bindings.json` - **Rate Limiting**: TokenBucket — 5 burst capacity, 5 tokens/sec refill diff --git a/docs/channels/feishu/feishu-scopes.json b/docs/channels/feishu/feishu-scopes.json index 030d0e35..9559fab2 100644 --- a/docs/channels/feishu/feishu-scopes.json +++ b/docs/channels/feishu/feishu-scopes.json @@ -1,5 +1,5 @@ { - "_comment": "Feishu (Lark) permission scopes required by CodeMux bot. Import these scopes in the Feishu Open Platform developer console under Permissions & Scopes.", + "_comment": "Feishu permission helper for CodeMux bot. Paste this richer payload into the Feishu Open Platform Batch Enable dialog.", "scopes": [ { "scope": "im:message", diff --git a/docs/channels/feishu/lark-scopes.json b/docs/channels/feishu/lark-scopes.json new file mode 100644 index 00000000..b13fcab3 --- /dev/null +++ b/docs/channels/feishu/lark-scopes.json @@ -0,0 +1,22 @@ +{ + "_comment": "Lark permission helper for CodeMux bot. This follows the default Lark permission template shape. CodeMux currently needs tenant scopes only, so user scopes remain empty. If your tenant does not expose JSON import, enable the same scopes manually from README.md.", + "scopes": { + "tenant": [ + "im:message", + "im:message:send_as_bot", + "im:message:send", + "im:message:update", + "im:message:recall", + "im:chat", + "im:chat:create", + "im:chat:update", + "im:message.p2p_msg:readonly", + "im:message.group_msg:readonly", + "im:chat:readonly", + "im:chat:operate_as_owner", + "im:chat.members:bot_access", + "im:resource" + ], + "user": [] + } +} diff --git a/electron-builder.yml b/electron-builder.yml index 6950d23c..2a0fb665 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -41,6 +41,7 @@ win: linux: icon: public/assets/icon.png category: Development + maintainer: realDuang <250407778@qq.com> target: - target: AppImage arch: @@ -48,11 +49,17 @@ linux: - target: deb arch: - x64 + extraResources: + - from: resources/cloudflared/linux-${arch} + to: cloudflared/linux-${arch} + filter: + - "**/*" desktop: - Name: CodeMux - Comment: Multi-engine AI coding assistant client - Categories: Development;IDE; - StartupWMClass: codemux + entry: + Name: CodeMux + Comment: Multi-engine AI coding assistant client + Categories: Development;IDE; + StartupWMClass: codemux nsis: oneClick: false perMachine: false diff --git a/electron/main/channels/channel-manager.ts b/electron/main/channels/channel-manager.ts index bb191c12..9b8b9be9 100644 --- a/electron/main/channels/channel-manager.ts +++ b/electron/main/channels/channel-manager.ts @@ -60,6 +60,28 @@ export class ChannelManager { private adapters = new Map(); private configs = new Map(); private webhookServer: WebhookServer | null = null; + private runtimeOptions: { gatewayUrl?: string } = {}; + + setRuntimeOptions(runtimeOptions: { gatewayUrl?: string }): void { + this.runtimeOptions = { + ...this.runtimeOptions, + ...runtimeOptions, + }; + + for (const config of this.configs.values()) { + this.applyRuntimeOptions(config); + } + } + + private applyRuntimeOptions(config: ChannelConfig): ChannelConfig { + config.options = { ...(config.options ?? {}) }; + + if (this.runtimeOptions.gatewayUrl) { + config.options.gatewayUrl = this.runtimeOptions.gatewayUrl; + } + + return config; + } /** Set the shared WebhookServer instance for adapters that need HTTP endpoints */ setWebhookServer(server: WebhookServer): void { @@ -93,15 +115,28 @@ export class ChannelManager { /** Load persisted config and auto-start enabled channels */ async initFromConfig(runtimeOptions?: { gatewayUrl?: string }): Promise { + this.setRuntimeOptions(runtimeOptions ?? {}); + for (const [type, adapter] of this.adapters) { - const config = loadConfig(type); + const diskConfig = loadConfig(type); + const existingConfig = this.configs.get(type); + let config = existingConfig ?? diskConfig; + + if (existingConfig && diskConfig) { + config = { + ...diskConfig, + ...existingConfig, + options: { + ...(diskConfig.options ?? {}), + ...(existingConfig.options ?? {}), + }, + }; + } + if (config) { - // Inject runtime gateway URL into channel options (overrides persisted value) - if (runtimeOptions?.gatewayUrl && config.options) { - (config.options as Record).gatewayUrl = runtimeOptions.gatewayUrl; - } + this.applyRuntimeOptions(config); this.configs.set(type, config); - if (config.enabled) { + if (config.enabled && adapter.getInfo().status === "stopped") { channelLog.info(`Auto-starting enabled channel: ${type}`); try { await adapter.start(config); @@ -132,8 +167,12 @@ export class ChannelManager { }; } else if (diskConfig) { // Merge: disk as base, in-memory overrides (preserves disk-only fields like tenantId) - config.options = { ...diskConfig.options, ...config.options }; + config.options = { + ...(diskConfig.options ?? {}), + ...(config.options ?? {}), + }; } + this.applyRuntimeOptions(config); this.configs.set(type, config); channelLog.info(`Starting channel: ${type}`); @@ -177,7 +216,8 @@ export class ChannelManager { /** Get config for a channel */ getConfig(type: string): ChannelConfig | undefined { - return this.configs.get(type) ?? loadConfig(type) ?? undefined; + const config = this.configs.get(type) ?? loadConfig(type) ?? undefined; + return config ? this.applyRuntimeOptions(config) : undefined; } /** Update channel config and optionally restart */ @@ -201,9 +241,13 @@ export class ChannelManager { if (updates.name !== undefined) config.name = updates.name; if (updates.enabled !== undefined) config.enabled = updates.enabled; if (updates.options !== undefined) { - config.options = { ...config.options, ...updates.options }; + config.options = { + ...(config.options ?? {}), + ...updates.options, + }; } + this.applyRuntimeOptions(config); this.configs.set(type, config); saveConfig(config); diff --git a/electron/main/channels/config-utils.ts b/electron/main/channels/config-utils.ts new file mode 100644 index 00000000..90d5ddb8 --- /dev/null +++ b/electron/main/channels/config-utils.ts @@ -0,0 +1,24 @@ +export function omitUndefinedConfig(updates?: Partial): Partial { + if (!updates) { + return {}; + } + + return Object.fromEntries( + Object.entries(updates).filter(([, value]) => value !== undefined), + ) as Partial; +} + +export function mergeDefinedConfig(baseConfig: T, updates?: Partial): T { + return { + ...baseConfig, + ...omitUndefinedConfig(updates), + }; +} + +export function didConfigValuesChange( + previousConfig: T, + nextConfig: T, + keys: readonly K[], +): boolean { + return keys.some((key) => previousConfig[key] !== nextConfig[key]); +} diff --git a/electron/main/channels/dingtalk/dingtalk-adapter.ts b/electron/main/channels/dingtalk/dingtalk-adapter.ts index 97cf1496..8bca8ca1 100644 --- a/electron/main/channels/dingtalk/dingtalk-adapter.ts +++ b/electron/main/channels/dingtalk/dingtalk-adapter.ts @@ -29,6 +29,7 @@ import { TokenManager } from "../streaming/token-manager"; import { TokenBucket } from "../streaming/rate-limiter"; import { BaseSessionMapper, type PersistedBinding } from "../base-session-mapper"; import { createStreamingSession, type StreamingSession } from "../streaming/streaming-types"; +import { didConfigValuesChange, mergeDefinedConfig } from "../config-utils"; import { DingTalkTransport } from "./dingtalk-transport"; import { DingTalkRenderer } from "./dingtalk-renderer"; import { @@ -138,10 +139,10 @@ export class DingTalkAdapter extends ChannelAdapter { this.emit("status.changed", this.status); // Merge config - this.config = { - ...DEFAULT_DINGTALK_CONFIG, - ...(config.options as unknown as Partial), - }; + this.config = mergeDefinedConfig( + DEFAULT_DINGTALK_CONFIG, + config.options as Partial | undefined, + ); if (!this.config.appKey || !this.config.appSecret) { this.status = "error"; @@ -266,14 +267,20 @@ export class DingTalkAdapter extends ChannelAdapter { async updateConfig(config: Partial): Promise { const wasRunning = this.status === "running"; const newOptions = config.options as Partial | undefined; + const previousConfig = { ...this.config }; if (newOptions) { - this.config = { ...this.config, ...newOptions }; + this.config = mergeDefinedConfig(this.config, newOptions); } - // If credentials changed while running, restart - if (wasRunning && newOptions && (newOptions.appKey || newOptions.appSecret || newOptions.robotCode)) { - dingtalkLog.info("Credentials changed, restarting DingTalk adapter"); + const shouldRestart = wasRunning && didConfigValuesChange( + previousConfig, + this.config, + ["appKey", "appSecret", "robotCode", "useStreamMode"], + ); + + if (shouldRestart) { + dingtalkLog.info("Connection settings changed, restarting DingTalk adapter"); await this.stop(); const fullConfig: ChannelConfig = { type: "dingtalk", diff --git a/electron/main/channels/feishu/feishu-adapter.ts b/electron/main/channels/feishu/feishu-adapter.ts index 58bba6cd..f0bea81a 100644 --- a/electron/main/channels/feishu/feishu-adapter.ts +++ b/electron/main/channels/feishu/feishu-adapter.ts @@ -1,5 +1,5 @@ // ============================================================================ -// Feishu Channel Adapter +// Feishu / Lark Channel Adapter // Connects Feishu (Lark) bot to CodeMux via Gateway WebSocket. // Architecture: One Group = One Session // P2P chat = entry point (project selection), Group chat = session interaction. @@ -45,6 +45,11 @@ import { type FeishuBotRemovedEvent, type FeishuUserRemovedEvent, } from "./feishu-types"; +import { + formatFeishuStartupError, + getLarkDomain, + normalizeFeishuPlatform, +} from "./feishu-platform"; import type { EngineType, UnifiedPart, @@ -52,7 +57,24 @@ import type { UnifiedPermission, UnifiedQuestion, } from "../../../../src/types/unified"; -import { feishuLog, getDefaultEngineFromSettings } from "../../services/logger"; +import { + getDefaultEngineFromSettings, + getFeishuChannelLog, + type ScopedLogger, +} from "../../services/logger"; + +interface WsStartupMonitor { + readyPromise: Promise; + cancel: () => void; + markStartResolved: () => void; + logger: { + error: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + trace: (...args: unknown[]) => void; + }; +} // ============================================================================ // Feishu Adapter @@ -88,11 +110,225 @@ export class FeishuAdapter extends ChannelAdapter { maxMessageBytes: 28_000, }; + // Verified against @larksuiteoapi/node-sdk@1.42.0. Re-check these markers after + // SDK upgrades because the SDK does not expose structured websocket lifecycle hooks. + private static readonly WS_READY_LOG = "ws client ready"; + private static readonly WS_STARTUP_FAILURE_MARKERS = ["system busy", "PingInterval"] as const; + private static readonly WS_STARTUP_TIMEOUT_MS = 30_000; + private static readonly CONFIG_RESTART_COOLDOWN_MS = 1_000; + private static readonly CONFIG_RESTART_RETRY_DELAY_MS = 2_000; + + private get channelLog(): ScopedLogger { + return getFeishuChannelLog(this.config.platform); + } + + private omitUndefinedConfig(updates: Partial): Partial { + return Object.fromEntries( + Object.entries(updates).filter(([, value]) => value !== undefined), + ) as Partial; + } + + private mergeConfig(baseConfig: FeishuConfig, updates?: Partial): FeishuConfig { + if (!updates) { + return { ...baseConfig }; + } + + const normalizedUpdates = this.omitUndefinedConfig(updates); + + return { + ...baseConfig, + ...normalizedUpdates, + platform: + normalizedUpdates.platform !== undefined + ? normalizeFeishuPlatform(normalizedUpdates.platform) + : baseConfig.platform, + }; + } + + private shouldRestartAfterConfigUpdate( + previousConfig: FeishuConfig, + updates?: Partial, + ): boolean { + if (!updates) { + return false; + } + + const nextConfig = this.mergeConfig(previousConfig, updates); + + return ( + nextConfig.appId !== previousConfig.appId || + nextConfig.appSecret !== previousConfig.appSecret || + nextConfig.platform !== previousConfig.platform + ); + } + + private getPlatformName(platform = this.config.platform): "Feishu" | "Lark" { + return platform === "lark" ? "Lark" : "Feishu"; + } + + private getChannelDisplayName(platform = this.config.platform): string { + return `${this.getPlatformName(platform)} Bot`; + } + + private async delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + private isTransientConfigRestartError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("system busy"); + } + + private async restartAfterConfigUpdate(config: ChannelConfig): Promise { + await this.stop(); + + // Give the previous long connection a brief cooldown before reconnecting + // with the replacement bot credentials. + await this.delay(FeishuAdapter.CONFIG_RESTART_COOLDOWN_MS); + + try { + await this.start(config); + } catch (error) { + if (!this.isTransientConfigRestartError(error)) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + this.channelLog.warn( + `Config update restart hit a transient long-connection busy error. Waiting ${FeishuAdapter.CONFIG_RESTART_RETRY_DELAY_MS}ms and retrying once. Original error: ${message}`, + ); + await this.delay(FeishuAdapter.CONFIG_RESTART_RETRY_DELAY_MS); + await this.start(config); + } + } + + private createWsStartupMonitor(platform: "feishu" | "lark", platformConfigured: boolean): WsStartupMonitor { + let isSettled = false; + let hasStartResolved = false; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + + const readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + readyPromise.catch(() => undefined); + + const settleReady = () => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeoutId); + resolveReady(); + }; + + const settleError = (message: string) => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeoutId); + rejectReady(new Error(message)); + }; + + const cancel = () => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeoutId); + resolveReady(); + }; + + const markStartResolved = () => { + hasStartResolved = true; + }; + + const normalizeLogArgs = (args: unknown[]): unknown[] => ( + args.length === 1 && Array.isArray(args[0]) ? args[0] as unknown[] : args + ); + + const stringifyLogArgs = (args: unknown[]): string => args + .map((arg) => { + if (typeof arg === "string") return arg; + if (arg instanceof Error) return arg.message; + if (Array.isArray(arg)) return arg.join(" "); + return String(arg); + }) + .join(" "); + + const handleLog = (level: "error" | "warn" | "info" | "debug" | "trace", args: unknown[]) => { + const normalizedArgs = normalizeLogArgs(args); + const text = stringifyLogArgs(normalizedArgs); + const isReadyLog = text.includes(FeishuAdapter.WS_READY_LOG); + const isStartupFailure = + this.status === "starting" && FeishuAdapter.WS_STARTUP_FAILURE_MARKERS.some((marker) => text.includes(marker)); + + // The Lark SDK logger uses trace/debug/info/warn/error, while electron-log uses + // debug/verbose/info/warn/error. Keep the readiness signal at info, downgrade + // other SDK info chatter to verbose, and map SDK trace to debug so it is not lost. + + switch (level) { + case "error": + this.channelLog.error(...normalizedArgs); + break; + case "warn": + this.channelLog.warn(...normalizedArgs); + break; + case "info": + if (isReadyLog) { + this.channelLog.info(...normalizedArgs); + } else { + this.channelLog.verbose(...normalizedArgs); + } + break; + case "debug": + this.channelLog.debug(...normalizedArgs); + break; + case "trace": + this.channelLog.debug(...normalizedArgs); + break; + } + + if (isReadyLog) { + settleReady(); + return; + } + + if (isStartupFailure) { + settleError(formatFeishuStartupError(text, platform, platformConfigured)); + } + }; + + const platformName = platform === "lark" ? "Lark" : "Feishu"; + const timeoutId = setTimeout(() => { + if (hasStartResolved) { + this.channelLog.warn( + `${platformName} WSClient.start() resolved before the ready log was observed. Treating start() resolution as a weak success signal; re-check websocket log markers after upgrading @larksuiteoapi/node-sdk@1.42.0.`, + ); + settleReady(); + return; + } + + settleError( + `Timed out waiting for ${platformName} websocket connection. Verify the app is self-built, long connection is enabled in the correct developer console, and the selected platform matches your tenant.`, + ); + }, FeishuAdapter.WS_STARTUP_TIMEOUT_MS); + + return { + readyPromise, + cancel, + markStartResolved, + logger: { + error: (...args: unknown[]) => handleLog("error", args), + warn: (...args: unknown[]) => handleLog("warn", args), + info: (...args: unknown[]) => handleLog("info", args), + debug: (...args: unknown[]) => handleLog("debug", args), + trace: (...args: unknown[]) => handleLog("trace", args), + }, + }; + } + // --- Lifecycle --- async start(config: ChannelConfig): Promise { if (this.status === "running") { - feishuLog.warn("Feishu adapter already running, stopping first"); + this.channelLog.warn(`${this.getPlatformName()} adapter already running, stopping first`); await this.stop(); } @@ -101,28 +337,33 @@ export class FeishuAdapter extends ChannelAdapter { this.emit("status.changed", this.status); // Merge config - this.config = { - ...DEFAULT_FEISHU_CONFIG, - ...(config.options as unknown as Partial), - }; + const options = (config.options as Partial | undefined) ?? {}; + const platformConfigured = options.platform === "feishu" || options.platform === "lark"; + this.config = this.mergeConfig(DEFAULT_FEISHU_CONFIG, options); if (!this.config.appId || !this.config.appSecret) { this.status = "error"; this.error = "Missing appId or appSecret"; this.emit("status.changed", this.status); - throw new Error("Feishu appId and appSecret are required"); + throw new Error(`${this.getPlatformName()} appId and appSecret are required`); } + let wsStartup: WsStartupMonitor | null = null; + try { + const domain = getLarkDomain(this.config.platform); + wsStartup = this.createWsStartupMonitor(this.config.platform, platformConfigured); + // 1. Create Lark REST client this.larkClient = new lark.Client({ appId: this.config.appId, appSecret: this.config.appSecret, + domain, disableTokenCache: false, }); // 1b. Create transport and streaming controller - this.transport = new FeishuTransport(this.larkClient, this.rateLimiter); + this.transport = new FeishuTransport(this.larkClient, this.rateLimiter, this.channelLog); this.streamingController = new StreamingController( this.transport, this.renderer, @@ -137,35 +378,35 @@ export class FeishuAdapter extends ChannelAdapter { try { await this.handleFeishuMessage(data as FeishuMessageEvent); } catch (err) { - feishuLog.error("Error handling Feishu message:", err); + this.channelLog.error(`Error handling ${this.getPlatformName()} message:`, err); } }, "application.bot.menu_v6": async (data: unknown) => { try { await this.handleBotMenuEvent(data as FeishuBotMenuEvent); } catch (err) { - feishuLog.error("Error handling bot menu event:", err); + this.channelLog.error("Error handling bot menu event:", err); } }, "im.chat.disbanded_v1": async (data: unknown) => { try { await this.handleGroupDisbanded(data as FeishuChatDisbandedEvent); } catch (err) { - feishuLog.error("Error handling group disbanded event:", err); + this.channelLog.error("Error handling group disbanded event:", err); } }, "im.chat.member.bot.deleted_v1": async (data: unknown) => { try { await this.handleBotRemovedFromGroup(data as FeishuBotRemovedEvent); } catch (err) { - feishuLog.error("Error handling bot removed event:", err); + this.channelLog.error("Error handling bot removed event:", err); } }, "im.chat.member.user.deleted_v1": async (data: unknown) => { try { await this.handleUserRemovedFromGroup(data as FeishuUserRemovedEvent); } catch (err) { - feishuLog.error("Error handling user removed event:", err); + this.channelLog.error("Error handling user removed event:", err); } }, // Suppress warnings for events we don't handle @@ -177,18 +418,23 @@ export class FeishuAdapter extends ChannelAdapter { this.wsClient = new lark.WSClient({ appId: this.config.appId, appSecret: this.config.appSecret, - loggerLevel: lark.LoggerLevel.warn, + domain, + loggerLevel: lark.LoggerLevel.info, + logger: wsStartup.logger, }); await this.wsClient.start({ eventDispatcher: dispatcher }); - feishuLog.info("Feishu WSClient connected to cloud"); + wsStartup.markStartResolved(); + await wsStartup.readyPromise; + this.channelLog.info(`${this.getPlatformName()} WSClient connected to cloud`); // 4. Connect to local Gateway this.gatewayClient = new GatewayWsClient(this.config.gatewayUrl); await this.gatewayClient.connect(); - feishuLog.info("Gateway WS client connected"); + this.channelLog.info("Gateway WS client connected"); // 5. Restore persisted group bindings from disk + this.sessionMapper.setLogger(this.channelLog); this.sessionMapper.loadBindings(); // 6. Subscribe to Gateway notifications @@ -197,12 +443,14 @@ export class FeishuAdapter extends ChannelAdapter { this.status = "running"; this.emit("status.changed", this.status); this.emit("connected"); - feishuLog.info("Feishu adapter started successfully"); + this.channelLog.info(`${this.getPlatformName()} adapter started successfully`); } catch (err) { + wsStartup?.cancel(); + const normalizedMessage = formatFeishuStartupError(err, this.config.platform, platformConfigured); this.status = "error"; - this.error = err instanceof Error ? err.message : String(err); + this.error = normalizedMessage; this.emit("status.changed", this.status); - feishuLog.error("Failed to start Feishu adapter:", err); + this.channelLog.error(`Failed to start ${this.getPlatformName()} adapter:`, err); // Clean up partial init (preserve error state) const savedStatus = this.status; const savedError = this.error; @@ -215,7 +463,7 @@ export class FeishuAdapter extends ChannelAdapter { } async stop(): Promise { - feishuLog.info("Stopping Feishu adapter..."); + this.channelLog.info(`Stopping ${this.getPlatformName()} adapter...`); // Clean up streaming timers this.sessionMapper.cleanup(); @@ -226,10 +474,15 @@ export class FeishuAdapter extends ChannelAdapter { this.gatewayClient = null; } - // Disconnect Feishu WSClient - // Note: lark.WSClient doesn't have a clean stop/close API in all versions. - // Setting to null allows GC. - this.wsClient = null; + // Disconnect Feishu WSClient and stop its reconnect loop. + if (this.wsClient) { + try { + this.wsClient.close({ force: true }); + } catch (err) { + this.channelLog.warn(`Failed to close ${this.getPlatformName()} WSClient cleanly:`, err); + } + this.wsClient = null; + } this.larkClient = null; this.transport = null; this.streamingController = null; @@ -238,13 +491,13 @@ export class FeishuAdapter extends ChannelAdapter { this.error = undefined; this.emit("status.changed", this.status); this.emit("disconnected", "stopped"); - feishuLog.info("Feishu adapter stopped"); + this.channelLog.info(`${this.getPlatformName()} adapter stopped`); } getInfo(): ChannelInfo { return { type: this.channelType, - name: "Feishu Bot", + name: this.getChannelDisplayName(), status: this.status, error: this.error, }; @@ -253,22 +506,24 @@ export class FeishuAdapter extends ChannelAdapter { async updateConfig(config: Partial): Promise { const wasRunning = this.status === "running"; const newOptions = config.options as Partial | undefined; + const previousConfig = { ...this.config }; if (newOptions) { - this.config = { ...this.config, ...newOptions }; + this.config = this.mergeConfig(this.config, newOptions); } - // If credentials changed while running, restart - if (wasRunning && newOptions && (newOptions.appId || newOptions.appSecret)) { - feishuLog.info("Credentials changed, restarting Feishu adapter"); - await this.stop(); + const shouldRestart = wasRunning && this.shouldRestartAfterConfigUpdate(previousConfig, newOptions); + + // If credentials or platform changed while running, restart + if (shouldRestart) { + this.channelLog.info(`Credentials or platform changed, restarting ${this.getPlatformName()} adapter`); const fullConfig: ChannelConfig = { type: "feishu", - name: "Feishu Bot", + name: this.getChannelDisplayName(), enabled: true, options: this.config as unknown as Record, }; - await this.start(fullConfig); + await this.restartAfterConfigUpdate(fullConfig); } } @@ -315,13 +570,13 @@ export class FeishuAdapter extends ChannelAdapter { // Skip non-text messages if (message_type !== "text") { - feishuLog.verbose(`Ignoring non-text message type: ${message_type}`); + this.channelLog.verbose(`Ignoring non-text message type: ${message_type}`); return; } // Deduplication if (this.sessionMapper.isDuplicate(message_id)) { - feishuLog.verbose(`Skipping duplicate message: ${message_id}`); + this.channelLog.verbose(`Skipping duplicate message: ${message_id}`); return; } @@ -338,7 +593,7 @@ export class FeishuAdapter extends ChannelAdapter { text = text.replace(/@_user_\d+/g, "").trim(); if (!text) return; - feishuLog.info(`Message from ${chat_type} chat ${chat_id}: ${text.slice(0, 100)}`); + this.channelLog.info(`Message from ${chat_type} chat ${chat_id}: ${text.slice(0, 100)}`); if (chat_type === "p2p") { // Record open_id → chat_id mapping for bot menu events @@ -350,7 +605,7 @@ export class FeishuAdapter extends ChannelAdapter { const pendingByOpenId = this.sessionMapper.takePendingSelectionByOpenId(sender.sender_id.open_id); if (pendingByOpenId) { this.sessionMapper.setPendingSelection(chat_id, pendingByOpenId); - feishuLog.info(`Transferred pending selection from openId=${sender.sender_id.open_id} to chat=${chat_id}`); + this.channelLog.info(`Transferred pending selection from openId=${sender.sender_id.open_id} to chat=${chat_id}`); } } await this.handleP2PMessage(chat_id, text); @@ -381,7 +636,7 @@ export class FeishuAdapter extends ChannelAdapter { questionId: pendingQ.questionId, answers: [[text]], }); - feishuLog.info(`Replied to question ${pendingQ.questionId} with freeform answer`); + this.channelLog.info(`Replied to question ${pendingQ.questionId} with freeform answer`); return; } @@ -644,7 +899,7 @@ export class FeishuAdapter extends ChannelAdapter { ): Promise { if (!this.gatewayClient || !this.transport || !this.streamingController) { tempSession.processing = false; - feishuLog.error("Gateway client not connected, cannot send P2P message"); + this.channelLog.error("Gateway client not connected, cannot send P2P message"); return; } @@ -653,7 +908,7 @@ export class FeishuAdapter extends ChannelAdapter { if (!this.streamingController.isBatchMode) { platformMsgId = await this.transport.sendText(chatId, "🤔 思考中..."); if (!platformMsgId) { - feishuLog.error("Failed to send P2P thinking message"); + this.channelLog.error("Failed to send P2P thinking message"); await this.processP2PQueue(chatId); return; } @@ -675,7 +930,7 @@ export class FeishuAdapter extends ChannelAdapter { streaming.messageId = msg.id; }) .catch(async (err) => { - feishuLog.error("P2P sendMessage failed:", err); + this.channelLog.error("P2P sendMessage failed:", err); tempSession.streamingSession = undefined; if (platformMsgId) { this.transport!.updateText( @@ -706,7 +961,7 @@ export class FeishuAdapter extends ChannelAdapter { } try { await this.gatewayClient?.deleteSession(temp.conversationId); - feishuLog.info(`Deleted expired temp session: ${temp.conversationId}`); + this.channelLog.info(`Deleted expired temp session: ${temp.conversationId}`); } catch { // Ignore deletion failures for temp sessions } @@ -842,7 +1097,7 @@ export class FeishuAdapter extends ChannelAdapter { questionId: pendingQ.questionId, answers: [[text]], }); - feishuLog.info(`Replied to question ${pendingQ.questionId} with freeform answer`); + this.channelLog.info(`Replied to question ${pendingQ.questionId} with freeform answer`); return; } @@ -960,13 +1215,13 @@ export class FeishuAdapter extends ChannelAdapter { `Session already has a group chat. Check your Feishu groups.`, ); } - feishuLog.warn(`Conversation ${conversationId} already has group ${existingChatId}`); + this.channelLog.warn(`Conversation ${conversationId} already has group ${existingChatId}`); return; } // Concurrency guard — prevent duplicate group creation from rapid clicks if (!this.sessionMapper.markCreating(conversationId)) { - feishuLog.warn(`Conversation ${conversationId} group creation already in progress`); + this.channelLog.warn(`Conversation ${conversationId} group creation already in progress`); return; } @@ -994,14 +1249,14 @@ export class FeishuAdapter extends ChannelAdapter { const newChatId = (createRes as any)?.data?.chat_id; if (!newChatId) { - feishuLog.error("Failed to create group chat: no chat_id returned"); + this.channelLog.error("Failed to create group chat: no chat_id returned"); if (p2pChatId) { await this.transport.sendText(p2pChatId, "Failed to create group chat. Please try again."); } return; } - feishuLog.info(`Created group chat: ${newChatId} for conversation ${conversationId}`); + this.channelLog.info(`Created group chat: ${newChatId} for conversation ${conversationId}`); // Register group binding this.sessionMapper.createGroupBinding({ @@ -1027,7 +1282,7 @@ export class FeishuAdapter extends ChannelAdapter { ); } } catch (err) { - feishuLog.error("Failed to create group for session:", err); + this.channelLog.error("Failed to create group for session:", err); if (p2pChatId) { await this.transport.sendText( p2pChatId, @@ -1047,7 +1302,7 @@ export class FeishuAdapter extends ChannelAdapter { const eventKey = event.event_key; const openId = event.operator?.operator_id?.open_id; - feishuLog.info(`Bot menu event: key=${eventKey}, operator=${openId}, raw=${JSON.stringify(event).slice(0, 200)}`); + this.channelLog.info(`Bot menu event: key=${eventKey}, operator=${openId}, raw=${JSON.stringify(event).slice(0, 200)}`); if (!eventKey || !openId) return; @@ -1103,7 +1358,7 @@ export class FeishuAdapter extends ChannelAdapter { } default: - feishuLog.warn(`Unknown bot menu event_key: ${eventKey}`); + this.channelLog.warn(`Unknown bot menu event_key: ${eventKey}`); } } @@ -1136,14 +1391,14 @@ export class FeishuAdapter extends ChannelAdapter { private async cleanupGroupResources(chatId: string | undefined, reason: string): Promise { if (!chatId) return; - feishuLog.info(`${reason}: ${chatId}`); + this.channelLog.info(`${reason}: ${chatId}`); const binding = this.sessionMapper.removeGroupBinding(chatId); if (binding && this.gatewayClient) { try { await this.gatewayClient.deleteSession(binding.conversationId); - feishuLog.info(`Deleted session ${binding.conversationId} after ${reason}`); + this.channelLog.info(`Deleted session ${binding.conversationId} after ${reason}`); } catch (err) { - feishuLog.error(`Failed to delete session ${binding.conversationId}:`, err); + this.channelLog.error(`Failed to delete session ${binding.conversationId}:`, err); } } } @@ -1164,7 +1419,7 @@ export class FeishuAdapter extends ChannelAdapter { if (!this.streamingController.isBatchMode) { platformMsgId = await this.transport.sendText(groupChatId, "🤔 思考中..."); if (!platformMsgId) { - feishuLog.error("Failed to send initial thinking message"); + this.channelLog.error("Failed to send initial thinking message"); return; } } @@ -1189,7 +1444,7 @@ export class FeishuAdapter extends ChannelAdapter { this.sessionMapper.registerStreamingSession(groupChatId, msg.id, streamingSession); }) .catch((err) => { - feishuLog.error("sendMessage failed:", err); + this.channelLog.error("sendMessage failed:", err); binding.streamingSessions.delete(placeholderKey); if (platformMsgId) { this.transport!.updateText( @@ -1324,7 +1579,7 @@ export class FeishuAdapter extends ChannelAdapter { ); if (acceptOption) { - feishuLog.info(`Auto-approving permission: ${permission.id}`); + this.channelLog.info(`Auto-approving permission: ${permission.id}`); this.gatewayClient.replyPermission({ permissionId: permission.id, optionId: acceptOption.id, @@ -1342,6 +1597,29 @@ export class FeishuAdapter extends ChannelAdapter { } if (!targetChatId) return; + // For plan review questions (ExitPlanMode), flush the streaming text so + // the user can see the full plan content before deciding to approve/reject. + if (question.questions?.[0]?.header === "Plan Review" && this.streamingController) { + const binding = this.sessionMapper.findGroupByConversationId(question.sessionId); + if (binding) { + for (const ss of binding.streamingSessions.values()) { + if (ss.conversationId === question.sessionId && !ss.completed && ss.textBuffer) { + void this.streamingController.flushAsIntermediateReply(ss); + break; + } + } + } else { + // Try P2P temp session + const p2pChatId = this.sessionMapper.findP2PChatByTempConversation(question.sessionId); + if (p2pChatId) { + const tempSession = this.sessionMapper.getTempSession(p2pChatId); + if (tempSession?.streamingSession && !tempSession.streamingSession.completed && tempSession.streamingSession.textBuffer) { + void this.streamingController.flushAsIntermediateReply(tempSession.streamingSession); + } + } + } + } + // UnifiedQuestion has questions: QuestionInfo[], each with question text and options if (question.questions && question.questions.length > 0) { const q = question.questions[0]; // Handle first question @@ -1372,6 +1650,12 @@ export class FeishuAdapter extends ChannelAdapter { const binding = this.sessionMapper.getGroupBinding(groupChatId); if (!binding) return; + // Skip group name update if the event carries no title (e.g. Claude engine + // emits session.updated solely to sync engineMeta/ccSessionId, without a + // title field — blindly falling back to "New Session" would overwrite any + // meaningful title that was already set). + if (!session.title) return; + // Update streaming session titles for any active streams for (const ss of binding.streamingSessions.values()) { if (!ss.completed) { @@ -1383,8 +1667,7 @@ export class FeishuAdapter extends ChannelAdapter { const projectName = binding.directory.split(/[\\/]/).pop() || binding.directory; // Build the expected group name - const newTitle = session.title || "New Session"; - const expectedGroupName = `[${projectName}] ${newTitle}`; + const expectedGroupName = `[${projectName}] ${session.title}`; // Update the Feishu group chat name try { @@ -1393,9 +1676,9 @@ export class FeishuAdapter extends ChannelAdapter { path: { chat_id: groupChatId }, data: { name: expectedGroupName }, }); - feishuLog.info(`Updated group chat name: ${groupChatId} → "${expectedGroupName}"`); + this.channelLog.info(`Updated group chat name: ${groupChatId} → "${expectedGroupName}"`); } catch (err) { - feishuLog.error(`Failed to update group chat name for ${groupChatId}:`, err); + this.channelLog.error(`Failed to update group chat name for ${groupChatId}:`, err); } } } diff --git a/electron/main/channels/feishu/feishu-platform.ts b/electron/main/channels/feishu/feishu-platform.ts new file mode 100644 index 00000000..2612a8d8 --- /dev/null +++ b/electron/main/channels/feishu/feishu-platform.ts @@ -0,0 +1,32 @@ +import * as lark from "@larksuiteoapi/node-sdk"; +import type { FeishuPlatform } from "./feishu-types"; + +export function normalizeFeishuPlatform(value: unknown): FeishuPlatform { + return value === "lark" ? "lark" : "feishu"; +} + +export function getLarkDomain(platform: FeishuPlatform): lark.Domain { + return platform === "lark" ? lark.Domain.Lark : lark.Domain.Feishu; +} + +export function formatFeishuStartupError( + error: unknown, + platform: FeishuPlatform, + platformConfigured = true, +): string { + const message = error instanceof Error ? error.message : String(error); + + if (message.startsWith("Failed to connect to") || message.startsWith("Timed out waiting for")) { + return message; + } + + if (message.includes("PingInterval") || message.includes("system busy")) { + const platformName = platform === "lark" ? "Lark" : "Feishu"; + const platformHint = !platformConfigured && platform === "feishu" + ? " If this is a Lark app from open.larksuite.com, open Configure and switch Platform to Lark, then save." + : ""; + return `Failed to connect to ${platformName} long connection. Verify the app is a self-built app, long connection is enabled in the correct developer console, and the selected platform matches your tenant.${platformHint} Original error: ${message}`; + } + + return message; +} diff --git a/electron/main/channels/feishu/feishu-session-mapper.ts b/electron/main/channels/feishu/feishu-session-mapper.ts index 0239a583..1ad1053d 100644 --- a/electron/main/channels/feishu/feishu-session-mapper.ts +++ b/electron/main/channels/feishu/feishu-session-mapper.ts @@ -10,7 +10,7 @@ import path from "path"; import { app } from "electron"; import type { EngineType } from "../../../../src/types/unified"; import type { GroupBinding, P2PChatState, PendingQuestion, PendingSelection, StreamingSession, TempSession } from "./feishu-types"; -import { feishuLog } from "../../services/logger"; +import { feishuLog, type ScopedLogger } from "../../services/logger"; // --- Persistence helpers --- @@ -33,6 +33,16 @@ function getBindingsFilePath(): string { } export class FeishuSessionMapper { + private log: ScopedLogger; + + constructor(log: ScopedLogger = feishuLog) { + this.log = log; + } + + setLogger(log: ScopedLogger): void { + this.log = log; + } + // --- Group Bindings (One Group = One Session) --- /** groupChatId → GroupBinding */ @@ -97,9 +107,9 @@ export class FeishuSessionMapper { this.groupBindings.set(binding.chatId, binding); this.conversationToGroupIndex.set(binding.conversationId, binding.chatId); } - feishuLog.info(`Loaded ${items.length} persisted group bindings`); + this.log.info(`Loaded ${items.length} persisted group bindings`); } catch (err) { - feishuLog.error("Failed to load group bindings:", err); + this.log.error("Failed to load group bindings:", err); } } @@ -129,7 +139,7 @@ export class FeishuSessionMapper { fs.writeFileSync(tmpPath, JSON.stringify(items, null, 2)); fs.renameSync(tmpPath, filePath); } catch (err) { - feishuLog.error("Failed to save group bindings:", err); + this.log.error("Failed to save group bindings:", err); } } @@ -142,7 +152,7 @@ export class FeishuSessionMapper { this.groupBindings.set(binding.chatId, binding); this.conversationToGroupIndex.set(binding.conversationId, binding.chatId); this.saveBindings(); - feishuLog.info( + this.log.info( `Created group binding: chat=${binding.chatId} → conversation=${binding.conversationId} (${binding.engineType}:${binding.projectId})`, ); } @@ -194,7 +204,7 @@ export class FeishuSessionMapper { this.groupBindings.delete(groupChatId); this.saveBindings(); - feishuLog.info( + this.log.info( `Removed group binding: chat=${groupChatId} (conversation=${binding.conversationId})`, ); return binding; @@ -210,7 +220,7 @@ export class FeishuSessionMapper { */ markCreating(conversationId: string): boolean { if (this.creatingGroups.has(conversationId)) { - feishuLog.warn(`Conversation ${conversationId} is already being created, skipping`); + this.log.warn(`Conversation ${conversationId} is already being created, skipping`); return false; } this.creatingGroups.add(conversationId); @@ -232,7 +242,7 @@ export class FeishuSessionMapper { if (!state) { state = { chatId, openId }; this.p2pChats.set(chatId, state); - feishuLog.info(`Created P2P chat state: chat=${chatId} openId=${openId}`); + this.log.info(`Created P2P chat state: chat=${chatId} openId=${openId}`); } return state; } @@ -250,7 +260,7 @@ export class FeishuSessionMapper { const state = this.p2pChats.get(chatId); if (state) { state.lastSelectedProject = project; - feishuLog.info( + this.log.info( `P2P chat ${chatId} last project: ${project.projectId} (${project.engineType})`, ); } @@ -328,7 +338,7 @@ export class FeishuSessionMapper { if (binding) { binding.streamingSessions.set(messageId, session); } else { - feishuLog.warn( + this.log.warn( `Cannot register streaming session: group ${groupChatId} not found`, ); } @@ -366,7 +376,7 @@ export class FeishuSessionMapper { } state.tempSession = tempSession; this.tempConversationToChat.set(tempSession.conversationId, chatId); - feishuLog.info( + this.log.info( `P2P chat ${chatId} temp session: ${tempSession.conversationId} (${tempSession.engineType})`, ); } @@ -387,7 +397,7 @@ export class FeishuSessionMapper { } this.tempConversationToChat.delete(state.tempSession.conversationId); state.tempSession = undefined; - feishuLog.info(`P2P chat ${chatId} temp session cleared`); + this.log.info(`P2P chat ${chatId} temp session cleared`); } } @@ -403,7 +413,7 @@ export class FeishuSessionMapper { /** Set a pending question for a chat (group or P2P) */ setPendingQuestion(chatId: string, question: PendingQuestion): void { this.pendingQuestions.set(chatId, question); - feishuLog.info(`Set pending question for chat ${chatId}: ${question.questionId}`); + this.log.info(`Set pending question for chat ${chatId}: ${question.questionId}`); } /** Get the pending question for a chat */ diff --git a/electron/main/channels/feishu/feishu-transport.ts b/electron/main/channels/feishu/feishu-transport.ts index bb158203..6ab8c25a 100644 --- a/electron/main/channels/feishu/feishu-transport.ts +++ b/electron/main/channels/feishu/feishu-transport.ts @@ -7,12 +7,13 @@ import type * as lark from "@larksuiteoapi/node-sdk"; import type { MessageTransport } from "../streaming/message-transport"; import type { TokenBucket } from "../streaming/rate-limiter"; -import { feishuLog } from "../../services/logger"; +import { feishuLog, type ScopedLogger } from "../../services/logger"; export class FeishuTransport implements MessageTransport { constructor( private larkClient: lark.Client, private rateLimiter: TokenBucket, + private log: ScopedLogger = feishuLog, ) {} async sendText(chatId: string, text: string): Promise { @@ -28,7 +29,7 @@ export class FeishuTransport implements MessageTransport { }); return (res as any)?.data?.message_id ?? ""; } catch (err) { - feishuLog.error("Failed to send text message:", err); + this.log.error("Failed to send text message:", err); return ""; } } @@ -46,7 +47,7 @@ export class FeishuTransport implements MessageTransport { }, }); } catch (err) { - feishuLog.error(`Failed to update message ${messageId}:`, err); + this.log.error(`Failed to update message ${messageId}:`, err); } } @@ -72,7 +73,7 @@ export class FeishuTransport implements MessageTransport { }); return (res as any)?.data?.message_id ?? ""; } catch (err) { - feishuLog.error("Failed to send card message:", err); + this.log.error("Failed to send card message:", err); return ""; } } @@ -99,7 +100,7 @@ export class FeishuTransport implements MessageTransport { }); return (res as any)?.data?.message_id ?? ""; } catch (err) { - feishuLog.error(`Failed to send message (${receiveIdType}=${receiveId}):`, err); + this.log.error(`Failed to send message (${receiveIdType}=${receiveId}):`, err); return ""; } } diff --git a/electron/main/channels/feishu/feishu-types.ts b/electron/main/channels/feishu/feishu-types.ts index d86d26de..3fa0c3a3 100644 --- a/electron/main/channels/feishu/feishu-types.ts +++ b/electron/main/channels/feishu/feishu-types.ts @@ -12,12 +12,16 @@ import { GATEWAY_PORT } from "../../../../shared/ports"; export type { StreamingSession } from "../streaming/streaming-types"; export { createStreamingSession } from "../streaming/streaming-types"; -// --- Feishu Configuration --- +// --- Feishu / Lark Configuration --- + +export type FeishuPlatform = "feishu" | "lark"; export interface FeishuConfig { - /** Feishu Open Platform App ID */ + /** Feishu or Lark developer console platform */ + platform: FeishuPlatform; + /** Feishu / Lark Open Platform App ID */ appId: string; - /** Feishu Open Platform App Secret */ + /** Feishu / Lark Open Platform App Secret */ appSecret: string; /** Auto-approve all permission requests from engines */ autoApprovePermissions: boolean; @@ -28,6 +32,7 @@ export interface FeishuConfig { } export const DEFAULT_FEISHU_CONFIG: FeishuConfig = { + platform: "feishu", appId: "", appSecret: "", autoApprovePermissions: true, diff --git a/electron/main/channels/streaming/streaming-controller.ts b/electron/main/channels/streaming/streaming-controller.ts index cbdb06e3..aac00790 100644 --- a/electron/main/channels/streaming/streaming-controller.ts +++ b/electron/main/channels/streaming/streaming-controller.ts @@ -175,6 +175,38 @@ export class StreamingController { } } + // ========================================================================= + // Intermediate Flush (for plan review, etc.) + // ========================================================================= + + /** + * Flush the current text buffer as a properly formatted update to the + * streaming message, WITHOUT marking the session as completed. + * + * Used when the engine pauses for user input (e.g. ExitPlanMode plan review) + * so the user can see the accumulated content before making a decision. + */ + async flushAsIntermediateReply(session: StreamingSession): Promise { + if (!session.textBuffer || session.completed || session.finalReplySent) return; + + // Cancel any pending throttled update to avoid a stale overwrite + if (session.patchTimer) { + clearTimeout(session.patchTimer); + session.patchTimer = null; + } + + // Update the existing streaming message with the full current text + if (this.capabilities.supportsMessageUpdate && session.platformMessageId) { + const rendered = this.renderer.renderStreamingUpdate(session.textBuffer); + const truncated = this.renderer.truncate(rendered); + try { + await this.transport.updateText(session.platformMessageId, truncated); + } catch (err: any) { + channelLog.error(`Failed to flush intermediate reply: ${err.message}`); + } + } + } + // ========================================================================= // Finalization // ========================================================================= diff --git a/electron/main/channels/teams/teams-adapter.ts b/electron/main/channels/teams/teams-adapter.ts index 494d008a..5b127a37 100644 --- a/electron/main/channels/teams/teams-adapter.ts +++ b/electron/main/channels/teams/teams-adapter.ts @@ -30,6 +30,7 @@ import { StreamingController } from "../streaming/streaming-controller"; import { TokenBucket } from "../streaming/rate-limiter"; import { BaseSessionMapper, type PersistedBinding } from "../base-session-mapper"; import { createStreamingSession, type StreamingSession } from "../streaming/streaming-types"; +import { didConfigValuesChange, mergeDefinedConfig } from "../config-utils"; import { TeamsTransport } from "./teams-transport"; import { TeamsRenderer } from "./teams-renderer"; import { ensureTeamsAppPackage } from "./teams-manifest"; @@ -155,10 +156,10 @@ export class TeamsAdapter extends ChannelAdapter { this.emit("status.changed", this.status); // Merge config - this.config = { - ...DEFAULT_TEAMS_CONFIG, - ...(config.options as unknown as Partial), - }; + this.config = mergeDefinedConfig( + DEFAULT_TEAMS_CONFIG, + config.options as Partial | undefined, + ); channelLog.info( `${LOG_PREFIX} Config: appId=${this.config.microsoftAppId}, tenantId=${this.config.tenantId || "(none)"}`, @@ -276,17 +277,20 @@ export class TeamsAdapter extends ChannelAdapter { async updateConfig(config: Partial): Promise { const wasRunning = this.status === "running"; const newOptions = config.options as Partial | undefined; + const previousConfig = { ...this.config }; if (newOptions) { - this.config = { ...this.config, ...newOptions }; + this.config = mergeDefinedConfig(this.config, newOptions); } - // If credentials changed while running, restart - if ( - wasRunning && - (newOptions?.microsoftAppId || newOptions?.microsoftAppPassword) - ) { - channelLog.info(`${LOG_PREFIX} Credentials changed, restarting adapter`); + const shouldRestart = wasRunning && didConfigValuesChange( + previousConfig, + this.config, + ["microsoftAppId", "microsoftAppPassword", "tenantId"], + ); + + if (shouldRestart) { + channelLog.info(`${LOG_PREFIX} Credentials or tenant changed, restarting adapter`); await this.stop(); const fullConfig: ChannelConfig = { type: "teams", diff --git a/electron/main/channels/telegram/telegram-adapter.ts b/electron/main/channels/telegram/telegram-adapter.ts index 080e910a..e94756f3 100644 --- a/electron/main/channels/telegram/telegram-adapter.ts +++ b/electron/main/channels/telegram/telegram-adapter.ts @@ -30,6 +30,7 @@ import { BaseSessionMapper } from "../base-session-mapper"; import { createStreamingSession, type StreamingSession } from "../streaming/streaming-types"; import { TelegramTransport } from "./telegram-transport"; import { TelegramRenderer } from "./telegram-renderer"; +import { didConfigValuesChange, mergeDefinedConfig } from "../config-utils"; import { parseCommand, buildHelpText, @@ -99,6 +100,9 @@ export class TelegramAdapter extends ChannelAdapter { private webhookServer: WebhookServer | null = null; private pollingActive = false; private pollingOffset = 0; + private pollingAbortController: AbortController | null = null; + private pollingLoopPromise: Promise | null = null; + private pollingGeneration = 0; private botUsername = ""; /** @@ -114,6 +118,12 @@ export class TelegramAdapter extends ChannelAdapter { supportsRichContent: true, maxMessageBytes: 16_384, }; + private static readonly POLLING_RETRY_DELAY_MS = 5_000; + + private isAbortError(error: unknown): boolean { + return typeof error === "object" && error !== null && "name" in error + && (error as { name?: unknown }).name === "AbortError"; + } // ============================================================================ // Lifecycle @@ -130,10 +140,11 @@ export class TelegramAdapter extends ChannelAdapter { this.emit("status.changed", this.status); // Merge config - this.config = { - ...DEFAULT_TELEGRAM_CONFIG, - ...(config.options as unknown as Partial), - }; + this.config = mergeDefinedConfig( + DEFAULT_TELEGRAM_CONFIG, + config.options as Partial | undefined, + ); + this.botUsername = ""; if (!this.config.botToken) { this.status = "error"; @@ -201,7 +212,7 @@ export class TelegramAdapter extends ChannelAdapter { channelLog.info(`${LOG_PREFIX} Stopping adapter...`); // Stop polling - this.pollingActive = false; + await this.stopPolling(); // Unregister webhook route (keep webhookServer ref for restart) if (this.webhookServer) { @@ -225,6 +236,7 @@ export class TelegramAdapter extends ChannelAdapter { // Clean up instances this.transport = null; this.streamingController = null; + this.botUsername = ""; this.status = "stopped"; this.error = undefined; @@ -249,14 +261,20 @@ export class TelegramAdapter extends ChannelAdapter { async updateConfig(config: Partial): Promise { const wasRunning = this.status === "running"; const newOptions = config.options as Partial | undefined; + const previousConfig = { ...this.config }; if (newOptions) { - this.config = { ...this.config, ...newOptions }; + this.config = mergeDefinedConfig(this.config, newOptions); } - // If botToken changed while running, restart - if (wasRunning && newOptions?.botToken) { - channelLog.info(`${LOG_PREFIX} Bot token changed, restarting adapter`); + const shouldRestart = wasRunning && didConfigValuesChange( + previousConfig, + this.config, + ["botToken", "webhookUrl", "webhookSecretToken"], + ); + + if (shouldRestart) { + channelLog.info(`${LOG_PREFIX} Bot connection settings changed, restarting adapter`); await this.stop(); const fullConfig: ChannelConfig = { type: "telegram", @@ -334,29 +352,77 @@ export class TelegramAdapter extends ChannelAdapter { this.pollingActive = true; this.pollingOffset = 0; + const generation = ++this.pollingGeneration; + const abortController = new AbortController(); + this.pollingAbortController = abortController; channelLog.info(`${LOG_PREFIX} Starting long polling...`); // Start polling loop (non-blocking) - void this.pollingLoop(); + const pollingLoopPromise = this.pollingLoop(generation, abortController.signal); + const trackedPollingLoopPromise = pollingLoopPromise.finally(() => { + if (this.pollingLoopPromise === trackedPollingLoopPromise) { + this.pollingLoopPromise = null; + } + if (this.pollingAbortController === abortController) { + this.pollingAbortController = null; + } + }); + this.pollingLoopPromise = trackedPollingLoopPromise; } - private async pollingLoop(): Promise { - while (this.pollingActive && this.transport) { + private async stopPolling(): Promise { + this.pollingActive = false; + this.pollingGeneration += 1; + this.pollingAbortController?.abort(); + this.pollingAbortController = null; + + const pollingLoopPromise = this.pollingLoopPromise; + this.pollingLoopPromise = null; + + if (!pollingLoopPromise) { + return; + } + + try { + await pollingLoopPromise; + } catch (error) { + if (!this.isAbortError(error)) { + channelLog.warn(`${LOG_PREFIX} Polling loop exited with an unexpected error during shutdown:`, error); + } + } + } + + private async pollingLoop(generation: number, signal: AbortSignal): Promise { + while (this.pollingActive && this.transport && generation === this.pollingGeneration) { try { const updates = await this.transport.getUpdates( this.pollingOffset || undefined, 30, + signal, ); + if (!this.pollingActive || generation !== this.pollingGeneration) { + break; + } + for (const update of updates) { + if (!this.pollingActive || generation !== this.pollingGeneration) { + break; + } void this.processUpdate(update as TelegramUpdate); this.pollingOffset = (update as TelegramUpdate).update_id + 1; } } catch (err) { + if (this.isAbortError(err) || !this.pollingActive || generation !== this.pollingGeneration) { + break; + } channelLog.error(`${LOG_PREFIX} Polling error:`, err); // Wait before retrying on error - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, TelegramAdapter.POLLING_RETRY_DELAY_MS)); + if (!this.pollingActive || generation !== this.pollingGeneration) { + break; + } } } } diff --git a/electron/main/channels/telegram/telegram-transport.ts b/electron/main/channels/telegram/telegram-transport.ts index b5e9b2c2..50685a4d 100644 --- a/electron/main/channels/telegram/telegram-transport.ts +++ b/electron/main/channels/telegram/telegram-transport.ts @@ -16,6 +16,11 @@ import { channelLog } from "../../services/logger"; const LOG_PREFIX = "[Telegram]"; +function isAbortError(error: unknown): boolean { + return typeof error === "object" && error !== null && "name" in error + && (error as { name?: unknown }).name === "AbortError"; +} + export class TelegramTransport implements MessageTransport { constructor( private botToken: string, @@ -283,15 +288,18 @@ export class TelegramTransport implements MessageTransport { /** * Get updates via long polling. */ - async getUpdates(offset?: number, timeout = 30): Promise { + async getUpdates(offset?: number, timeout = 30, signal?: AbortSignal): Promise { try { const params: Record = { timeout }; if (offset !== undefined) { params.offset = offset; } - const result = await this.callApi("getUpdates", params); + const result = await this.callApi("getUpdates", params, signal); return result?.result || []; } catch (err) { + if (isAbortError(err)) { + throw err; + } channelLog.error(`${LOG_PREFIX} Failed to get updates:`, err); return []; } @@ -320,11 +328,13 @@ export class TelegramTransport implements MessageTransport { private async callApi( method: string, params: Record, + signal?: AbortSignal, ): Promise { const res = await fetch(this.apiUrl(method), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), + signal, }); if (!res.ok) { diff --git a/electron/main/channels/wecom/wecom-adapter.ts b/electron/main/channels/wecom/wecom-adapter.ts index 1f3ca141..bffb8853 100644 --- a/electron/main/channels/wecom/wecom-adapter.ts +++ b/electron/main/channels/wecom/wecom-adapter.ts @@ -25,6 +25,7 @@ import { TokenManager } from "../streaming/token-manager"; import { TokenBucket } from "../streaming/rate-limiter"; import { createStreamingSession, type StreamingSession } from "../streaming/streaming-types"; import { BaseSessionMapper, type BaseGroupBinding, type BaseTempSession, type BasePendingSelection, type PersistedBinding } from "../base-session-mapper"; +import { didConfigValuesChange, mergeDefinedConfig } from "../config-utils"; import type { WebhookServer, WebhookRequest, WebhookResponse } from "../webhook-server"; import { WeComCrypto } from "./wecom-crypto"; import { WeComTransport } from "./wecom-transport"; @@ -153,10 +154,10 @@ export class WeComAdapter extends ChannelAdapter { this.error = undefined; this.emit("status.changed", this.status); - this.config = { - ...DEFAULT_WECOM_CONFIG, - ...(config.options as unknown as Partial), - }; + this.config = mergeDefinedConfig( + DEFAULT_WECOM_CONFIG, + config.options as Partial | undefined, + ); // Trim whitespace from all string config values to prevent subtle mismatches if (this.config.corpId) this.config.corpId = this.config.corpId.trim(); @@ -295,14 +296,20 @@ export class WeComAdapter extends ChannelAdapter { async updateConfig(config: Partial): Promise { const wasRunning = this.status === "running"; const newOptions = config.options as Partial | undefined; + const previousConfig = { ...this.config }; if (newOptions) { - this.config = { ...this.config, ...newOptions }; + this.config = mergeDefinedConfig(this.config, newOptions); } - // If credentials changed while running, restart - if (wasRunning && newOptions && (newOptions.corpId || newOptions.corpSecret)) { - channelLog.info("[WeCom] Credentials changed, restarting adapter"); + const shouldRestart = wasRunning && didConfigValuesChange( + previousConfig, + this.config, + ["corpId", "corpSecret", "agentId", "callbackToken", "callbackEncodingAESKey"], + ); + + if (shouldRestart) { + channelLog.info("[WeCom] Connection settings changed, restarting adapter"); await this.stop(); const fullConfig: ChannelConfig = { type: "wecom", diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index b2ac9367..756ded9d 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -50,6 +50,8 @@ import type { StepStartPart, StepFinishPart, QuestionInfo, + EngineCommand, + CommandInvokeResult, } from "../../../../src/types/unified"; import { sdkSessionToUnified, convertSdkMessages } from "./converters"; @@ -124,6 +126,13 @@ export class ClaudeCodeAdapter extends EngineAdapter { // --- V2 Sessions (persistent, process reuse) --- private v2Sessions = new Map(); + // --- Slash commands --- + private availableCommands: EngineCommand[] = []; + /** Cached user-installed skill names, for system prompt injection */ + private cachedSkillNames: string[] = []; + /** In-flight warmup promise — prevents concurrent warmups and lets listCommands await it */ + private warmupPromise: Promise | null = null; + // --- State --- private status: EngineStatus = "stopped"; private lastError: string | undefined; @@ -321,14 +330,47 @@ export class ClaudeCodeAdapter extends EngineAdapter { await Promise.race([Promise.allSettled(interruptPromises), hardTimeout]); } - // Now close all V2 sessions cleanly + // Now close all V2 sessions. + // + // session.close() internally schedules abort after 5s via setTimeout().unref(), + // and transport.close() then schedules SIGTERM after another 2s (also .unref()). + // Since .unref() timers don't prevent the event loop from exiting, app.exit(0) + // tears down the NAPI modules while the CLI subprocess is still alive and + // sending callbacks through its NAPI threadsafe function — causing the fatal + // "napi_ref_threadsafe_function" crash during node::FreeEnvironment. + // + // Fix: directly kill the subprocess BEFORE calling session.close(), then wait + // for it to actually exit. This guarantees no NAPI callbacks are in flight + // when app.exit(0) destroys the native module. + const exitPromises: Promise[] = []; for (const [sessionId, info] of this.v2Sessions) { try { + const transport = (info.session as any)?.query?.transport; + const proc = transport?.process as import("child_process").ChildProcess | undefined; + if (proc && proc.exitCode === null && !proc.killed) { + const exitPromise = new Promise((resolve) => { + proc.once("exit", () => resolve()); + // Safety: resolve anyway after 3s if the process doesn't exit + setTimeout(resolve, 3000).unref(); + }); + proc.kill("SIGTERM"); + exitPromises.push(exitPromise); + claudeLog.info(`[Claude][${sessionId}] Sent SIGTERM to CLI subprocess (pid=${proc.pid})`); + } info.session.close(); } catch (e) { claudeLog.warn(`Error closing Claude session ${sessionId}:`, e); } } + + // Wait for all CLI subprocesses to actually exit before returning. + // This ensures no NAPI threadsafe function callbacks are pending when + // app.exit(0) destroys the native module environment. + if (exitPromises.length > 0) { + await Promise.all(exitPromises); + claudeLog.info("All CLI subprocesses exited"); + } + this.v2Sessions.clear(); // Abort any remaining active requests (sessions without V2 info) @@ -385,6 +427,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { modelSwitchable: true, customModelInput: true, messageEnqueue: true, + slashCommands: true, availableModes: this.getModes(), }; } @@ -446,6 +489,9 @@ export class ClaudeCodeAdapter extends EngineAdapter { } this.emit("session.created", { session }); + // Warm up in background — store the promise so listCommands() can await it. + this.triggerWarmup(directory); + return session; } @@ -1182,9 +1228,15 @@ export class ClaudeCodeAdapter extends EngineAdapter { return new Promise((resolve) => { this.pendingQuestions.set(questionId, { resolve: (answer: string) => { + const trimmed = answer.trim(); + const lower = trimmed.toLowerCase(); const approved = - answer.toLowerCase().includes("approve") || - answer === "0"; // first option index + lower.includes("approve") || + trimmed.includes("同意") || + trimmed.includes("批准") || + trimmed.includes("确认") || + trimmed === "1" || // 1-based: first option = Approve (Feishu/DingTalk display) + trimmed === "0"; // 0-based: backward compat with frontend UI if (approved) { resolve({ behavior: "allow", updatedInput: input }); } else { @@ -1329,6 +1381,68 @@ export class ClaudeCodeAdapter extends EngineAdapter { return []; } + // ========================================================================== + // Slash Commands + // ========================================================================== + + override async listCommands(sessionId?: string, directory?: string): Promise { + // Fast path: commands already populated + if (this.availableCommands.length > 0) return this.availableCommands; + + // Commands not yet available — trigger warmup if not already running, + // then await it so the first listCommands() call returns real data + // instead of a hardcoded fallback. + const dir = directory || (sessionId && this.sessionDirectories.get(sessionId)) || "."; + this.triggerWarmup(dir); + + if (this.warmupPromise) { + try { + await this.warmupPromise; + } catch { + // warmup failed — fall through to fallback + } + } + + if (this.availableCommands.length > 0) return this.availableCommands; + + // Fallback: minimal list if warmup failed entirely + return [ + { name: "compact", description: "Compact conversation context" }, + { name: "context", description: "Show current context window usage" }, + { name: "cost", description: "Show token usage and cost" }, + ]; + } + + /** + * Trigger a warmup if one isn't already in progress and commands haven't + * been populated yet. Safe to call multiple times — deduplicates via + * warmupPromise. + */ + private triggerWarmup(directory: string): void { + if (this.availableCommands.length > 0) return; + if (this.warmupPromise) return; + + this.warmupPromise = this.warmupV2Session("warmup", directory) + .catch((err) => claudeLog.warn("[Claude] Warmup failed:", err)) + .finally(() => { this.warmupPromise = null; }); + } + + override async invokeCommand( + sessionId: string, + commandName: string, + args: string, + options?: { mode?: string; modelId?: string; directory?: string }, + ): Promise { + // Claude Code processes slash commands as inline text + const commandText = `/${commandName}${args ? ` ${args}` : ""}`; + const message = await this.sendMessage( + sessionId, + [{ type: "text", text: commandText }], + options, + ); + return { handledAsCommand: true, message }; + } + // ========================================================================== // V2 Session Management // ========================================================================== @@ -1415,32 +1529,68 @@ export class ClaudeCodeAdapter extends EngineAdapter { // We use 'as any' because the SDK v0.2.x SDKSessionOptions type is still // narrower than the internal Options type. The SDK internally passes these // through to ProcessTransport which accepts all Options fields. + + // Build system prompt append: identity + cached user skills + let promptAppend = CODEMUX_IDENTITY_PROMPT; + if (this.cachedSkillNames.length > 0) { + promptAppend += `\n\nThe user has installed the following additional skills (invokable via the Skill tool): ${this.cachedSkillNames.join(", ")}. When the user's request matches one of these skills, use the Skill tool to invoke it.`; + } + const sdkOptions: any = { model: opts.model ?? this.currentModelId ?? "claude-sonnet-4-20250514", env, permissionMode: opts.permissionMode ?? "default", allowedTools: ["Read", "Write", "Edit", "Grep", "Glob", "Bash", "WebFetch", "WebSearch", "Task", "TodoWrite", "TodoRead", "NotebookEdit"], canUseTool: this.createCanUseTool(sessionId), - systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: CODEMUX_IDENTITY_PROMPT }, + systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: promptAppend }, pathToClaudeCodeExecutable: this.resolveCliPath(), }; - // Set working directory (natively supported since SDK v0.2.81) - if (directory) { - sdkOptions.cwd = directory.replaceAll("/", process.platform === "win32" ? "\\" : "/"); + // Set working directory. + // Note: sdkOptions.cwd is currently ignored by the SDK's V2 session API + // (rQ constructor doesn't forward it to ProcessTransport), so we also + // temporarily chdir to the target directory before creating the session. + // The rQ → y4 → spawn() chain is fully synchronous, so this is safe in + // single-threaded Node.js. + const nativeCwd = directory + ? directory.replaceAll("/", process.platform === "win32" ? "\\" : "/") + : undefined; + if (nativeCwd) { + sdkOptions.cwd = nativeCwd; } let v2Session: SDKSession; - if (ccSessionId) { - // Resume existing session - claudeLog.info( - `[Claude][${sessionId}] Resuming CC session: ${ccSessionId}`, - ); - v2Session = unstable_v2_resumeSession(ccSessionId, sdkOptions); - } else { - // Create new session - v2Session = unstable_v2_createSession(sdkOptions); + const origCwd = nativeCwd ? process.cwd() : undefined; + if (nativeCwd) { + try { + process.chdir(nativeCwd); + } catch (e) { + claudeLog.warn( + `[Claude][${sessionId}] Failed to chdir to ${nativeCwd}: ${e}`, + ); + } + } + + try { + if (ccSessionId) { + // Resume existing session + claudeLog.info( + `[Claude][${sessionId}] Resuming CC session: ${ccSessionId}`, + ); + v2Session = unstable_v2_resumeSession(ccSessionId, sdkOptions); + } else { + // Create new session + v2Session = unstable_v2_createSession(sdkOptions); + } + } finally { + if (origCwd) { + try { + process.chdir(origCwd); + } catch { + // ignore — original cwd may have been removed + } + } } claudeLog.info( @@ -1484,6 +1634,93 @@ export class ClaudeCodeAdapter extends EngineAdapter { this.v2Sessions.delete(sessionId); } + /** + * Built-in commands that ship with Claude Code. Used to distinguish + * user-installed skills from built-in slash commands. + */ + private static readonly BUILT_IN_COMMANDS = new Set([ + "compact", "context", "cost", "init", "review", + "help", "clear", "config", "doctor", "memory", "model", + "login", "logout", "bug", "mcp", "approved-tools", + "pr-comments", "release-notes", "listen", + ]); + + private isBuiltInCommand(name: string): boolean { + return ClaudeCodeAdapter.BUILT_IN_COMMANDS.has(name); + } + + /** + * Warm up by querying the SDK for available commands (including user-installed + * skills). Uses the SDK Query's supportedCommands() API which returns + * SlashCommand[] with full name + description + argumentHint. + * + * Creates a lightweight sdkQuery session, extracts commands, and closes it. + */ + private async warmupV2Session(sessionId: string, directory: string): Promise { + // Skip if commands are already populated (e.g. another session already warmed up) + if (this.availableCommands.length > 0) return; + + const cwd = directory.replaceAll("/", process.platform === "win32" ? "\\" : "/"); + const env: Record = { + ...process.env, + ...this.options?.env, + }; + delete env.ANTHROPIC_MODEL; + delete env.ELECTRON_RUN_AS_NODE; + + const q = sdkQuery({ + prompt: "", + options: { + model: "claude-sonnet-4-20250514", + env, + cwd, + abortController: new AbortController(), + pathToClaudeCodeExecutable: this.resolveCliPath(), + } as any, + }); + + try { + const commands = await q.supportedCommands(); + this.availableCommands = commands.map((cmd: { name: string; description: string; argumentHint: string }) => ({ + name: cmd.name, + description: cmd.description || "", + argumentHint: cmd.argumentHint || undefined, + })); + + // Cache skill names (non-built-in commands) for system prompt injection + this.cachedSkillNames = commands + .filter((cmd: { name: string }) => !this.isBuiltInCommand(cmd.name)) + .map((cmd: { name: string }) => cmd.name); + + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.availableCommands, + }); + + claudeLog.info( + `[Claude][${sessionId}] Warmup complete via supportedCommands(): ${this.availableCommands.length} commands (${this.cachedSkillNames.length} user skills: ${this.cachedSkillNames.join(", ")})`, + ); + } catch (err) { + claudeLog.warn(`[Claude][${sessionId}] supportedCommands() failed, using fallback:`, err); + // Minimal fallback + this.availableCommands = [ + { name: "compact", description: "Compact conversation context" }, + { name: "context", description: "Show current context window usage" }, + { name: "cost", description: "Show token usage and cost" }, + ]; + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.availableCommands, + }); + } finally { + try { + q.close(); + } catch { + // Ignore errors during cleanup + } + } + } + // ========================================================================== // Stream Processing // ========================================================================== @@ -1603,6 +1840,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { buffer: MessageBuffer, streamingBlocks: Map, ): void { + claudeLog.debug( + `[Claude][${sessionId}] handleSdkMessage: type=${(msg as any).type}, subtype=${(msg as any).subtype ?? "N/A"}`, + ); + switch (msg.type) { case "system": this.handleSystemMessage(msg, sessionId, buffer); @@ -1630,9 +1871,9 @@ export class ClaudeCodeAdapter extends EngineAdapter { break; default: - // tool_progress, auth_status, etc. — log but don't process - claudeLog.info( - `[Claude][${sessionId}] Unhandled message type: ${(msg as any).type}`, + // Log type/subtype only — avoid serializing full message to prevent leaking user data + claudeLog.debug( + `[Claude][${sessionId}] Unhandled message: type=${(msg as any).type}, subtype=${(msg as any).subtype}`, ); break; } @@ -1646,6 +1887,9 @@ export class ClaudeCodeAdapter extends EngineAdapter { sessionId: string, buffer: MessageBuffer, ): void { + claudeLog.debug( + `[Claude][${sessionId}] handleSystemMessage: subtype=${msg.subtype}`, + ); if (msg.subtype === "init") { const ccSessionId = msg.session_id; @@ -1684,6 +1928,20 @@ export class ClaudeCodeAdapter extends EngineAdapter { claudeLog.info( `[Claude][${sessionId}] System init: session=${ccSessionId}, model=${msg.model}`, ); + + // Note: Command/skill discovery is handled by warmupV2Session() via + // supportedCommands() API. The init message's slash_commands/skills + // arrays are ignored here to avoid overwriting the richer data from + // the API (which includes descriptions and argumentHints). + } else if (msg.subtype === "local_command_output") { + // Slash command output (e.g., /help, /cost, /compact). + const output = msg.content ?? ""; + claudeLog.debug( + `[Claude][${sessionId}] local_command_output: content_length=${output.length}`, + ); + if (output) { + this.appendText(sessionId, buffer, output); + } } else if (msg.subtype === "status") { // Handle status changes (e.g., compacting) if (msg.status === "compacting") { @@ -1718,8 +1976,21 @@ export class ClaudeCodeAdapter extends EngineAdapter { }; } + // Handle content — may be a string (from slash command output via Ko1()) + // or an array of content blocks (from normal LLM responses). + const content = betaMessage.content; + if (typeof content === "string") { + // Slash command output converted by SDK: content is plain text + if (content.trim()) { + this.appendText(sessionId, buffer, content); + } + return; + } + + if (!Array.isArray(content)) return; + // Process content blocks - for (const block of betaMessage.content) { + for (const block of content) { if (block.type === "text") { this.appendText(sessionId, buffer, block.text ?? ""); } else if (block.type === "thinking") { @@ -1741,7 +2012,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { } /** - * Handle user messages from the stream (typically tool_result). + * Handle user messages from the stream. + * These include tool_result blocks (normal flow) and also synthetic user + * messages from slash command output (CLI wraps output in user messages + * for commands with display !== "system"). */ private handleUserMessage( msg: any, @@ -1751,9 +2025,30 @@ export class ClaudeCodeAdapter extends EngineAdapter { const betaMessage = msg.message; if (!betaMessage?.content) return; + // Ignore synthetic replay messages — these are echoes of the command input, + // not actual output we should display. + if (msg.isReplay || msg.isSynthetic) return; + + // String content — may be slash command output stripped of XML tags + if (typeof betaMessage.content === "string") { + const text = betaMessage.content.trim(); + if (text) { + this.appendText(sessionId, buffer, text); + } + return; + } + + if (!Array.isArray(betaMessage.content)) return; + for (const block of betaMessage.content) { if (block.type === "tool_result") { this.handleToolResult(sessionId, buffer, block); + } else if (block.type === "text") { + // Text blocks in user messages — from slash command output + const text = (block.text ?? "").trim(); + if (text) { + this.appendText(sessionId, buffer, text); + } } } } @@ -1787,6 +2082,20 @@ export class ClaudeCodeAdapter extends EngineAdapter { buffer.error = msg.result ?? "Unknown error"; } + // For slash commands: if the buffer has no text content but the result + // message carries text, use it as the command output. This handles + // local-jsx commands where the JSX rendering doesn't produce stream + // messages but the CLI records a result text. + if ( + !msg.is_error && + typeof msg.result === "string" && + msg.result.trim() && + buffer.parts.length === 0 && + !buffer.textAccumulator + ) { + this.appendText(sessionId, buffer, msg.result); + } + claudeLog.info( `[Claude][${sessionId}] Result: cost=$${buffer.cost?.toFixed(4)}, ` + `tokens=${buffer.tokens?.input ?? 0}/${buffer.tokens?.output ?? 0}`, diff --git a/electron/main/engines/copilot/config.ts b/electron/main/engines/copilot/config.ts index d07ceb95..293af2ea 100644 --- a/electron/main/engines/copilot/config.ts +++ b/electron/main/engines/copilot/config.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "fs"; -import { join, dirname } from "path"; +import { join, dirname, sep } from "path"; import { fileURLToPath } from "url"; import { app } from "electron"; import type { AgentMode } from "../../../../src/types/unified"; @@ -43,8 +43,15 @@ export function resolvePlatformCli(): string | undefined { const binaryName = process.platform === "win32" ? "copilot.exe" : "copilot"; // Strategy 1: import.meta.resolve + // In packaged builds, import.meta.resolve returns paths inside app.asar. + // Electron's patched fs.existsSync sees those files, but the OS cannot + // execute a binary from inside an asar archive. Convert the path to the + // unpacked location (electron-builder extracts copilot-* via asarUnpack). try { - const resolved = fileURLToPath((import.meta as any).resolve(pkgName)); + let resolved = fileURLToPath((import.meta as any).resolve(pkgName)); + if (resolved.includes(`app.asar${sep}`) && !resolved.includes("app.asar.unpacked")) { + resolved = resolved.replace(`app.asar${sep}`, `app.asar.unpacked${sep}`); + } if (existsSync(resolved)) return resolved; } catch { // Not resolvable diff --git a/electron/main/engines/copilot/index.ts b/electron/main/engines/copilot/index.ts index 60ad8ef0..1fd95b91 100644 --- a/electron/main/engines/copilot/index.ts +++ b/electron/main/engines/copilot/index.ts @@ -38,6 +38,8 @@ import type { ToolPart, TextPart, PermissionOption, + EngineCommand, + CommandInvokeResult, } from "../../../../src/types/unified"; import { @@ -106,6 +108,7 @@ export class CopilotSdkAdapter extends EngineAdapter { private sessionTodos = new Map>(); private allowedAlwaysKinds = new Set(); + private cachedCommands: EngineCommand[] = []; private messageBuffers = new Map(); private messageHistory = new Map(); @@ -140,12 +143,17 @@ export class CopilotSdkAdapter extends EngineAdapter { } copilotLog.info("Using Copilot CLI binary:", cliPath); + // Remove ELECTRON_RUN_AS_NODE which leaks from Electron in packaged builds + // and causes the Copilot CLI subprocess to malfunction (stream destroyed). + const env = { ...process.env, ...this.options?.env }; + delete env.ELECTRON_RUN_AS_NODE; + this.client = new CopilotClient({ useStdio: true, autoRestart: true, autoStart: true, cliPath, - env: this.options?.env, + env, }); await this.client.start(); @@ -246,6 +254,7 @@ export class CopilotSdkAdapter extends EngineAdapter { modelSwitchable: true, customModelInput: false, messageEnqueue: true, + slashCommands: true, availableModes: this.getModes(), }; } @@ -303,6 +312,16 @@ export class CopilotSdkAdapter extends EngineAdapter { }; this.emit("session.created", { session }); + + // Fetch initial skills/commands for slash command support. + // Await the fetch to ensure cachedCommands is populated before + // the frontend calls listCommands() shortly after session creation. + try { + await this.fetchSkills(sdkSession); + } catch (err) { + copilotLog.warn(`Failed to fetch initial skills for session ${sessionId}:`, err); + } + return session; } @@ -608,9 +627,6 @@ export class CopilotSdkAdapter extends EngineAdapter { this.cachedModels = sdkModels.map((m: any) => sdkModelToUnified(this.engineType, m)); } catch (err) {} - const configModel = readConfigModel(); - if (configModel) this.currentModelId = configModel; - return { models: this.cachedModels, currentModelId: this.currentModelId ?? undefined, @@ -681,6 +697,174 @@ export class CopilotSdkAdapter extends EngineAdapter { async listProjects(): Promise { return []; } + // --- Slash Commands / Skills --- + + private async fetchSkills(session: CopilotSession): Promise { + try { + copilotLog.debug(`[Copilot] fetchSkills: calling session.rpc.skills.list()...`); + const result = await session.rpc.skills.list(); + copilotLog.debug(`[Copilot] fetchSkills: received ${Array.isArray((result as any)?.skills ?? result) ? ((result as any)?.skills ?? result).length : 0} skills`); + const skills = (result as any)?.skills ?? (result as any) ?? []; + if (Array.isArray(skills)) { + this.cachedCommands = skills + .filter((s: any) => s.userInvocable !== false) + .map((s: any) => ({ + name: s.name, + description: s.description ?? "", + source: s.source, + userInvocable: s.userInvocable, + })); + copilotLog.info(`[Copilot] fetchSkills: cached ${this.cachedCommands.length} commands: ${this.cachedCommands.map(c => c.name).join(", ")}`); + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.cachedCommands, + }); + } else { + copilotLog.warn(`[Copilot] fetchSkills: skills is not an array: ${typeof skills}`); + } + } catch (err) { + copilotLog.warn(`[Copilot] fetchSkills FAILED:`, err); + } + } + + override async listCommands(sessionId?: string, directory?: string): Promise { + // If cached commands are available, return them immediately. + if (this.cachedCommands.length > 0) return this.cachedCommands; + + copilotLog.info(`[Copilot] listCommands: cache empty, sessionId=${sessionId ?? "none"}, activeSessions=${this.activeSessions.size}`); + + // Cache is empty — try to fetch from the active session. + // This handles the case where the initial fetch was skipped or failed. + if (sessionId) { + let session = this.activeSessions.get(sessionId); + if (!session) { + // Session not active yet — try to activate it so we can fetch skills. + // Use the directory passed from engine-manager (from conversationStore), + // falling back to the in-memory sessionDirectories map. + const dir = directory || this.sessionDirectories.get(sessionId); + if (dir) { + try { + copilotLog.info(`[Copilot] listCommands: activating session ${sessionId} to fetch skills`); + session = await this.ensureActiveSession(sessionId, dir); + } catch (err) { + copilotLog.warn(`[Copilot] listCommands: failed to activate session:`, err); + } + } + } + if (session) { + try { + await this.fetchSkills(session); + } catch (err) { + copilotLog.warn(`[Copilot] Failed to fetch skills on listCommands:`, err); + } + } + } else { + // No sessionId provided — try the first active session + const firstSession = this.activeSessions.values().next().value; + if (firstSession) { + try { + await this.fetchSkills(firstSession); + } catch (err) { + copilotLog.warn(`[Copilot] Failed to fetch skills on listCommands (fallback):`, err); + } + } + } + + return this.cachedCommands; + } + + override async invokeCommand( + sessionId: string, + commandName: string, + args: string, + options?: { mode?: string; modelId?: string; directory?: string }, + ): Promise { + // Send the command as text — Copilot CLI intercepts /command prefix. + // The CLI emits a command.execute event which we acknowledge via + // handlePendingCommand() in handleCommandExecute(). + const commandText = `/${commandName}${args ? ` ${args}` : ""}`; + const message = await this.sendMessage( + sessionId, + [{ type: "text", text: commandText }], + options, + ); + return { handledAsCommand: true, message }; + } + + /** + * Handle command.execute event from Copilot CLI. + * The CLI dispatches this when it intercepts a /command in user input. + * We must acknowledge it via handlePendingCommand() so the CLI can proceed. + */ + private async handleCommandExecute( + sessionId: string, + data: { requestId: string; command: string; commandName: string; args: string }, + ): Promise { + copilotLog.info( + `[Copilot][${sessionId}] command.execute: /${data.commandName} ${data.args} (requestId=${data.requestId})`, + ); + try { + const session = this.activeSessions.get(sessionId); + if (session) { + await session.rpc.commands.handlePendingCommand({ + requestId: data.requestId, + }); + } + } catch (err) { + copilotLog.warn(`[Copilot][${sessionId}] Failed to acknowledge command:`, err); + } + } + + /** + * Handle commands.changed event — refresh the cached command list. + */ + private handleCommandsChanged( + sessionId: string, + data: { commands: Array<{ name: string; description?: string }> }, + ): void { + if (Array.isArray(data?.commands)) { + this.cachedCommands = data.commands.map(cmd => ({ + name: cmd.name, + description: cmd.description ?? "", + })); + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.cachedCommands, + }); + copilotLog.info( + `[Copilot][${sessionId}] Commands updated: ${this.cachedCommands.length} commands`, + ); + } + } + + /** + * Handle session.skills_loaded event — the Copilot CLI emits this when + * skills are loaded or reloaded from disk. Update cached commands from + * the skills data. + */ + private handleSkillsLoaded( + sessionId: string, + data: { skills: Array<{ name: string; description?: string; userInvocable?: boolean; source?: string }> }, + ): void { + if (Array.isArray(data?.skills)) { + this.cachedCommands = data.skills + .filter((s) => s.userInvocable !== false) + .map((s) => ({ + name: s.name, + description: s.description ?? "", + source: s.source, + userInvocable: s.userInvocable, + })); + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.cachedCommands, + }); + copilotLog.info( + `[Copilot][${sessionId}] Skills loaded: ${this.cachedCommands.length} skills`, + ); + } + } + private setStatus(status: EngineStatus, error?: string): void { this.status = status; this.lastError = error; @@ -782,6 +966,7 @@ export class CopilotSdkAdapter extends EngineAdapter { private handleSessionEvent(sessionId: string, event: SessionEvent): void { try { + copilotLog.debug(`[Copilot][${sessionId}] SessionEvent: type=${event.type}`); switch (event.type) { case "assistant.message_delta": this.handleMessageDelta(sessionId, event.data as any); break; case "assistant.reasoning_delta": this.handleReasoningDelta(sessionId, event.data as any); break; @@ -800,6 +985,22 @@ export class CopilotSdkAdapter extends EngineAdapter { case "abort": this.handleAbort(sessionId, event.data as any); break; case "subagent.started": this.handleSubagentStarted(sessionId, event.data as any); break; case "subagent.completed": this.handleSubagentCompleted(sessionId, event.data as any); break; + + // --- Slash Command events --- + case "command.execute": + this.handleCommandExecute(sessionId, event.data as any); + break; + case "command.completed": + // Command completed — no action needed on our side + copilotLog.info(`[Copilot][${sessionId}] Command completed: requestId=${(event.data as any)?.requestId}`); + break; + case "commands.changed": + this.handleCommandsChanged(sessionId, event.data as any); + break; + case "session.skills_loaded": + // Skills loaded/reloaded — refresh command list from the event data + this.handleSkillsLoaded(sessionId, event.data as any); + break; } } catch (err) { copilotLog.warn(`Error handling session event for session ${sessionId}:`, err); diff --git a/electron/main/engines/engine-adapter.ts b/electron/main/engines/engine-adapter.ts index f259ff6f..73bfa751 100644 --- a/electron/main/engines/engine-adapter.ts +++ b/electron/main/engines/engine-adapter.ts @@ -21,6 +21,8 @@ import type { MessagePromptContent, PermissionReply, ImportableSession, + EngineCommand, + CommandInvokeResult, } from "../../../src/types/unified"; /** @@ -110,6 +112,12 @@ export interface EngineAdapterEvents { sessionId: string; messageId: string; }) => void; + + /** Available slash commands / skills changed (e.g. after session init, Copilot skills reload) */ + "commands.changed": (data: { + engineType: EngineType; + commands: EngineCommand[]; + }) => void; } // Type-safe event emitter @@ -275,4 +283,32 @@ export abstract class EngineAdapter extends EventEmitter { /** List projects (directories with engine bindings) */ abstract listProjects(): Promise; + + // --- Slash Commands / Skills --- + + /** + * List available slash commands for this engine. + * Default: returns empty array (engine doesn't support commands). + * @param sessionId Optional session ID for session-scoped command lists + * @param directory Optional working directory (for resuming/creating sessions to fetch commands) + */ + async listCommands(_sessionId?: string, _directory?: string): Promise { + return []; + } + + /** + * Invoke a slash command. Returns a result indicating whether the command + * was handled natively or should fall through to sendMessage. + * + * Default: returns { handledAsCommand: false }, causing the caller to + * fall back to sending "/commandName args" as a regular message. + */ + async invokeCommand( + _sessionId: string, + _commandName: string, + _args: string, + _options?: { mode?: string; modelId?: string; directory?: string }, + ): Promise { + return { handledAsCommand: false }; + } } diff --git a/electron/main/engines/mock-adapter.ts b/electron/main/engines/mock-adapter.ts index 67091c18..ffd891cb 100644 --- a/electron/main/engines/mock-adapter.ts +++ b/electron/main/engines/mock-adapter.ts @@ -123,6 +123,7 @@ export class MockEngineAdapter extends EngineAdapter { modelSwitchable: true, customModelInput: false, messageEnqueue: false, + slashCommands: false, availableModes: this.getModes(), }; } diff --git a/electron/main/engines/opencode/index.ts b/electron/main/engines/opencode/index.ts index 081ed68c..8bbc4c45 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -47,6 +47,8 @@ import type { PermissionReply, PermissionOption, QuestionInfo, + EngineCommand, + CommandInvokeResult, } from "../../../../src/types/unified"; import { OPENCODE_PORT } from "../../../../shared/ports"; @@ -71,6 +73,7 @@ export class OpenCodeAdapter extends EngineAdapter { // Cached state private sessions = new Map(); private currentDirectory: string | null = null; + private cachedCommands: EngineCommand[] = []; // Message completion tracking: sessionId → array of pending entries. // The first entry is the "primary" (normal send), subsequent entries are @@ -693,6 +696,11 @@ export class OpenCodeAdapter extends EngineAdapter { this.status = "running"; this.emit("status.changed", { engineType: this.engineType, status: "running" }); + + // Fetch initial commands for slash command support + this.fetchCommands().catch(err => { + openCodeLog.warn("Failed to fetch initial commands:", err); + }); } async stop(): Promise { @@ -766,6 +774,7 @@ export class OpenCodeAdapter extends EngineAdapter { modelSwitchable: true, customModelInput: false, messageEnqueue: true, + slashCommands: true, availableModes: this.getModes(), }; } @@ -1251,4 +1260,81 @@ export class OpenCodeAdapter extends EngineAdapter { return []; } } + + // --- Slash Commands --- + + private async fetchCommands(): Promise { + try { + const client = this.ensureClient(); + const result = await client.command.list({ + directory: this.currentDirectory ?? undefined, + }); + const commands = result.data ?? []; + if (Array.isArray(commands)) { + this.cachedCommands = commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description ?? "", + argumentHint: cmd.template ? `<${cmd.template}>` : undefined, + })); + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.cachedCommands, + }); + } + } catch (err) { + openCodeLog.warn("Failed to list commands:", err); + } + } + + override async listCommands(_sessionId?: string): Promise { + return this.cachedCommands; + } + + override async invokeCommand( + sessionId: string, + commandName: string, + args: string, + options?: { mode?: string; modelId?: string; directory?: string }, + ): Promise { + const session = this.sessions.get(sessionId); + const dir = session?.directory ?? options?.directory ?? this.currentDirectory ?? undefined; + const client = dir ? this.createClient(dir) : this.ensureClient(); + + // Build model spec if provided + let model: { providerID: string; modelID: string } | undefined; + if (options?.modelId) { + const slashIdx = options.modelId.indexOf("/"); + if (slashIdx > 0) { + model = { + providerID: options.modelId.slice(0, slashIdx), + modelID: options.modelId.slice(slashIdx + 1), + }; + } + } + + try { + const result = await client.session.command({ + sessionID: sessionId, + directory: dir, + command: commandName, + arguments: args, + agent: options?.mode, + model: model ? `${model.providerID}/${model.modelID}` : undefined, + }); + + if (result.error) { + throw new Error(`Command failed: ${JSON.stringify(result.error)}`); + } + + // OpenCode command responses flow through SSE events (message.part.updated, etc.) + // just like regular promptAsync responses. We need to wait for the session to + // become idle, similar to sendMessage(). + // For now, fall back to sendMessage with the command as text, since the SSE + // event handling is already wired up for that flow. + return { handledAsCommand: false }; + } catch (err) { + openCodeLog.warn(`Command /${commandName} failed, falling back to sendMessage:`, err); + return { handledAsCommand: false }; + } + } } diff --git a/electron/main/gateway/engine-manager.ts b/electron/main/gateway/engine-manager.ts index 9432b6f2..f67eeee7 100644 --- a/electron/main/gateway/engine-manager.ts +++ b/electron/main/gateway/engine-manager.ts @@ -26,6 +26,8 @@ import type { ImportableSession, SessionImportResult, SessionImportProgress, + EngineCommand, + CommandInvokeResult, } from "../../../src/types/unified"; // --- Helpers --- @@ -37,11 +39,18 @@ function normalizeDir(dir: string): string { /** Convert ConversationMeta → UnifiedSession for wire compatibility */ function convToSession(conv: ConversationMeta): UnifiedSession { + // For worktree sessions, resolve projectId from the parent repo directory + const projectDir = conv.worktreeId && conv.parentDirectory + ? normalizeDir(conv.parentDirectory) + : normalizeDir(conv.directory); + return { id: conv.id, engineType: conv.engineType, directory: normalizeDir(conv.directory), title: conv.title, + worktreeId: conv.worktreeId, + projectId: `dir-${projectDir}`, time: { created: conv.createdAt, updated: conv.updatedAt, @@ -102,6 +111,9 @@ export class EngineManager extends EventEmitter { private stepFlushTimer: ReturnType | null = null; private static readonly STEP_FLUSH_INTERVAL_MS = 2000; + /** Track which sessions have active sendMessage calls (for idle detection) */ + private activeSessions = new Set(); + // --- Adapter Registration --- registerAdapter(adapter: EngineAdapter): void { @@ -326,6 +338,7 @@ export class EngineManager extends EventEmitter { "status.changed", "message.queued", "message.queued.consumed", + "commands.changed", ]; for (const event of simpleEvents) { @@ -673,10 +686,45 @@ export class EngineManager extends EventEmitter { async createSession( engineType: EngineType, directory: string, + worktreeId?: string, ): Promise { - this.getAdapterOrThrow(engineType); // Validate engine exists - const conv = conversationStore.create({ engineType, directory }); + const adapter = this.getAdapterOrThrow(engineType); // Validate engine exists + + // If worktreeId is specified, resolve worktree directory + let sessionDir = directory; + if (worktreeId) { + const { worktreeManager } = await import("../services/worktree-manager"); + const projectId = await worktreeManager.resolveProjectId(directory); + const wt = worktreeManager.getWorktreeByName(projectId, worktreeId); + if (wt) { + sessionDir = wt.directory; + } + } + + const conv = conversationStore.create({ + engineType, + directory: sessionDir, + worktreeId, + // Remember the original repo directory so worktree sessions group under the right project + parentDirectory: worktreeId ? directory : undefined, + }); this.sessionEngineMap.set(conv.id, engineType); + + // Create the engine session immediately (not lazily on first sendMessage). + // This ensures that engine-specific initialization (like fetching Copilot skills + // or Claude V2 session init) happens at session creation time, so features + // like slash command autocomplete work before the user sends a message. + try { + const engineSession = await adapter.createSession(conv.directory, conv.engineMeta); + conversationStore.setEngineSession(conv.id, engineSession.id, engineSession.engineMeta); + this.engineToConvMap.set(engineSession.id, conv.id); + } catch (err) { + // Clean up the orphaned conversation if engine session creation fails + this.sessionEngineMap.delete(conv.id); + conversationStore.delete(conv.id); + throw err; + } + const session = convToSession(conv); // Broadcast to all connected clients (e.g., UI) so session lists update in real-time this.emit("session.created", { session }); @@ -778,6 +826,8 @@ export class EngineManager extends EventEmitter { content: MessagePromptContent[], options?: { mode?: string; modelId?: string }, ): Promise { + this.activeSessions.add(sessionId); + try { const conv = conversationStore.get(sessionId); if (!conv) throw new Error(`Conversation not found: ${sessionId}`); @@ -822,6 +872,14 @@ export class EngineManager extends EventEmitter { } return result; + } finally { + this.activeSessions.delete(sessionId); + } + } + + /** Check if a session is idle (not actively processing a message) */ + isSessionIdle(sessionId: string): boolean { + return !this.activeSessions.has(sessionId); } async cancelMessage(sessionId: string): Promise { @@ -907,6 +965,65 @@ export class EngineManager extends EventEmitter { return await conversationStore.getSteps(sessionId, messageId); } + // --- Slash Commands --- + + async listCommands(engineType: EngineType, sessionId?: string): Promise { + const adapter = this.adapters.get(engineType); + if (!adapter) return []; + + if (sessionId) { + const conv = conversationStore.get(sessionId); + return adapter.listCommands(conv?.engineSessionId ?? undefined, conv?.directory); + } + + return adapter.listCommands(); + } + + async invokeCommand( + sessionId: string, + commandName: string, + args: string, + options?: { mode?: string; modelId?: string }, + ): Promise { + const conv = conversationStore.get(sessionId); + if (!conv) throw new Error(`Conversation not found: ${sessionId}`); + + const adapter = this.getAdapterForSession(sessionId); + + // Lazy engine session creation (same pattern as sendMessage) + let engineSessionId = conv.engineSessionId; + if (!engineSessionId || !adapter.hasSession(engineSessionId)) { + const engineSession = await adapter.createSession(conv.directory, conv.engineMeta); + engineSessionId = engineSession.id; + conversationStore.setEngineSession(sessionId, engineSessionId, engineSession.engineMeta); + } + this.engineToConvMap.set(engineSessionId, sessionId); + + // Persist user command message + const commandText = `/${commandName}${args ? ` ${args}` : ""}`; + this.applyTitleFallback(sessionId, [{ type: "text", text: commandText }]); + await this.persistUserMessage(sessionId, [{ type: "text", text: commandText }]); + + const result = await adapter.invokeCommand( + engineSessionId, + commandName, + args, + { ...options, directory: conv.directory }, + ); + + // If the adapter couldn't handle it, fall back to sendMessage + if (!result.handledAsCommand) { + const message = await adapter.sendMessage( + engineSessionId, + [{ type: "text", text: commandText }], + { ...options, directory: conv.directory }, + ); + return { handledAsCommand: false, message }; + } + + return result; + } + // --- Models --- async listModels(engineType: EngineType): Promise { diff --git a/electron/main/gateway/ws-server.ts b/electron/main/gateway/ws-server.ts index eeb378ec..05c6239b 100644 --- a/electron/main/gateway/ws-server.ts +++ b/electron/main/gateway/ws-server.ts @@ -16,6 +16,7 @@ import { import { gatewayLog } from "../services/logger"; import log from "../services/logger"; import { conversationStore } from "../services/conversation-store"; +import { scheduledTaskService } from "../services/scheduled-task-service"; import { GatewayRequestType, GatewayNotificationType, @@ -32,6 +33,13 @@ import { type ModeSetRequest, type SessionImportPreviewRequest, type SessionImportExecuteRequest, + type ScheduledTaskCreateRequest, + type ScheduledTaskUpdateRequest, + type WorktreeCreateRequest, + type WorktreeListRequest, + type WorktreeRemoveRequest, + type WorktreeMergeRequest, + type WorktreeListBranchesRequest, } from "../../../src/types/unified"; interface ClientConnection { @@ -229,6 +237,20 @@ export class GatewayServer { // --- Request Routing --- + private isWorktreeEnabled(): boolean { + try { + const settingsPath = require("path").join( + require("electron").app.getPath("userData"), + "settings.json", + ); + const raw = require("fs").readFileSync(settingsPath, "utf-8"); + const settings = JSON.parse(raw); + return settings.worktreeEnabled === true; + } catch { + return false; + } + } + private async routeRequest(request: GatewayRequest): Promise { const { type, payload } = request; const p = payload as any; @@ -249,7 +271,7 @@ export class GatewayServer { case GatewayRequestType.SESSION_CREATE: { const req = p as SessionCreateRequest; - return this.engineManager.createSession(req.engineType, req.directory); + return this.engineManager.createSession(req.engineType, req.directory, req.worktreeId); } case GatewayRequestType.SESSION_GET: @@ -391,6 +413,90 @@ export class GatewayServer { return { success: true }; } + // Slash Commands + case GatewayRequestType.COMMAND_LIST: { + const req = p as any; + return this.engineManager.listCommands(req.engineType, req.sessionId); + } + + case GatewayRequestType.COMMAND_INVOKE: { + const req = p as any; + return this.engineManager.invokeCommand(req.sessionId, req.commandName, req.args, { + mode: req.mode, + modelId: req.modelId, + }); + } + + // Scheduled Tasks + case GatewayRequestType.SCHEDULED_TASK_LIST: + return scheduledTaskService.list(); + + case GatewayRequestType.SCHEDULED_TASK_GET: + return scheduledTaskService.get(p.id); + + case GatewayRequestType.SCHEDULED_TASK_CREATE: + return scheduledTaskService.create(p as ScheduledTaskCreateRequest); + + case GatewayRequestType.SCHEDULED_TASK_UPDATE: + return scheduledTaskService.update(p as ScheduledTaskUpdateRequest); + + case GatewayRequestType.SCHEDULED_TASK_DELETE: + scheduledTaskService.delete(p.id); + return { success: true }; + + case GatewayRequestType.SCHEDULED_TASK_RUN_NOW: + return scheduledTaskService.runNow(p.id); + + // Worktree + case GatewayRequestType.WORKTREE_CREATE: { + const req = p as WorktreeCreateRequest; + if (!this.isWorktreeEnabled()) { + throw Object.assign(new Error("Worktree feature is disabled"), { code: "WORKTREE_DISABLED" }); + } + const { worktreeManager } = await import("../services/worktree-manager"); + return worktreeManager.create(req.directory, { + name: req.name, + baseBranch: req.baseBranch, + }); + } + + case GatewayRequestType.WORKTREE_LIST: { + const req = p as WorktreeListRequest; + const { worktreeManager } = await import("../services/worktree-manager"); + return worktreeManager.list(req.directory); + } + + case GatewayRequestType.WORKTREE_REMOVE: { + const req = p as WorktreeRemoveRequest; + const { worktreeManager } = await import("../services/worktree-manager"); + + // Delete all sessions belonging to this worktree (same pattern as project delete) + const allConvs = conversationStore.list(); + const worktreeConvs = allConvs.filter((conv) => conv.worktreeId === req.worktreeName); + for (const conv of worktreeConvs) { + await this.engineManager.deleteSession(conv.id); + } + + // Then remove the git worktree, branch, and directory + return worktreeManager.remove(req.directory, req.worktreeName); + } + + case GatewayRequestType.WORKTREE_MERGE: { + const req = p as WorktreeMergeRequest; + const { worktreeManager } = await import("../services/worktree-manager"); + return worktreeManager.merge(req.directory, req.worktreeName, { + targetBranch: req.targetBranch, + mode: req.mode, + message: req.message, + }); + } + + case GatewayRequestType.WORKTREE_LIST_BRANCHES: { + const req = p as WorktreeListBranchesRequest; + const { worktreeManager } = await import("../services/worktree-manager"); + return worktreeManager.listBranches(req.directory); + } + default: throw Object.assign( new Error(`Unknown request type: ${type}`), @@ -487,6 +593,33 @@ export class GatewayServer { payload: data, }); }); + + em.on("commands.changed", (data) => { + this.broadcast({ + type: GatewayNotificationType.COMMANDS_CHANGED, + payload: data, + }); + }); + + // Scheduled Task events + scheduledTaskService.on("task.fired", (data) => { + this.broadcast({ + type: GatewayNotificationType.SCHEDULED_TASK_FIRED, + payload: data, + }); + }); + scheduledTaskService.on("task.failed", (data) => { + this.broadcast({ + type: GatewayNotificationType.SCHEDULED_TASK_FAILED, + payload: data, + }); + }); + scheduledTaskService.on("tasks.changed", (data) => { + this.broadcast({ + type: GatewayNotificationType.SCHEDULED_TASKS_CHANGED, + payload: data, + }); + }); } private broadcast(notification: GatewayNotification): void { @@ -498,6 +631,13 @@ export class GatewayServer { } } + broadcastSettingsChanged(settings: Record): void { + this.broadcast({ + type: GatewayNotificationType.SETTINGS_CHANGED, + payload: { settings }, + }); + } + private sendToClient( client: ClientConnection, response: GatewayResponse, diff --git a/electron/main/index.ts b/electron/main/index.ts index 48e89328..2d516a13 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow } from "electron"; import fixPath from "fix-path"; import { mainLog } from "./services/logger"; +import { unwatchAll } from "./services/file-service"; // dev restart trigger // Fix $PATH for packaged macOS/Linux apps launched from GUI. @@ -40,6 +41,7 @@ import { WeComAdapter } from "./channels/wecom/wecom-adapter"; import { TeamsAdapter } from "./channels/teams/teams-adapter"; import { updateManager } from "./services/update-manager"; import { trayManager } from "./services/tray-manager"; +import { scheduledTaskService } from "./services/scheduled-task-service"; import { ensureDefaultWorkspace } from "./services/default-workspace"; import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_PORT } from "../../shared/ports"; @@ -112,6 +114,9 @@ if (!gotTheLock) { // Rebuild engine routing tables from persisted ConversationStore data engineManager.initFromStore(); + // Initialize scheduled task service (persistent desktop-level scheduled tasks) + scheduledTaskService.init(engineManager); + // Register IPC handlers registerIpcHandlers(); @@ -184,30 +189,38 @@ if (!gotTheLock) { // Mark startup as ready once all engines have settled (success or failure) Promise.allSettled(enginePromises).then(async () => { - startupReady = true; - mainLog.info("All engines settled, startup ready"); - const win = getMainWindow(); - if (win && !win.isDestroyed()) { - win.webContents.send("startup:ready"); - } + mainLog.info("All engines settled"); + + const gatewayUrl = app.isPackaged && productionServer.isRunning() + ? `ws://127.0.0.1:${WEB_PORT}/ws` + : `ws://127.0.0.1:${GATEWAY_PORT}`; + + channelManager.setRuntimeOptions({ gatewayUrl }); - // Initialize channels (after engines are ready and gateway is running) try { // Start the shared webhook HTTP server for channels that need it // (Telegram, WeCom, Teams). Feishu and DingTalk use platform WSClient. await webhookServer.start(); mainLog.info(`Webhook server started on port ${webhookServer.serverPort}`); + } catch (err) { + mainLog.error("Failed to start channel webhook server:", err); + } - // Determine the actual Gateway WS URL for channel adapters. - // In production, gateway is attached to the production HTTP server on /ws path. - // In dev, gateway runs on a standalone port. - const gatewayUrl = app.isPackaged && productionServer.isRunning() - ? `ws://127.0.0.1:${WEB_PORT}/ws` - : `ws://127.0.0.1:${GATEWAY_PORT}`; + // Initialize channels (after engines are ready and gateway is running) + try { await channelManager.initFromConfig({ gatewayUrl }); } catch (err) { mainLog.error("Failed to initialize channels:", err); } + + // Mark startup ready AFTER channels are initialized so the renderer + // sees final channel statuses when it (re-)polls on startup:ready. + startupReady = true; + mainLog.info("All engines and channels settled, startup ready"); + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send("startup:ready"); + } }); app.on("activate", () => { @@ -240,6 +253,7 @@ if (!gotTheLock) { if (updateManager.isInstallingUpdate()) { trayManager.destroy(); await conversationStore.flushAll(); + await scheduledTaskService.shutdown(); gatewayServer.stop(); return; } @@ -249,6 +263,10 @@ if (!gotTheLock) { try { trayManager.destroy(); + // Stop native file watchers early — @parcel/watcher uses NAPI threadsafe + // functions that must be torn down before Node.js module cleanup begins. + unwatchAll(); + // Flush conversation store before quit await conversationStore.flushAll(); @@ -258,6 +276,7 @@ if (!gotTheLock) { webhookServer.stop(), engineManager.stopAll(), productionServer.stop(), + scheduledTaskService.shutdown(), ]); gatewayServer.stop(); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index c9b30c1d..70d65c7a 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -6,8 +6,9 @@ import { tunnelManager } from "./services/tunnel-manager"; import { productionServer } from "./services/production-server"; import { updateManager } from "./services/update-manager"; import { trayManager } from "./services/tray-manager"; -import { getLogFilePath, getFileLogLevel, setFileLogLevel, loadSettings, saveSettings } from "./services/logger"; -import { isStartupReady } from "./index"; +import { getLogFilePath, getFileLogLevel, setFileLogLevel, loadSettings, saveSettings, onSettingsChanged } from "./services/logger"; +import { filterSharedSettings, getSettingsSyncEnabled, SETTINGS_SYNC_ENABLED_KEY } from "../../shared/settings-sync"; +import { gatewayServer, isStartupReady } from "./index"; import { channelManager } from "./index"; import { GATEWAY_PORT } from "../../shared/ports"; @@ -271,6 +272,21 @@ export function registerIpcHandlers(): void { return { success: true }; }); + onSettingsChanged((settings) => { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send("settings:changed", settings); + } + } + // Include settingsSyncEnabled so web clients learn immediately when the + // host toggles sync on or off (filterSharedSettings omits this key). + const broadcastPayload: Record = { + [SETTINGS_SYNC_ENABLED_KEY]: getSettingsSyncEnabled(settings), + ...filterSharedSettings(settings), + }; + gatewayServer.broadcastSettingsChanged(broadcastPayload); + }); + // =========================================================================== // Auto Update // =========================================================================== @@ -367,4 +383,4 @@ function getLocalIp(): string { } } return fallback ?? "localhost"; -} \ No newline at end of file +} diff --git a/electron/main/services/auth-api-server.ts b/electron/main/services/auth-api-server.ts index cbb724a1..4c0d1dc4 100644 --- a/electron/main/services/auth-api-server.ts +++ b/electron/main/services/auth-api-server.ts @@ -2,8 +2,9 @@ import http from "http"; import { deviceStore } from "./device-store"; import { authLog, getLogFilePath, getFileLogLevel, setFileLogLevel } from "./logger"; import { sendJson } from "../../../shared/http-utils"; -import { handleAuthRoutes, handleLogRoutes } from "../../../shared/auth-route-handlers"; +import { handleAuthRoutes, handleLogRoutes, handleSettingsRoutes } from "../../../shared/auth-route-handlers"; import { AUTH_API_PORT } from "../../../shared/ports"; +import { loadSettings, saveSettings, replaceSettings } from "./logger"; // ============================================================================ // Internal Auth API Server @@ -83,6 +84,13 @@ class AuthApiServer { }); if (logHandled) return; + const settingsHandled = await handleSettingsRoutes(req, res, pathname, deviceStore, { + loadSettings, + saveSettings, + replaceSettings, + }); + if (settingsHandled) return; + // Not found sendJson(res, { error: "Not found" }, 404); } diff --git a/electron/main/services/conversation-store.ts b/electron/main/services/conversation-store.ts index 02c1fd68..3b118a0c 100644 --- a/electron/main/services/conversation-store.ts +++ b/electron/main/services/conversation-store.ts @@ -133,6 +133,8 @@ class ConversationStore { engineType: EngineType; directory: string; title?: string; + worktreeId?: string; + parentDirectory?: string; }): ConversationMeta { this.ensureInitialized(); @@ -145,6 +147,8 @@ class ConversationStore { createdAt: now, updatedAt: now, messageCount: 0, + worktreeId: params.worktreeId, + parentDirectory: params.parentDirectory, }; this.index.set(conv.id, conv); @@ -376,6 +380,8 @@ class ConversationStore { for (const conv of this.index.values()) { if (!conv.directory || conv.directory === "/") continue; + // Worktree sessions belong to their parent project, not a separate one + if (conv.worktreeId) continue; const key = this.normalizeDir(conv.directory); if (!dirMap.has(key)) { // Always store the normalized (forward-slash) form as the canonical directory diff --git a/electron/main/services/logger.ts b/electron/main/services/logger.ts index 9aa0e19e..7ef2f521 100644 --- a/electron/main/services/logger.ts +++ b/electron/main/services/logger.ts @@ -3,6 +3,7 @@ import { app } from "electron"; import path from "node:path"; import fs from "node:fs"; import type { LevelOption } from "electron-log"; +import { applySettingsMutation } from "../../../shared/settings-sync"; // Configure electron-log for the main process. // All logs (main + renderer forwarded via WebSocket) go to a single file. @@ -23,6 +24,7 @@ log.transports.file.maxSize = 5 * 1024 * 1024; // --- Persisted settings --- const VALID_LEVELS: LevelOption[] = ["error", "warn", "info", "verbose", "debug", "silly", false]; +const settingsChangeListeners = new Set<(settings: Record) => void>(); function getSettingsPath(): string { return path.join(app.getPath("userData"), "settings.json"); @@ -37,18 +39,24 @@ function loadSettings(): Record { } } +function cloneSettings(settings: Record): Record { + return JSON.parse(JSON.stringify(settings)); +} + +function emitSettingsChanged(settings: Record): void { + const snapshot = cloneSettings(settings); + for (const listener of settingsChangeListeners) { + listener(snapshot); + } +} + function saveSettings(patch: Record): void { const existing = loadSettings(); - // Deep merge: for object-valued keys, merge nested properties instead of replacing - const settings = { ...existing }; - for (const [key, value] of Object.entries(patch)) { - if (value && typeof value === "object" && !Array.isArray(value) - && existing[key] && typeof existing[key] === "object" && !Array.isArray(existing[key])) { - settings[key] = { ...(existing[key] as Record), ...(value as Record) }; - } else { - settings[key] = value; - } - } + const settings = applySettingsMutation(existing, patch); + saveSettingsState(settings); +} + +function saveSettingsState(settings: Record): void { const filePath = getSettingsPath(); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { @@ -57,6 +65,20 @@ function saveSettings(patch: Record): void { const tmpPath = `${filePath}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2)); fs.renameSync(tmpPath, filePath); + emitSettingsChanged(settings); +} + +export function replaceSettings(settings: Record): void { + saveSettingsState(settings); +} + +export function onSettingsChanged( + listener: (settings: Record) => void, +): () => void { + settingsChangeListeners.add(listener); + return () => { + settingsChangeListeners.delete(listener); + }; } // Restore persisted log level, fallback to "warn" @@ -143,10 +165,21 @@ export const tunnelLog = log.scope("tunnel"); export const windowLog = log.scope("window"); export const channelLog = log.scope("channel"); export const feishuLog = log.scope("feishu"); +export const larkLog = log.scope("lark"); export const dingtalkLog = log.scope("dingtalk"); export const telegramLog = log.scope("telegram"); export const wecomLog = log.scope("wecom"); export const teamsLog = log.scope("teams"); +export const scheduledTaskLog = log.scope("sched-task"); + +export type ScopedLogger = Pick< + typeof feishuLog, + "error" | "warn" | "info" | "verbose" | "debug" | "silly" +>; + +export function getFeishuChannelLog(platform: "feishu" | "lark" = "feishu"): ScopedLogger { + return platform === "lark" ? larkLog : feishuLog; +} // Re-export the root logger for ad-hoc usage and renderer log forwarding export default log; diff --git a/electron/main/services/production-server.ts b/electron/main/services/production-server.ts index 10f08608..4c8574ff 100644 --- a/electron/main/services/production-server.ts +++ b/electron/main/services/production-server.ts @@ -3,9 +3,17 @@ import fs from "fs"; import path from "path"; import { app } from "electron"; import { deviceStore } from "./device-store"; -import { prodServerLog, getLogFilePath, getFileLogLevel, setFileLogLevel } from "./logger"; +import { + prodServerLog, + getLogFilePath, + getFileLogLevel, + setFileLogLevel, + loadSettings, + saveSettings, + replaceSettings, +} from "./logger"; import { sendJson, getClientIp, isLocalhost, getLocalIp } from "../../../shared/http-utils"; -import { handleAuthRoutes, handleLogRoutes } from "../../../shared/auth-route-handlers"; +import { handleAuthRoutes, handleLogRoutes, handleSettingsRoutes } from "../../../shared/auth-route-handlers"; import { WEB_PORT, OPENCODE_PORT, WEBHOOK_PORT } from "../../../shared/ports"; // ============================================================================ @@ -240,7 +248,12 @@ class ProductionServer { // ======================================================================== // Auth + Device + Admin API Routes (shared handler) // ======================================================================== - if (pathname.startsWith("/api/auth/") || pathname.startsWith("/api/admin/") || pathname.startsWith("/api/devices")) { + if ( + pathname.startsWith("/api/auth/") + || pathname.startsWith("/api/admin/") + || pathname.startsWith("/api/devices") + || pathname.startsWith("/api/settings/") + ) { const handled = await handleAuthRoutes(req, res, pathname, url, deviceStore, { defaultDeviceName: "Local Machine", defaultPlatform: process.platform, @@ -248,6 +261,12 @@ class ProductionServer { includeDeviceInResponse: true, }); if (handled) return; + const settingsHandled = await handleSettingsRoutes(req, res, pathname, deviceStore, { + loadSettings, + saveSettings, + replaceSettings, + }); + if (settingsHandled) return; // Fall through to 404 if no auth route matched sendJson(res, { error: "Not found" }, 404); return; diff --git a/electron/main/services/scheduled-task-service.ts b/electron/main/services/scheduled-task-service.ts new file mode 100644 index 00000000..808997a8 --- /dev/null +++ b/electron/main/services/scheduled-task-service.ts @@ -0,0 +1,583 @@ +// ============================================================================ +// Desktop-level Scheduled Task Service +// Persistent scheduled tasks that survive app restarts. +// Each trigger creates a new session (never reuses existing ones). +// Permissions are auto-approved (same pattern as channel adapters). +// ============================================================================ + +import { EventEmitter } from "events"; +import { randomUUID } from "crypto"; +import { app, Notification } from "electron"; +import path from "node:path"; +import fs from "node:fs"; +import { scheduledTaskLog } from "./logger"; +import type { EngineManager } from "../gateway/engine-manager"; +import type { + ScheduledTask, + ScheduledTaskCreateRequest, + ScheduledTaskUpdateRequest, + ScheduledTaskRunResult, + ScheduledTaskFrequency, + EngineType, +} from "../../../src/types/unified"; + +/** Max setTimeout value (~24.8 days). Timers longer than this overflow to 1. */ +const MAX_TIMEOUT = 2_147_483_647; + +/** Maximum number of run history entries kept per task. */ +const MAX_RUN_HISTORY = 50; + +/** Missed run catch-up window (7 days). */ +const MISSED_RUN_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +/** Debounce delay for persisting to disk. */ +const SAVE_DEBOUNCE_MS = 500; + +/** Maximum jitter offset (10 minutes). */ +const MAX_JITTER_MS = 10 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Persistence file format +// --------------------------------------------------------------------------- + +interface TaskFileFormat { + version: 1; + tasks: ScheduledTask[]; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class ScheduledTaskService extends EventEmitter { + private tasks = new Map(); + private timers = new Map>(); + private engineManager: EngineManager | null = null; + private saveTimer: ReturnType | null = null; + private initialized = false; + /** Session IDs created by scheduled tasks — auto-approve permissions for these. */ + private autoApproveSessions = new Set(); + /** Task IDs currently being executed (for graceful shutdown). */ + private runningTasks = new Set(); + + // --- Lifecycle ------------------------------------------------------- + + /** + * Initialize the service. + * Must be called after `app.whenReady()` and after `engineManager.initFromStore()`. + */ + init(engineManager: EngineManager): void { + if (this.initialized) return; + this.engineManager = engineManager; + this.loadFromDisk(); + this.initialized = true; + + // Subscribe to permission events for auto-approval + this.subscribePermissionAutoApprove(); + + // Schedule all enabled non-manual tasks + for (const task of this.tasks.values()) { + if (task.enabled && task.frequency.type !== "manual") { + this.scheduleTask(task); + } + } + + // Check for missed runs + this.checkMissedRuns(); + + scheduledTaskLog.info(`Initialized with ${this.tasks.size} task(s)`); + } + + /** Graceful shutdown: clear timers, wait for running tasks, flush pending writes. */ + async shutdown(): Promise { + // Clear all scheduling timers (prevent new triggers) + for (const [id, timer] of this.timers.entries()) { + clearTimeout(timer); + this.timers.delete(id); + } + + // Wait for currently executing tasks to finish (max 5 seconds) + if (this.runningTasks.size > 0) { + scheduledTaskLog.info( + `Waiting for ${this.runningTasks.size} running task(s) to finish...`, + ); + const deadline = Date.now() + 5000; + while (this.runningTasks.size > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + if (this.runningTasks.size > 0) { + scheduledTaskLog.warn( + `${this.runningTasks.size} task(s) still running at shutdown, proceeding anyway`, + ); + } + } + + // Flush pending save + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.writeToDisk(); + } + + this.autoApproveSessions.clear(); + this.runningTasks.clear(); + this.initialized = false; + scheduledTaskLog.info("Shut down"); + } + + // --- Auto-approve permissions (same pattern as channel adapters) ------ + + private subscribePermissionAutoApprove(): void { + if (!this.engineManager) return; + + this.engineManager.on("permission.asked", (data: any) => { + const permission = data.permission ?? data; + const sessionId = permission.sessionId; + if (!sessionId || !this.autoApproveSessions.has(sessionId)) return; + + // Find an accept/allow option + const acceptOption = permission.options?.find( + (o: any) => + o.type?.includes("accept") || + o.type?.includes("allow") || + o.label?.toLowerCase().includes("allow"), + ); + + if (acceptOption) { + scheduledTaskLog.info(`Auto-approving permission ${permission.id} for session ${sessionId}`); + this.engineManager!.replyPermission(permission.id, { optionId: acceptOption.id }); + } + }); + } + + // --- CRUD ----------------------------------------------------------- + + list(): ScheduledTask[] { + return Array.from(this.tasks.values()); + } + + get(id: string): ScheduledTask | null { + return this.tasks.get(id) ?? null; + } + + create(req: ScheduledTaskCreateRequest): ScheduledTask { + const jitterMs = this.computeJitter(req.name); + const now = Date.now(); + + const task: ScheduledTask = { + id: randomUUID(), + name: req.name, + description: req.description, + prompt: req.prompt, + engineType: req.engineType, + directory: req.directory, + frequency: req.frequency, + enabled: req.enabled ?? true, + jitterMs, + createdAt: now, + lastRunAt: null, + nextRunAt: null, + runHistory: [], + }; + + // Compute nextRunAt for non-manual tasks + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, now); + } + + this.tasks.set(task.id, task); + this.scheduleSave(); + this.emitChanged(); + + // Schedule if enabled and non-manual + if (task.enabled && task.frequency.type !== "manual") { + this.scheduleTask(task); + } + + scheduledTaskLog.info(`Created task "${task.name}" (${task.id})`); + return task; + } + + update(req: ScheduledTaskUpdateRequest): ScheduledTask { + const task = this.tasks.get(req.id); + if (!task) { + throw Object.assign(new Error(`Task not found: ${req.id}`), { code: "NOT_FOUND" }); + } + + // Apply partial updates + if (req.name !== undefined) task.name = req.name; + if (req.description !== undefined) task.description = req.description; + if (req.prompt !== undefined) task.prompt = req.prompt; + if (req.engineType !== undefined) task.engineType = req.engineType; + if (req.directory !== undefined) task.directory = req.directory; + if (req.enabled !== undefined) task.enabled = req.enabled; + + // Recompute jitter if name changed + if (req.name !== undefined) { + task.jitterMs = this.computeJitter(req.name); + } + + // Reschedule if frequency or enabled changed + const frequencyChanged = req.frequency !== undefined; + if (frequencyChanged) { + task.frequency = req.frequency!; + } + + // Clear existing timer + this.clearTaskTimer(task.id); + + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, Date.now()); + this.scheduleTask(task); + } else { + task.nextRunAt = null; + } + + this.scheduleSave(); + this.emitChanged(); + scheduledTaskLog.info(`Updated task "${task.name}" (${task.id})`); + return task; + } + + delete(id: string): void { + const task = this.tasks.get(id); + if (!task) { + throw Object.assign(new Error(`Task not found: ${id}`), { code: "NOT_FOUND" }); + } + + this.clearTaskTimer(id); + this.tasks.delete(id); + this.scheduleSave(); + this.emitChanged(); + scheduledTaskLog.info(`Deleted task "${task.name}" (${id})`); + } + + // --- Execution ------------------------------------------------------ + + async runNow(taskId: string): Promise { + const task = this.tasks.get(taskId); + if (!task) { + throw Object.assign(new Error(`Task not found: ${taskId}`), { code: "NOT_FOUND" }); + } + + return this.executeTask(task); + } + + private async executeTask(task: ScheduledTask): Promise { + if (!this.engineManager) { + throw new Error("ScheduledTaskService not initialized"); + } + + scheduledTaskLog.info(`Executing task "${task.name}" (${task.id})`); + this.runningTasks.add(task.id); + + try { + // 1. Create a new session + const session = await this.engineManager.createSession( + task.engineType as EngineType, + task.directory, + ); + + // 2. Register session for auto-approve (with size limit fallback) + if (this.autoApproveSessions.size > 200) { + // Keep only the most recent 100 entries (Set preserves insertion order) + const recent = [...this.autoApproveSessions].slice(-100); + this.autoApproveSessions.clear(); + for (const id of recent) this.autoApproveSessions.add(id); + } + this.autoApproveSessions.add(session.id); + + // 3. Send the prompt as the first message + await this.engineManager.sendMessage(session.id, [ + { type: "text", text: task.prompt }, + ]); + + // 4. Update task state + task.lastRunAt = Date.now(); + task.runHistory.unshift(session.id); + if (task.runHistory.length > MAX_RUN_HISTORY) { + task.runHistory = task.runHistory.slice(0, MAX_RUN_HISTORY); + } + + // 5. Reschedule next run + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, Date.now()); + this.scheduleTask(task); + } + + this.scheduleSave(); + this.emit("task.fired", { taskId: task.id, conversationId: session.id }); + this.emitChanged(); + + // Desktop notification + this.showNotification( + `Scheduled task "${task.name}" started`, + `New session created for: ${task.prompt.slice(0, 100)}`, + ); + + return { taskId: task.id, conversationId: session.id }; + } catch (err: any) { + scheduledTaskLog.error(`Task "${task.name}" execution failed:`, err); + this.emit("task.failed", { taskId: task.id, error: err.message }); + + this.showNotification( + `Scheduled task "${task.name}" failed`, + err.message ?? "Unknown error", + ); + + throw err; + } finally { + this.runningTasks.delete(task.id); + } + } + + // --- Scheduling ----------------------------------------------------- + + private scheduleTask(task: ScheduledTask): void { + this.clearTaskTimer(task.id); + + if (!task.enabled || task.frequency.type === "manual" || task.nextRunAt === null) { + return; + } + + const delay = Math.max(0, task.nextRunAt - Date.now()); + + // Handle setTimeout overflow (max ~24.8 days) + if (delay > MAX_TIMEOUT) { + const timer = setTimeout(() => { + this.scheduleTask(task); + }, MAX_TIMEOUT); + this.timers.set(task.id, timer); + return; + } + + const timer = setTimeout(async () => { + this.timers.delete(task.id); + try { + await this.executeTask(task); + } catch { + // Error already logged and emitted in executeTask + // Still reschedule next run + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, Date.now()); + this.scheduleTask(task); + this.scheduleSave(); + } + } + }, delay); + + this.timers.set(task.id, timer); + scheduledTaskLog.info( + `Scheduled task "${task.name}" next run in ${Math.round(delay / 1000)}s`, + ); + } + + private clearTaskTimer(id: string): void { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + } + + // --- Next-run computation ------------------------------------------- + + /** + * Compute the next run timestamp for a given frequency. + * @param frequency The task frequency configuration + * @param jitterMs Deterministic jitter offset in ms + * @param afterMs Compute the next run after this timestamp (usually Date.now()) + */ + computeNextRun( + frequency: ScheduledTaskFrequency, + jitterMs: number, + afterMs: number, + ): number | null { + if (frequency.type === "manual") return null; + + switch (frequency.type) { + case "interval": { + const intervalMs = (frequency.intervalMinutes ?? 60) * 60_000; + // Next run = afterMs + interval + jitter (capped to not exceed interval) + const cappedJitter = Math.min(jitterMs, intervalMs * 0.1); + return afterMs + intervalMs + cappedJitter; + } + + case "daily": { + const hour = frequency.hour ?? 9; + const minute = frequency.minute ?? 0; + const next = new Date(afterMs); + next.setHours(hour, minute, 0, 0); + let ts = next.getTime() + jitterMs; + if (ts <= afterMs) { + next.setDate(next.getDate() + 1); + ts = next.getTime() + jitterMs; + } + return ts; + } + + case "weekly": { + const hour = frequency.hour ?? 9; + const minute = frequency.minute ?? 0; + const targetDays = frequency.daysOfWeek ?? [1]; // Default Monday + + if (targetDays.length === 0) return null; + + // Find the earliest next occurrence among the target days + const candidates: number[] = []; + for (const targetDay of targetDays) { + const next = new Date(afterMs); + next.setHours(hour, minute, 0, 0); + + const currentDay = next.getDay(); + let daysUntil = (targetDay - currentDay + 7) % 7; + if (daysUntil === 0) { + // Same day — check if the time has passed + const ts = next.getTime() + jitterMs; + if (ts <= afterMs) { + daysUntil = 7; + } + } + next.setDate(next.getDate() + daysUntil); + candidates.push(new Date(next).setHours(hour, minute, 0, 0) + jitterMs); + } + + return Math.min(...candidates); + } + + default: + return null; + } + } + + // --- Jitter --------------------------------------------------------- + + /** + * Compute a deterministic jitter value (0–600000 ms) from the task name. + * Uses a simple hash so the same name always gets the same offset. + */ + private computeJitter(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + const ch = name.charCodeAt(i); + hash = ((hash << 5) - hash + ch) | 0; + } + return Math.abs(hash) % MAX_JITTER_MS; + } + + // --- Missed-run catch-up ------------------------------------------- + + /** + * On startup, check each task for missed runs within the 7-day window. + * If a task should have run while the app was offline, execute it once now. + */ + private checkMissedRuns(): void { + const now = Date.now(); + + for (const task of this.tasks.values()) { + if (!task.enabled || task.frequency.type === "manual") continue; + + const lastRun = task.lastRunAt ?? task.createdAt; + const expectedNext = this.computeNextRun(task.frequency, task.jitterMs, lastRun); + + if (expectedNext === null) continue; + + // If the expected next run is in the past but within the 7-day window + if (expectedNext < now && (now - expectedNext) < MISSED_RUN_WINDOW_MS) { + scheduledTaskLog.info( + `Missed run detected for "${task.name}" (expected at ${new Date(expectedNext).toISOString()})`, + ); + + this.executeTask(task).catch((err) => { + scheduledTaskLog.error(`Missed-run catch-up failed for "${task.name}":`, err); + }); + } + } + } + + // --- Persistence ---------------------------------------------------- + + private getFilePath(): string { + return path.join(app.getPath("userData"), "scheduled-tasks.json"); + } + + private loadFromDisk(): void { + const filePath = this.getFilePath(); + try { + if (!fs.existsSync(filePath)) { + scheduledTaskLog.info("No scheduled-tasks.json found, starting empty"); + return; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const data: TaskFileFormat = JSON.parse(raw); + + if (data.version !== 1 || !Array.isArray(data.tasks)) { + scheduledTaskLog.warn("Invalid scheduled-tasks.json format, ignoring"); + return; + } + + for (const task of data.tasks) { + this.tasks.set(task.id, task); + } + + scheduledTaskLog.info(`Loaded ${data.tasks.length} task(s) from disk`); + } catch (err) { + scheduledTaskLog.error("Failed to load scheduled-tasks.json:", err); + } + } + + private writeToDisk(): void { + const filePath = this.getFilePath(); + const data: TaskFileFormat = { + version: 1, + tasks: Array.from(this.tasks.values()), + }; + + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Atomic write: write to .tmp then rename + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + fs.renameSync(tmpPath, filePath); + } catch (err) { + scheduledTaskLog.error("Failed to write scheduled-tasks.json:", err); + } + } + + /** Debounced save — coalesces rapid changes into one disk write. */ + private scheduleSave(): void { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + } + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + this.writeToDisk(); + }, SAVE_DEBOUNCE_MS); + } + + // --- Notifications -------------------------------------------------- + + private showNotification(title: string, body: string): void { + try { + if (Notification.isSupported()) { + new Notification({ title, body }).show(); + } + } catch (err) { + scheduledTaskLog.warn("Failed to show notification:", err); + } + } + + // --- Events --------------------------------------------------------- + + private emitChanged(): void { + this.emit("tasks.changed", { tasks: this.list() }); + } +} + +// Singleton +export const scheduledTaskService = new ScheduledTaskService(); diff --git a/electron/main/services/slug.ts b/electron/main/services/slug.ts new file mode 100644 index 00000000..cabd53c2 --- /dev/null +++ b/electron/main/services/slug.ts @@ -0,0 +1,85 @@ +/** + * Slug generator for human-readable random names. + * Used for worktree naming (e.g. "brave-cabin", "cosmic-rocket"). + */ + +const ADJECTIVES = [ + "brave", + "calm", + "clever", + "cosmic", + "crisp", + "curious", + "eager", + "gentle", + "glowing", + "happy", + "hidden", + "jolly", + "kind", + "lucky", + "mighty", + "misty", + "neon", + "nimble", + "playful", + "proud", + "quick", + "quiet", + "shiny", + "silent", + "stellar", + "sunny", + "swift", + "tidy", + "witty", +] as const; + +const NOUNS = [ + "cabin", + "cactus", + "canyon", + "circuit", + "comet", + "eagle", + "engine", + "falcon", + "forest", + "garden", + "harbor", + "island", + "knight", + "lagoon", + "meadow", + "moon", + "mountain", + "nebula", + "orchid", + "otter", + "panda", + "pixel", + "planet", + "river", + "rocket", + "sailor", + "squid", + "star", + "tiger", + "wizard", + "wolf", +] as const; + +export function createSlug(): string { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + return `${adj}-${noun}`; +} + +export function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} diff --git a/electron/main/services/worktree-manager.ts b/electron/main/services/worktree-manager.ts new file mode 100644 index 00000000..b11684c2 --- /dev/null +++ b/electron/main/services/worktree-manager.ts @@ -0,0 +1,363 @@ +/** + * Core worktree management service. + * Handles git worktree creation, listing, removal, and merging. + */ + +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { app } from "electron"; +import log from "electron-log/main"; +import { createSlug, slugify } from "./slug"; +import { worktreeStore, type WorktreeInfo } from "./worktree-store"; + +const wtLog = log.scope("WorktreeManager"); + +const GIT_TIMEOUT = 30_000; +const MAX_NAME_ATTEMPTS = 20; +const BRANCH_PREFIX = "codemux"; + +interface GitResult { + stdout: string; + stderr: string; + code: number; +} + +function git(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + execFile( + "git", + ["-c", "core.quotepath=false", ...args], + { cwd, timeout: GIT_TIMEOUT, maxBuffer: 10 * 1024 * 1024 }, + (error, stdout, stderr) => { + resolve({ + stdout: stdout?.trim() ?? "", + stderr: stderr?.trim() ?? "", + code: error ? (error as NodeJS.ErrnoException & { code?: number }).code + ? -1 + : (error as { status?: number }).status ?? 1 + : 0, + }); + }, + ); + }); +} + +export interface MergeResult { + success: boolean; + conflicts?: string[]; + message: string; +} + +export interface CreateWorktreeOptions { + name?: string; + baseBranch?: string; +} + +export interface MergeWorktreeOptions { + targetBranch?: string; + mode?: "merge" | "squash" | "rebase"; + message?: string; +} + +class WorktreeManager { + private worktreeBase: string | null = null; + private initialized = false; + + private ensureInit(): void { + if (this.initialized) return; + this.worktreeBase = path.join(app.getPath("userData"), "worktrees"); + if (!fs.existsSync(this.worktreeBase)) { + fs.mkdirSync(this.worktreeBase, { recursive: true }); + } + worktreeStore.init(); + this.initialized = true; + wtLog.info(`Initialized worktree base at ${this.worktreeBase}`); + } + + init(): void { + this.ensureInit(); + } + + async resolveProjectId(repoDir: string): Promise { + this.ensureInit(); + // Use the last segment of the repo directory as the project identifier + // e.g. "/Users/user/workspace/codemux" → "codemux" + const normalized = repoDir.replace(/\\/g, "/").replace(/\/+$/, ""); + const name = normalized.split("/").filter(Boolean).pop(); + if (!name) { + throw new Error(`Cannot determine project name for ${repoDir}`); + } + return name; + } + + async detectMainBranch(repoDir: string): Promise { + this.ensureInit(); + // Try symbolic-ref first + const symRef = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], repoDir); + if (symRef.code === 0 && symRef.stdout) { + const branch = symRef.stdout.replace("refs/remotes/origin/", ""); + if (branch) return branch; + } + + // Check common branch names + for (const candidate of ["main", "master", "develop"]) { + const check = await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoDir); + if (check.code === 0) return candidate; + } + + // Fallback: current branch + const current = await git(["rev-parse", "--abbrev-ref", "HEAD"], repoDir); + return current.code === 0 ? current.stdout : "main"; + } + + async listBranches(repoDir: string): Promise { + this.ensureInit(); + const result = await git(["branch", "--format=%(refname:short)"], repoDir); + if (result.code !== 0) return []; + return result.stdout.split("\n").filter(Boolean); + } + + private async findCandidate( + projectId: string, + repoDir: string, + baseName?: string, + ): Promise<{ name: string; branch: string; directory: string }> { + const root = path.join(this.worktreeBase!, projectId); + + for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) { + const name = baseName + ? attempt === 0 + ? slugify(baseName) + : `${slugify(baseName)}-${createSlug()}` + : createSlug(); + + const branch = `${BRANCH_PREFIX}/${name}`; + const directory = path.join(root, name); + + // Check if directory already exists + if (fs.existsSync(directory)) continue; + + // Check if branch already exists + const branchCheck = await git( + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + repoDir, + ); + if (branchCheck.code === 0) continue; + + return { name, branch, directory }; + } + + throw new Error(`Failed to generate unique worktree name after ${MAX_NAME_ATTEMPTS} attempts`); + } + + async create(repoDir: string, options?: CreateWorktreeOptions): Promise { + this.ensureInit(); + const projectId = await this.resolveProjectId(repoDir); + const baseBranch = options?.baseBranch || (await this.detectMainBranch(repoDir)); + const candidate = await this.findCandidate(projectId, repoDir, options?.name); + + wtLog.info( + `Creating worktree: ${candidate.name} (branch: ${candidate.branch}) from ${baseBranch}`, + ); + + const info: WorktreeInfo = { + name: candidate.name, + branch: candidate.branch, + directory: candidate.directory, + baseBranch, + projectId, + createdAt: Date.now(), + status: "pending", + }; + + worktreeStore.add(info); + + try { + // Create the worktree with a new branch from baseBranch + const result = await git( + ["worktree", "add", "-b", candidate.branch, candidate.directory, baseBranch], + repoDir, + ); + + if (result.code !== 0) { + throw new Error(`git worktree add failed: ${result.stderr}`); + } + + worktreeStore.update(projectId, candidate.name, { status: "ready" }); + wtLog.info(`Worktree created: ${candidate.directory}`); + + return { ...info, status: "ready" }; + } catch (err) { + worktreeStore.update(projectId, candidate.name, { status: "error" }); + // Cleanup on failure + try { + await fsp.rm(candidate.directory, { recursive: true, force: true }); + } catch { + /* ignore */ + } + throw err; + } + } + + async list(repoDir: string): Promise { + this.ensureInit(); + const projectId = await this.resolveProjectId(repoDir); + return worktreeStore.list(projectId); + } + + async remove(repoDir: string, worktreeName: string): Promise { + this.ensureInit(); + const projectId = await this.resolveProjectId(repoDir); + const info = worktreeStore.get(projectId, worktreeName); + if (!info) { + wtLog.warn(`Worktree not found: ${worktreeName} in project ${projectId}`); + return false; + } + + wtLog.info(`Removing worktree: ${worktreeName} at ${info.directory}`); + + // Remove git worktree + const removeResult = await git( + ["worktree", "remove", "--force", info.directory], + repoDir, + ); + + if (removeResult.code !== 0) { + wtLog.warn(`git worktree remove failed: ${removeResult.stderr}, cleaning up manually`); + } + + // Force-clean the directory + try { + await fsp.rm(info.directory, { recursive: true, force: true }); + } catch { + /* directory may already be gone */ + } + + // Delete the branch + await git(["branch", "-D", info.branch], repoDir); + + // Remove from store + worktreeStore.remove(projectId, worktreeName); + + wtLog.info(`Worktree removed: ${worktreeName}`); + return true; + } + + /** + * Update target branch to point at worktree branch. + * If target is the currently checked-out branch in repoDir, use `git merge`. + * Otherwise use `git fetch .` for a safe fast-forward without checkout. + */ + private async updateTargetBranch( + repoDir: string, + sourceBranch: string, + targetBranch: string, + mergeMessage: string, + ): Promise<{ ok: boolean; stderr: string }> { + // Check if target is the current branch in the main repo + const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], repoDir); + const currentBranch = head.code === 0 ? head.stdout : ""; + + if (currentBranch === targetBranch) { + // Target is checked out — merge directly in the main repo + const result = await git(["merge", "--ff-only", sourceBranch], repoDir); + if (result.code === 0) return { ok: true, stderr: "" }; + // ff-only failed, try with merge commit + const result2 = await git(["merge", "--no-ff", "-m", mergeMessage, sourceBranch], repoDir); + return { ok: result2.code === 0, stderr: result2.stderr }; + } + + // Target is NOT checked out — safe to use fetch + const ff = await git(["fetch", ".", `${sourceBranch}:${targetBranch}`], repoDir); + return { ok: ff.code === 0, stderr: ff.stderr }; + } + + async merge( + repoDir: string, + worktreeName: string, + options?: MergeWorktreeOptions, + ): Promise { + this.ensureInit(); + const projectId = await this.resolveProjectId(repoDir); + const info = worktreeStore.get(projectId, worktreeName); + if (!info) { + return { success: false, message: `Worktree not found: ${worktreeName}` }; + } + + const target = options?.targetBranch || (await this.detectMainBranch(repoDir)); + const mode = options?.mode || "merge"; + const message = options?.message || `Merge ${info.branch} into ${target}`; + + wtLog.info(`Merging ${info.branch} into ${target} (mode: ${mode})`); + + if (mode === "rebase") { + const rebase = await git(["rebase", target], info.directory); + if (rebase.code !== 0) { + await git(["rebase", "--abort"], info.directory); + return { success: false, message: `Rebase failed: ${rebase.stderr}` }; + } + const upd = await this.updateTargetBranch(repoDir, info.branch, target, message); + if (upd.ok) { + return { success: true, message: `Successfully rebased ${info.branch} onto ${target}` }; + } + return { success: false, message: `Fast-forward after rebase failed: ${upd.stderr}` }; + } + + if (mode === "squash") { + const squashMerge = await git(["merge", "--squash", target], info.directory); + if (squashMerge.code !== 0) { + const status = await git(["diff", "--name-only", "--diff-filter=U"], info.directory); + const conflicts = status.stdout.split("\n").filter(Boolean); + if (conflicts.length > 0) { + await git(["reset", "--merge"], info.directory); + return { success: false, conflicts, message: `Squash merge conflict in ${conflicts.length} file(s)` }; + } + return { success: false, message: `Squash merge failed: ${squashMerge.stderr}` }; + } + await git(["commit", "-m", message], info.directory); + const upd = await this.updateTargetBranch(repoDir, info.branch, target, message); + if (upd.ok) { + return { success: true, message: `Successfully squash-merged ${info.branch} into ${target}` }; + } + return { success: false, message: `Update target after squash failed: ${upd.stderr}` }; + } + + // Default: regular merge + // Try fast-forward first + const upd = await this.updateTargetBranch(repoDir, info.branch, target, message); + if (upd.ok) { + return { success: true, message: `Successfully merged ${info.branch} into ${target}` }; + } + + // Fast-forward failed — merge target INTO worktree, then update target + const wtMerge = await git(["merge", "--no-ff", "-m", message, target], info.directory); + if (wtMerge.code === 0) { + const retry = await this.updateTargetBranch(repoDir, info.branch, target, message); + if (retry.ok) { + return { success: true, message: `Successfully merged ${info.branch} into ${target}` }; + } + } + + // Check for merge conflicts + const status = await git(["diff", "--name-only", "--diff-filter=U"], info.directory); + const conflicts = status.stdout.split("\n").filter(Boolean); + if (conflicts.length > 0) { + await git(["merge", "--abort"], info.directory); + return { success: false, conflicts, message: `Merge conflict in ${conflicts.length} file(s)` }; + } + + return { success: false, message: `Merge failed: ${upd.stderr || wtMerge.stderr}` }; + } + + getWorktreeByName(projectId: string, name: string): WorktreeInfo | undefined { + return worktreeStore.get(projectId, name); + } + + getWorktreeByDirectory(directory: string): WorktreeInfo | undefined { + return worktreeStore.findByDirectory(directory); + } +} + +export const worktreeManager = new WorktreeManager(); diff --git a/electron/main/services/worktree-store.ts b/electron/main/services/worktree-store.ts new file mode 100644 index 00000000..6672328b --- /dev/null +++ b/electron/main/services/worktree-store.ts @@ -0,0 +1,193 @@ +/** + * Persistent storage for worktree metadata. + * Stores per-project worktree index at {appData}/codemux/worktrees/{projectId}/index.json. + */ + +import fs from "fs"; +import fsp from "fs/promises"; +import path from "path"; +import { app } from "electron"; +import log from "electron-log/main"; + +const worktreeStoreLog = log.scope("WorktreeStore"); + +export interface WorktreeInfo { + name: string; + branch: string; + directory: string; + baseBranch: string; + projectId: string; + createdAt: number; + status: "pending" | "ready" | "error"; +} + +interface WorktreeIndex { + version: number; + updatedAt: string; + worktrees: WorktreeInfo[]; +} + +const INDEX_VERSION = 1; +const INDEX_DEBOUNCE_MS = 500; + +export class WorktreeStore { + private basePath!: string; + private initialized = false; + // projectId → Map + private cache = new Map>(); + private dirtyProjects = new Set(); + private writeTimer: ReturnType | null = null; + + init(): void { + if (this.initialized) return; + this.basePath = path.join(app.getPath("userData"), "worktrees"); + this.ensureDirSync(this.basePath); + this.initialized = true; + worktreeStoreLog.info(`Initialized at ${this.basePath}`); + } + + private ensureDirSync(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } + + private projectDir(projectId: string): string { + return path.join(this.basePath, projectId); + } + + private indexPath(projectId: string): string { + return path.join(this.projectDir(projectId), "index.json"); + } + + private loadProject(projectId: string): Map { + const cached = this.cache.get(projectId); + if (cached) return cached; + + const map = new Map(); + const indexFile = this.indexPath(projectId); + + if (fs.existsSync(indexFile)) { + try { + const raw = fs.readFileSync(indexFile, "utf-8"); + const data: WorktreeIndex = JSON.parse(raw); + if (data.version === INDEX_VERSION) { + for (const wt of data.worktrees) { + map.set(wt.name, wt); + } + } else { + worktreeStoreLog.warn( + `Index version mismatch for ${projectId} (${data.version} vs ${INDEX_VERSION})`, + ); + } + } catch (err) { + worktreeStoreLog.error(`Failed to read worktree index for ${projectId}:`, err); + } + } + + this.cache.set(projectId, map); + return map; + } + + list(projectId: string): WorktreeInfo[] { + const map = this.loadProject(projectId); + return Array.from(map.values()); + } + + get(projectId: string, name: string): WorktreeInfo | undefined { + const map = this.loadProject(projectId); + return map.get(name); + } + + add(info: WorktreeInfo): void { + const map = this.loadProject(info.projectId); + map.set(info.name, info); + this.scheduleWrite(info.projectId); + } + + update(projectId: string, name: string, patch: Partial): void { + const map = this.loadProject(projectId); + const existing = map.get(name); + if (!existing) return; + map.set(name, { ...existing, ...patch }); + this.scheduleWrite(projectId); + } + + remove(projectId: string, name: string): boolean { + const map = this.loadProject(projectId); + const deleted = map.delete(name); + if (deleted) { + this.scheduleWrite(projectId); + } + return deleted; + } + + findByDirectory(directory: string): WorktreeInfo | undefined { + const normalized = directory.replace(/\\/g, "/").replace(/\/+$/, ""); + for (const map of this.cache.values()) { + for (const wt of map.values()) { + const wtDir = wt.directory.replace(/\\/g, "/").replace(/\/+$/, ""); + if (wtDir === normalized) return wt; + } + } + return undefined; + } + + private scheduleWrite(projectId: string): void { + this.dirtyProjects.add(projectId); + if (this.writeTimer) { + clearTimeout(this.writeTimer); + } + this.writeTimer = setTimeout(() => { + this.writeTimer = null; + this.flushDirty(); + }, INDEX_DEBOUNCE_MS); + } + + private async flushDirty(): Promise { + const projects = Array.from(this.dirtyProjects); + this.dirtyProjects.clear(); + await Promise.all(projects.map((pid) => this.writeIndex(pid))); + } + + private async writeIndex(projectId: string): Promise { + const map = this.cache.get(projectId); + if (!map) return; + + const worktrees = Array.from(map.values()); + worktrees.sort((a, b) => b.createdAt - a.createdAt); + + const data: WorktreeIndex = { + version: INDEX_VERSION, + updatedAt: new Date().toISOString(), + worktrees, + }; + + const dir = this.projectDir(projectId); + await fsp.mkdir(dir, { recursive: true }); + + const filePath = this.indexPath(projectId); + const tmpPath = filePath + ".tmp"; + try { + await fsp.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + await fsp.rename(tmpPath, filePath); + } catch (err) { + worktreeStoreLog.error(`Failed to write worktree index for ${projectId}:`, err); + try { + await fsp.unlink(tmpPath); + } catch { + /* ignore */ + } + } + } + + async flush(): Promise { + if (this.writeTimer) { + clearTimeout(this.writeTimer); + this.writeTimer = null; + } + await this.flushDirty(); + } +} + +export const worktreeStore = new WorktreeStore(); diff --git a/electron/main/window-manager.ts b/electron/main/window-manager.ts index 53a1393b..b1b5f1b7 100644 --- a/electron/main/window-manager.ts +++ b/electron/main/window-manager.ts @@ -39,7 +39,7 @@ export function createWindow(hidden = false): BrowserWindow { ...(process.platform === "darwin" ? { titleBarStyle: "hiddenInset" as const, - trafficLightPosition: { x: 16, y: 16 }, + trafficLightPosition: { x: 13, y: 14 }, } : process.platform === "win32" ? { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f6fb854f..eb92abf4 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -2,7 +2,9 @@ import { contextBridge, ipcRenderer } from "electron"; // Pre-load settings synchronously so renderer can read them immediately at module init // (needed for theme/locale which must be applied before first paint) -const _settingsCache: Record = ipcRenderer.sendSync("settings:loadSync") ?? {}; +let _settingsCache: Record = { + ...(ipcRenderer.sendSync("settings:loadSync") ?? {}), +}; const electronAPI = { // System API @@ -88,6 +90,16 @@ const electronAPI = { save: (patch: Record) => { return ipcRenderer.invoke("settings:save", patch) as Promise<{ success: boolean }>; }, + onChanged: (callback: (settings: Record) => void) => { + const handler = (_event: unknown, settings: Record) => { + _settingsCache = { ...settings }; + callback({ ..._settingsCache }); + }; + ipcRenderer.on("settings:changed", handler); + return () => { + ipcRenderer.removeListener("settings:changed", handler); + }; + }, }, // Startup API @@ -141,4 +153,4 @@ const electronAPI = { contextBridge.exposeInMainWorld("electronAPI", electronAPI); -export type ElectronAPI = typeof electronAPI; \ No newline at end of file +export type ElectronAPI = typeof electronAPI; diff --git a/package.json b/package.json index 834dfc8a..e78c8287 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codemux", - "version": "1.5.1", + "version": "1.5.2", "type": "module", "description": "A unified AI coding assistant client - access multiple AI coding engines from any device via desktop app or web interface", "author": "realDuang", diff --git a/scripts/auth-proxy-plugin.ts b/scripts/auth-proxy-plugin.ts index de7a25f4..34eb0638 100644 --- a/scripts/auth-proxy-plugin.ts +++ b/scripts/auth-proxy-plugin.ts @@ -134,6 +134,7 @@ export function createAuthProxyPlugin(options: AuthProxyPluginOptions = {}): Plu "/api/auth/", "/api/admin/", "/api/devices", + "/api/settings/", "/api/system/log", ]; diff --git a/scripts/settings-store.ts b/scripts/settings-store.ts new file mode 100644 index 00000000..68058729 --- /dev/null +++ b/scripts/settings-store.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import path from "path"; +import { applySettingsMutation } from "../shared/settings-sync"; + +const SETTINGS_FILE = path.join(process.cwd(), ".settings.json"); + +function ensureDirectory(): void { + const dir = path.dirname(SETTINGS_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +export function loadStandaloneSettings(): Record { + try { + const raw = fs.readFileSync(SETTINGS_FILE, "utf-8"); + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +export function replaceStandaloneSettings(settings: Record): void { + ensureDirectory(); + const tmpPath = `${SETTINGS_FILE}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2)); + fs.renameSync(tmpPath, SETTINGS_FILE); +} + +export function saveStandaloneSettings(patch: Record): void { + const current = loadStandaloneSettings(); + const next = applySettingsMutation(current, patch); + replaceStandaloneSettings(next); +} diff --git a/scripts/setup.ts b/scripts/setup.ts index 3115e754..be1f8316 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -114,7 +114,7 @@ async function installOpenCode(): Promise { return runInstallCommand( "OpenCode CLI installation", "bash", - ["-c", "curl -fsSL https://opencode.ai/install.sh | bash"], + ["-c", "curl -fsSL https://opencode.ai/install | bash"], { shell: false } ); } @@ -370,7 +370,7 @@ async function main() { if (isWindows) { log(" irm https://opencode.ai/install.ps1 | iex", colors.cyan); } else { - log(" curl -fsSL https://opencode.ai/install.sh | bash", colors.cyan); + log(" curl -fsSL https://opencode.ai/install | bash", colors.cyan); } } } else { diff --git a/scripts/start.ts b/scripts/start.ts index e14dc692..9286ea8d 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -38,7 +38,7 @@ async function installOpenCode(): Promise { } else { return runInstallCommand("bash", [ "-c", - "curl -fsSL https://opencode.ai/install.sh | bash", + "curl -fsSL https://opencode.ai/install | bash", ]); } } @@ -67,7 +67,7 @@ async function checkDependencies(): Promise { if (isWindows) { console.log(`${colors.cyan} irm https://opencode.ai/install.ps1 | iex${colors.reset}`); } else { - console.log(`${colors.cyan} curl -fsSL https://opencode.ai/install.sh | bash${colors.reset}`); + console.log(`${colors.cyan} curl -fsSL https://opencode.ai/install | bash${colors.reset}`); } return false; } diff --git a/scripts/update-cloudflared.ts b/scripts/update-cloudflared.ts index a82919f4..4a1b21e8 100644 --- a/scripts/update-cloudflared.ts +++ b/scripts/update-cloudflared.ts @@ -40,6 +40,12 @@ const ALL_PLATFORMS: Platform[] = [ url: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe", binaryName: "cloudflared.exe", }, + { + name: "linux", + arch: "x64", + url: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64", + binaryName: "cloudflared", + }, ]; /** diff --git a/shared/auth-route-handlers.ts b/shared/auth-route-handlers.ts index 44eff517..fdc1b414 100644 --- a/shared/auth-route-handlers.ts +++ b/shared/auth-route-handlers.ts @@ -1,6 +1,21 @@ import type { IncomingMessage, ServerResponse } from "http"; -import { sendJson, parseBody, extractBearerToken, getClientIp, isLocalhost } from "./http-utils"; +import { + sendJson, + sendNoCacheJson, + parseBody, + extractBearerToken, + getClientIp, + isLocalhost, +} from "./http-utils"; import type { DeviceInfo, PendingRequest } from "./device-store-types"; +import { + SETTINGS_SYNC_ENABLED_KEY, + applySettingsMutation, + filterSharedSettings, + filterSharedSettingsPatch, + filterSharedSettingsRemoveKeys, + getSettingsSyncEnabled, +} from "./settings-sync"; // ============================================================================= // Shared auth route handlers for auth-api-server and production-server. @@ -164,6 +179,115 @@ export async function handleLogRoutes( return false; } +export async function handleSettingsRoutes( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + store: AuthDeviceStore, + settingsFns: { + loadSettings: () => Record; + saveSettings: (patch: Record) => void; + replaceSettings?: (settings: Record) => void; + }, +): Promise { + if (pathname === "/api/settings/bootstrap" && req.method === "GET") { + if (!requireAuth(req, res, store)) return true; + const settings = settingsFns.loadSettings(); + sendNoCacheJson(res, { + syncEnabled: getSettingsSyncEnabled(settings), + sharedSettings: filterSharedSettings(settings), + }, 200, req); + return true; + } + + if (pathname === "/api/settings/sync-enabled" && req.method === "GET") { + if (!requireAuth(req, res, store)) return true; + const settings = settingsFns.loadSettings(); + sendNoCacheJson(res, { + enabled: getSettingsSyncEnabled(settings), + }, 200, req); + return true; + } + + if (pathname === "/api/settings/sync-enabled" && req.method === "POST") { + if (!requireAuth(req, res, store)) return true; + try { + const body = await parseBody(req); + if (typeof body.enabled !== "boolean") { + sendNoCacheJson(res, { error: "enabled must be a boolean" }, 400, req); + return true; + } + settingsFns.saveSettings({ [SETTINGS_SYNC_ENABLED_KEY]: body.enabled }); + sendNoCacheJson(res, { success: true, enabled: body.enabled }, 200, req); + } catch { + sendNoCacheJson(res, { error: "Bad request" }, 400, req); + } + return true; + } + + if (pathname === "/api/settings/shared" && req.method === "GET") { + if (!requireAuth(req, res, store)) return true; + const settings = settingsFns.loadSettings(); + sendNoCacheJson(res, { + settings: filterSharedSettings(settings), + syncEnabled: getSettingsSyncEnabled(settings), + }, 200, req); + return true; + } + + if (pathname === "/api/settings/shared" && req.method === "POST") { + if (!requireAuth(req, res, store)) return true; + try { + const body = await parseBody(req); + const patchInput = body.patch; + const removeKeysInput = body.removeKeys; + + if ((patchInput === undefined || patchInput === null || typeof patchInput !== "object" || Array.isArray(patchInput)) + && removeKeysInput === undefined) { + sendNoCacheJson(res, { error: "patch or removeKeys is required" }, 400, req); + return true; + } + + const patch = filterSharedSettingsPatch( + (patchInput && typeof patchInput === "object" && !Array.isArray(patchInput)) + ? patchInput as Record + : {}, + ); + const removeKeys = Array.isArray(removeKeysInput) + ? filterSharedSettingsRemoveKeys(removeKeysInput.filter((key): key is string => typeof key === "string")) + : []; + + if (Object.keys(patch).length === 0 && removeKeys.length === 0) { + sendNoCacheJson(res, { error: "No allowed shared settings in request" }, 400, req); + return true; + } + + if (settingsFns.replaceSettings) { + const current = settingsFns.loadSettings(); + const next = applySettingsMutation(current, patch, removeKeys); + settingsFns.replaceSettings(next); + } else { + const patchWithDeletes: Record = { ...patch }; + for (const key of removeKeys) { + patchWithDeletes[key] = undefined; + } + settingsFns.saveSettings(patchWithDeletes); + } + + const updated = settingsFns.loadSettings(); + sendNoCacheJson(res, { + success: true, + settings: filterSharedSettings(updated), + }, 200, req); + } catch { + sendNoCacheJson(res, { error: "Bad request" }, 400, req); + } + return true; + } + + return false; +} + // ----------------------------------------------------------------------------- // Helper: enforce localhost access, send 403 if not local. // Returns true if the request IS from localhost, false if blocked. diff --git a/shared/http-utils.ts b/shared/http-utils.ts index eab2c97a..9654141c 100644 --- a/shared/http-utils.ts +++ b/shared/http-utils.ts @@ -63,14 +63,42 @@ function getCorsOrigin(req: IncomingMessage): string { * Send a JSON response with optional status code and CORS headers. */ export function sendJson(res: ServerResponse, data: unknown, status = 200, req?: IncomingMessage): void { + sendJsonInternal(res, data, status, req, false); +} + +export function sendNoCacheJson( + res: ServerResponse, + data: unknown, + status = 200, + req?: IncomingMessage, +): void { + sendJsonInternal(res, data, status, req, true); +} + +function sendJsonInternal( + res: ServerResponse, + data: unknown, + status = 200, + req?: IncomingMessage, + noCache = false, +): void { const body = JSON.stringify(data); const origin = req ? getCorsOrigin(req) : "*"; - res.writeHead(status, { + const headers: Record = { "Content-Type": "application/json", + "Vary": "Origin", "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH", "Access-Control-Allow-Headers": "Content-Type, Authorization, x-opencode-directory", - }); + }; + + if (noCache) { + headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + headers["Pragma"] = "no-cache"; + headers["Expires"] = "0"; + } + + res.writeHead(status, headers); res.end(body); } diff --git a/shared/settings-sync.ts b/shared/settings-sync.ts new file mode 100644 index 00000000..d6c99107 --- /dev/null +++ b/shared/settings-sync.ts @@ -0,0 +1,155 @@ +export const SETTINGS_SYNC_ENABLED_KEY = "settingsSyncEnabled" as const; + +export const SHARED_SETTINGS_KEYS = [ + "theme", + "locale", + "engineModels", + "defaultEngine", + "showDefaultWorkspace", + "scheduledTasksEnabled", + "worktreeEnabled", +] as const; + +export const LOCAL_ONLY_SETTINGS_KEYS = [ + "lastSessionId", + "fileExplorerPanelOpen", + "fileExplorerPanelWidth", + "fileExplorerTreeWidth", + "fileExplorerActiveTab", +] as const; + +const SHARED_SETTINGS_KEY_SET = new Set(SHARED_SETTINGS_KEYS); +const LOCAL_ONLY_SETTINGS_KEY_SET = new Set(LOCAL_ONLY_SETTINGS_KEYS); + +export type SharedSettingsKey = (typeof SHARED_SETTINGS_KEYS)[number]; +export type LocalOnlySettingsKey = (typeof LOCAL_ONLY_SETTINGS_KEYS)[number]; + +export function isSharedSettingsKey(key: string): key is SharedSettingsKey { + return SHARED_SETTINGS_KEY_SET.has(key); +} + +export function isLocalOnlySettingsKey(key: string): key is LocalOnlySettingsKey { + return LOCAL_ONLY_SETTINGS_KEY_SET.has(key); +} + +export function filterSharedSettings(settings: Record): Record { + const filtered: Record = {}; + for (const key of SHARED_SETTINGS_KEYS) { + if (Object.prototype.hasOwnProperty.call(settings, key)) { + filtered[key] = settings[key]; + } + } + return filtered; +} + +export function filterSharedSettingsPatch(patch: Record): Record { + const filtered: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (isSharedSettingsKey(key) && isValidSharedSettingValue(key, value)) { + filtered[key] = value; + } + } + return filtered; +} + +const VALID_THEMES = new Set(["light", "dark", "system"]); +const VALID_LOCALES = new Set(["en", "zh", "ru"]); +const MAX_ENGINE_MODELS = 16; +const MAX_ENGINE_KEY_LENGTH = 64; +const MAX_ENGINE_MODEL_FIELD_LENGTH = 256; +const ALLOWED_ENGINE_MODEL_KEYS = new Set(["providerID", "modelID", "enabled"]); + +function isPlainObjectValue(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Validate that a shared setting value has the expected type/shape. */ +function isValidSharedSettingValue(key: SharedSettingsKey, value: unknown): boolean { + switch (key) { + case "theme": + return typeof value === "string" && VALID_THEMES.has(value); + case "locale": + return typeof value === "string" && VALID_LOCALES.has(value); + case "defaultEngine": + return typeof value === "string" && value.length <= 64; + case "showDefaultWorkspace": + case "scheduledTasksEnabled": + case "worktreeEnabled": + return typeof value === "boolean"; + case "engineModels": { + if (!isPlainObjectValue(value)) return false; + const entries = Object.entries(value); + if (entries.length > MAX_ENGINE_MODELS) return false; + for (const [engineKey, v] of entries) { + if (engineKey.length === 0 || engineKey.length > MAX_ENGINE_KEY_LENGTH) return false; + if (engineKey === "__proto__" || engineKey === "constructor" || engineKey === "prototype") return false; + if (!isPlainObjectValue(v)) return false; + const entry = v as Record; + for (const key of Object.keys(entry)) { + if (!ALLOWED_ENGINE_MODEL_KEYS.has(key)) return false; + } + if ( + "providerID" in entry + && (typeof entry.providerID !== "string" || entry.providerID.length > MAX_ENGINE_MODEL_FIELD_LENGTH) + ) return false; + if ( + "modelID" in entry + && (typeof entry.modelID !== "string" || entry.modelID.length > MAX_ENGINE_MODEL_FIELD_LENGTH) + ) return false; + if ("enabled" in entry && typeof entry.enabled !== "boolean") return false; + for (const nestedValue of Object.values(entry)) { + if (nestedValue !== null && typeof nestedValue === "object") return false; + } + } + return true; + } + default: + return false; + } +} + +export function filterSharedSettingsRemoveKeys(removeKeys: string[]): string[] { + return removeKeys.filter((key) => isSharedSettingsKey(key)); +} + +export function getSettingsSyncEnabled(settings: Record): boolean { + return settings[SETTINGS_SYNC_ENABLED_KEY] === true; +} + +export function applySettingsMutation( + existing: Record, + patch: Record, + removeKeys: string[] = [], +): Record { + const next = { ...existing }; + + for (const key of removeKeys) { + delete next[key]; + } + + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + delete next[key]; + continue; + } + + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + next[key] && + typeof next[key] === "object" && + !Array.isArray(next[key]) + ) { + next[key] = { + ...(next[key] as Record), + ...(value as Record), + }; + continue; + } + + next[key] = value; + } + + return next; +} diff --git a/src/App.tsx b/src/App.tsx index e6fd5433..02d82854 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,17 @@ import { Router, HashRouter, Route, useNavigate, Navigate } from "@solidjs/router"; -import { createEffect, createSignal, lazy, onMount, Show, Suspense, type ParentComponent } from "solid-js"; +import { createEffect, createSignal, lazy, onCleanup, onMount, Show, Suspense, type ParentComponent } from "solid-js"; import { Auth } from "./lib/auth"; import { I18nProvider, useI18n } from "./lib/i18n"; import { logger } from "./lib/logger"; import { initElectronTitleBar, isElectron } from "./lib/platform"; -import "./lib/theme"; +import { bootstrapSharedSettings, subscribeToSettingsChanges } from "./lib/settings"; import { AccessRequestNotification } from "./components/AccessRequestNotification"; import { UpdateNotification } from "./components/UpdateNotification"; import { NotificationToast } from "./components/NotificationToast"; import { Spinner } from "./components/Spinner"; import EntryPage from "./pages/EntryPage"; import Chat from "./pages/Chat"; +import { applyRendererSettingsState } from "./lib/renderer-settings"; const PageFallback = () => (
@@ -207,6 +208,24 @@ function App() { onMount(() => { initElectronTitleBar(); + const initializeSharedSettings = async () => { + if (isElectron()) return; + if (!Auth.isAuthenticated()) return; + await bootstrapSharedSettings(); + applyRendererSettingsState(); + }; + + void initializeSharedSettings(); + + const unsubscribeSettingsChanged = subscribeToSettingsChanges(() => { + applyRendererSettingsState(); + }); + // Register cleanup immediately so the subscription is always torn down on + // unmount, even if the component unmounts during the async init above. + onCleanup(() => { + unsubscribeSettingsChanged?.(); + }); + if (isElectron()) { const api = (window as any).electronAPI; if (api?.startup) { diff --git a/src/components/DeleteWorktreeModal.tsx b/src/components/DeleteWorktreeModal.tsx new file mode 100644 index 00000000..3704a98e --- /dev/null +++ b/src/components/DeleteWorktreeModal.tsx @@ -0,0 +1,110 @@ +import { createSignal, Show } from "solid-js"; +import { useI18n, formatMessage } from "../lib/i18n"; +import { logger } from "../lib/logger"; + +interface DeleteWorktreeModalProps { + isOpen: boolean; + worktreeName: string; + worktreeBranch: string; + sessionCount: number; + onClose: () => void; + onConfirm: () => Promise; +} + +export function DeleteWorktreeModal(props: DeleteWorktreeModalProps) { + const { t } = useI18n(); + const [loading, setLoading] = createSignal(false); + + const handleConfirm = async () => { + setLoading(true); + try { + await props.onConfirm(); + props.onClose(); + } catch (error) { + logger.error("Failed to delete worktree:", error); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + + ); +} diff --git a/src/components/FeishuConfigModal.tsx b/src/components/FeishuConfigModal.tsx index 0da9525b..890c5420 100644 --- a/src/components/FeishuConfigModal.tsx +++ b/src/components/FeishuConfigModal.tsx @@ -2,6 +2,7 @@ import { createSignal, createEffect, Show } from "solid-js"; import { useI18n } from "../lib/i18n"; interface FeishuConfig { + platform: "feishu" | "lark"; appId: string; appSecret: string; autoApprovePermissions: boolean; @@ -88,6 +89,23 @@ export function FeishuConfigModal(props: FeishuConfigModalProps) { {/* Body */}
+ {/* Platform */} +
+ + +

+ {t().channel.platformDesc} +

+
{/* App ID */}