* chore(ai): update dependencies and enhance logging functionality

- Bump version of `@github/copilot-sdk` and related packages to `0.1.32` and `1.0.7` for improved features and bug fixes.
- Update Discord invite links across multiple README files to the new server.
- Introduce a new logging utility in `server/utils/server-logger.ts` for better server process logging, including automatic log cleanup and directory management.
- Enhance the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands.
- Refactor `resolve-claude-cli.ts` and `resolve-copilot-cli.ts` to include detailed logging for CLI binary resolution processes.

This update improves dependency management, enhances user experience with updated links, and provides better insights into server operations through logging.

* chore: bump version from 0.4.0 to 0.4.3 in package.json
This commit is contained in:
Kayshen Xu 2026-03-18 21:34:58 +08:00 committed by GitHub
parent 18be43416a
commit 6a1891fc6e
42 changed files with 854 additions and 271 deletions

View file

@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Discord
url: https://discord.gg/KwXp6BJD
url: https://discord.gg/h9Fmyy6pVh
about: Ask questions and chat with the community

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail
## Community
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Unserem Discord beitreten</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -312,7 +312,7 @@ bun run electron:build # Empaquetado de Electron
## Comunidad
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Únete a nuestro Discord</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -312,7 +312,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour
## Communauté
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Rejoindre notre Discord</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ bun run electron:build # Electron पैकेज
## समुदाय
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> हमारे Discord में शामिल हों</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt
## Komunitas
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Bergabung dengan Discord kami</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ bun run electron:build # Electron パッケージング
## コミュニティ
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Discord に参加する</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ bun run electron:build # Electron 패키징
## 커뮤니티
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Discord에 참여하기</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
## Community
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Join our Discord</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh
## Comunidade
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Entre no nosso Discord</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ bun run electron:build # Упаковка Electron
## Сообщество
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Присоединяйтесь к нашему Discord</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ bun run electron:build # Electron package
## ชุมชน
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> เข้าร่วม Discord ของเรา</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
## Topluluk
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Discord'umuza katılın</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v
## Cộng đồng
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Tham gia Discord của chúng tôi</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -301,7 +301,7 @@ bun run electron:build # Electron 封裝
## 社群
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> 加入我們的 Discord</strong>
</a>

View file

@ -17,7 +17,7 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat&color=cfb537" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil?color=64748b" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
<a href="https://discord.gg/h9Fmyy6pVh"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white&color=5865F2" alt="Discord" /></a>
</p>
<br />
@ -302,7 +302,7 @@ bun run electron:build # Electron 打包
## 社区
<a href="https://discord.gg/KwXp6BJD">
<a href="https://discord.gg/h9Fmyy6pVh">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> 加入我们的 Discord</strong>
</a>

View file

@ -19,7 +19,7 @@
"@fontsource/raleway": "^5.2.8",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-sans-3": "^5.2.9",
"@github/copilot-sdk": "^0.1.30",
"@github/copilot-sdk": "^0.1.32",
"@iconify-json/feather": "^1.2.1",
"@iconify-json/lucide": "^1.2.93",
"@iconify-json/simple-icons": "^1.2.71",
@ -263,21 +263,21 @@
"@fontsource/source-sans-3": ["@fontsource/source-sans-3@5.2.9", "https://registry.npmmirror.com/@fontsource/source-sans-3/-/source-sans-3-5.2.9.tgz", {}, "sha512-u3ymIq4rfmCCyB9MEw/sFR5lPVJ1yTNXmIMbUz+9kVCFIHvNtfzXOEBuvkg3Tk0zhmioPeJ28ZK5smZ7TurezQ=="],
"@github/copilot": ["@github/copilot@0.0.420", "https://registry.npmmirror.com/@github/copilot/-/copilot-0.0.420.tgz", { "optionalDependencies": { "@github/copilot-darwin-arm64": "0.0.420", "@github/copilot-darwin-x64": "0.0.420", "@github/copilot-linux-arm64": "0.0.420", "@github/copilot-linux-x64": "0.0.420", "@github/copilot-win32-arm64": "0.0.420", "@github/copilot-win32-x64": "0.0.420" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-UpPuSjxUxQ+j02WjZEFffWf0scLb23LvuGHzMFtaSsweR+P/BdbtDUI5ZDIA6T0tVyyt6+X1/vgfsJiRqd6jig=="],
"@github/copilot": ["@github/copilot@1.0.7", "https://registry.npmmirror.com/@github/copilot/-/copilot-1.0.7.tgz", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.7", "@github/copilot-darwin-x64": "1.0.7", "@github/copilot-linux-arm64": "1.0.7", "@github/copilot-linux-x64": "1.0.7", "@github/copilot-win32-arm64": "1.0.7", "@github/copilot-win32-x64": "1.0.7" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-KHBaJ1kbc19pqUMnB9LubPtwWVOaDCzWbzwsJss+DvHyCpr8wP8jR3GEZUnhq3rsuXI96ZKEeEozXM0NqxCAiw=="],
"@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.420.tgz", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-sj8Oxcf3oKDbeUotm2gtq5YU1lwCt3QIzbMZioFD/PMLOeqSX/wrecI+c0DDYXKofFhALb0+DxxnWgbEs0mnkQ=="],
"@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.7", "https://registry.npmmirror.com/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.7.tgz", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-yQITowpkQYamww59CwcG5JTWV9ahj7nMH6oqObMJaeqXnG7j7dqE/YhLkujQZ3XR8VXAoIa1rZ3TahdMu94gOA=="],
"@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@0.0.420", "https://registry.npmmirror.com/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.420.tgz", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-2acA93IqXz1uuz3TVUm0Y7BVrBr0MySh1kQa8LqMILhTsG0YHRMm8ybzTp2HA7Mi1tl5CjqMSk163kkS7OzfUA=="],
"@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.7", "https://registry.npmmirror.com/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.7.tgz", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-23vP5bHaFA030nB3tr+dUUdRm2SqmQbs2fZUQ4F7JeYy59jp9hi8lBdaZp/TeQnjEirAUU9H2HZxsGRIIUWp7g=="],
"@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.420.tgz", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-h/IvEryTOYm1HzR2GNq8s2aDtN4lvT4MxldfZuS42CtWJDOfVG2jLLsoHWU1T3QV8j1++PmDgE//HX0JLpLMww=="],
"@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.7", "https://registry.npmmirror.com/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.7.tgz", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-g0mB98oyXKcpd4sMNBc5n1h3UhLy9AGRlT//VL8BXPSzvlTH/dJP3fdx74pbLSgvz105to/YUMmEAFfv25VNaw=="],
"@github/copilot-linux-x64": ["@github/copilot-linux-x64@0.0.420", "https://registry.npmmirror.com/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.420.tgz", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-iL2NpZvXIDZ+3lw7sO2fo5T0nKmP5dZbU2gdYcv+SFBm/ONhCxIY5VRX4yN/9VkFaa9ePv5JzCnsl3vZINiDxg=="],
"@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.7", "https://registry.npmmirror.com/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.7.tgz", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-TRxzvTo9I4ehYJLFHTCJSJYQ4QnO/V9zebqwszxHpJRxuBd7FV4cxLmfOBqZcUpEpZgBH+VJ4OG98BPW7YEtJQ=="],
"@github/copilot-sdk": ["@github/copilot-sdk@0.1.30", "https://registry.npmmirror.com/@github/copilot-sdk/-/copilot-sdk-0.1.30.tgz", { "dependencies": { "@github/copilot": "^0.0.420", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-Stg+h8xsPRR0TNGBQfd9laxhJfWZ6DsdpbowcKIZoyKxZvMAbjnY0zyDeOpewJbxWBTJVhBZb5okOq6iaPNMZw=="],
"@github/copilot-sdk": ["@github/copilot-sdk@0.1.32", "https://registry.npmmirror.com/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz", { "dependencies": { "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA=="],
"@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.420.tgz", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-Njlc2j9vYSBAL+lC6FIEhQ3C+VxO3xavwKnw0ecVRiNLcGLyPrTdzPfPQOmEjC63gpVCqLabikoDGv8fuLPA2w=="],
"@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.7", "https://registry.npmmirror.com/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.7.tgz", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-4yFgW1K0MlKBrK5BwMIj4nMu5KSFfytNXrs8iOpVgp7erEvKVyN7VXb6SWkoU3M9TfeNlqP6Uje2rxDvgR1u5w=="],
"@github/copilot-win32-x64": ["@github/copilot-win32-x64@0.0.420", "https://registry.npmmirror.com/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.420.tgz", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-rZlH35oNehAP2DvQbu4vQFVNeCh/1p3rUjafBYaEY0Nkhx7RmdrYBileL5U3PtRPPRsBPaq3Qp+pVIrGoCDLzQ=="],
"@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.7", "https://registry.npmmirror.com/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.7.tgz", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-RDZlvPf/q6B54wLXJRmI39fc9+pwfcAjSwUqw0FeQruCTQgoUl8eo9NqeVWDFlr3RdzgVSMUiJHc3aiifVG6lA=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.9.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.4.0",
"version": "0.4.3",
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
"author": {
"name": "ZSeven-W",
@ -42,7 +42,7 @@
"@fontsource/raleway": "^5.2.8",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-sans-3": "^5.2.9",
"@github/copilot-sdk": "^0.1.30",
"@github/copilot-sdk": "^0.1.32",
"@iconify-json/feather": "^1.2.1",
"@iconify-json/lucide": "^1.2.93",
"@iconify-json/simple-icons": "^1.2.71",

View file

@ -1,6 +1,8 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { existsSync } from 'node:fs'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { serverLog } from '../../utils/server-logger'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
@ -15,6 +17,10 @@ interface ConnectResult {
models: GroupedModel[]
error?: string
notInstalled?: boolean
/** Human-readable connection status, e.g. "Connected via API key" */
connectionInfo?: string
/** Config file path for the hint (client renders localized text) */
hintPath?: string
}
/**
@ -65,7 +71,9 @@ const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [
/** Connect to Claude Code via Agent SDK and fetch real supported models */
async function connectClaudeCode(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Claude Code...')
const claudePath = resolveClaudeCli()
serverLog.info(`[connect-agent] resolved claude path: ${claudePath ?? 'NOT FOUND'}`)
if (!claudePath) {
return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' }
}
@ -75,6 +83,8 @@ async function connectClaudeCode(): Promise<ConnectResult> {
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
serverLog.info(`[connect-agent] claude env keys: ${Object.keys(env).join(', ')}`)
serverLog.info(`[connect-agent] claude debugFile: ${debugFile ?? 'none'}`)
const q = query({
prompt: '',
@ -89,7 +99,17 @@ async function connectClaudeCode(): Promise<ConnectResult> {
},
})
serverLog.info('[connect-agent] querying supportedModels...')
const raw = await q.supportedModels()
// Fetch account info (email, org, subscription type)
let account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null = null
try {
account = await q.accountInfo()
serverLog.info(`[connect-agent] claude account: email=${account?.email ?? 'n/a'}, type=${account?.subscriptionType ?? 'n/a'}, source=${account?.apiKeySource ?? 'n/a'}`)
} catch {
serverLog.info('[connect-agent] accountInfo() not available')
}
q.close()
const models: GroupedModel[] = raw.map((m) => ({
@ -99,19 +119,107 @@ async function connectClaudeCode(): Promise<ConnectResult> {
provider: 'anthropic' as const,
}))
return { connected: true, models }
serverLog.info(`[connect-agent] claude connected, ${models.length} models found`)
const claudeInfo = buildClaudeConnectionInfo(env, account)
return { connected: true, models, ...claudeInfo }
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] claude connection error: ${msg}`)
// Third-party API proxies often don't support the supportedModels() call,
// causing "query closed before response". Fall back to a default model list
// so users can still connect and choose a model.
if (/closed before|closed early|query closed/i.test(msg)) {
return { connected: true, models: FALLBACK_CLAUDE_MODELS }
serverLog.info('[connect-agent] using fallback model list (proxy detected)')
const fallbackEnv = buildClaudeAgentEnv()
const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null)
return { connected: true, models: FALLBACK_CLAUDE_MODELS, ...claudeInfo }
}
return { connected: false, models: [], error: friendlyClaudeError(msg) }
}
}
/** Resolve config file path (cross-platform) */
function configPath(unixPath: string, winPath: string): string {
return process.platform === 'win32' ? winPath : unixPath
}
/** Build Claude connection info from env + SDK account info */
function buildClaudeConnectionInfo(
env: Record<string, string | undefined>,
account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null,
): { connectionInfo: string; hintPath?: string } {
const hp = configPath('~/.claude/settings.json', '%USERPROFILE%\\.claude\\settings.json')
const apiKey = env.ANTHROPIC_API_KEY
const baseUrl = env.ANTHROPIC_BASE_URL
if (account?.email) {
const sub = account.subscriptionType ?? 'subscription'
return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp }
}
if (apiKey && baseUrl) {
return { connectionInfo: 'Connected via API key (custom endpoint)', hintPath: hp }
}
if (apiKey) {
const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***'
return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }
}
return { connectionInfo: 'Connected via subscription', hintPath: hp }
}
/** Decode a JWT payload (no verification — just base64url decode the middle part) */
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
// base64url → base64 → Buffer → JSON
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'))
} catch {
return null
}
}
/** Build Codex CLI connection info by reading ~/.codex/auth.json + JWT tokens */
async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> {
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const hp = configPath('~/.codex/config.json', '%USERPROFILE%\\.codex\\config.json')
if (process.env.OPENAI_API_KEY) {
const key = process.env.OPENAI_API_KEY
const masked = key.length > 12 ? `${key.slice(0, 8)}...` : '***'
return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }
}
try {
const authPath = join(homedir(), '.codex', 'auth.json')
const raw = await readFile(authPath, 'utf-8')
const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } }
const idToken = auth.tokens?.id_token
if (idToken) {
const payload = decodeJwtPayload(idToken)
if (payload) {
const email = payload.email as string | undefined
const authClaims = payload['https://api.openai.com/auth'] as Record<string, unknown> | undefined
const plan = authClaims?.chatgpt_plan_type as string | undefined
serverLog.info(`[connect-agent] codex JWT: email=${email ?? 'n/a'}, plan=${plan ?? 'n/a'}`)
if (email) {
const label = plan ?? auth.auth_mode ?? 'subscription'
return { connectionInfo: `Connected via ${label} (${email})`, hintPath: hp }
}
}
}
if (auth.auth_mode) {
return { connectionInfo: `Connected via ${auth.auth_mode}`, hintPath: hp }
}
} catch { /* auth.json not found */ }
return { connectionInfo: 'Connected via Codex CLI', hintPath: hp }
}
/** Map raw Agent SDK errors to user-friendly messages */
function friendlyClaudeError(raw: string): string {
if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) {
@ -131,33 +239,85 @@ function friendlyClaudeError(raw: string): string {
/** Connect to Codex CLI and fetch its supported models from the local cache */
async function connectCodexCli(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Codex CLI...')
try {
const { execSync } = await import('node:child_process')
const { readFile } = await import('node:fs/promises')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const isWin = process.platform === 'win32'
// Check if codex binary exists
const whichCmd = process.platform === 'win32' ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'
const which = execSync(whichCmd, {
encoding: 'utf-8',
timeout: 5000,
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
// Check if codex binary exists — PATH, npm prefix, then common locations
let which = ''
if (!which) {
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }
// 1. PATH lookup
try {
const whichCmd = isWin ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'
serverLog.info(`[connect-agent] codex PATH lookup: ${whichCmd}`)
const result = execSync(whichCmd, {
encoding: 'utf-8',
timeout: 5000,
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
if (result && existsSync(result)) which = result
serverLog.info(`[connect-agent] codex PATH result: "${result}" (exists=${result ? existsSync(result) : false})`)
} catch (err) {
serverLog.info(`[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// Verify codex is responsive
// 2. npm prefix -g (Windows: npm global creates .cmd wrappers)
if (!which && isWin) {
try {
serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g')
const prefix = execSync('npm.cmd prefix -g', {
encoding: 'utf-8',
timeout: 5000,
}).trim()
serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`)
if (prefix) {
const bin = join(prefix, 'codex.cmd')
serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) which = bin
}
} catch (err) {
serverLog.info(`[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
}
// 3. Common install locations
if (!which && isWin) {
const candidates = [
join(process.env.APPDATA || '', 'npm', 'codex.cmd'),
join(process.env.NVM_SYMLINK || '', 'codex.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'codex.cmd'),
]
for (const c of candidates) {
const exists = c ? existsSync(c) : false
serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`)
if (c && exists) { which = c; break }
}
}
if (!which) {
serverLog.warn('[connect-agent] codex not found')
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }
}
serverLog.info(`[connect-agent] codex resolved: "${which}"`)
// Verify codex is responsive — on Windows, use the resolved path or .cmd wrapper
const versionCmd = isWin ? `"${which}" --version 2>&1` : 'codex --version 2>&1'
try {
execSync('codex --version 2>&1', { encoding: 'utf-8', timeout: 5000 })
} catch {
const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
serverLog.info(`[connect-agent] codex version: ${ver}`)
} catch (err) {
serverLog.error(`[connect-agent] codex --version failed: ${err instanceof Error ? err.message : err}`)
return { connected: false, models: [], error: 'Codex CLI not responding' }
}
// Read models from Codex CLI's local models cache
let models: GroupedModel[] = []
const cachePath = join(homedir(), '.codex', 'models_cache.json')
serverLog.info(`[connect-agent] codex models cache: "${cachePath}" (exists=${existsSync(cachePath)})`)
try {
const raw = await readFile(cachePath, 'utf-8')
@ -182,17 +342,21 @@ async function connectCodexCli(): Promise<ConnectResult> {
provider: 'openai' as const,
}))
}
} catch {
// Cache file not found or unreadable
} catch (cacheErr) {
serverLog.info(`[connect-agent] codex cache read failed: ${cacheErr instanceof Error ? cacheErr.message : cacheErr}`)
}
if (models.length === 0) {
serverLog.info('[connect-agent] codex: no models found')
return { connected: false, models: [], error: 'No models found. Try running codex once to populate the model cache.' }
}
return { connected: true, models }
serverLog.info(`[connect-agent] codex connected, ${models.length} models found`)
const codexInfo = await buildCodexConnectionInfo()
return { connected: true, models, ...codexInfo }
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] codex connection error: ${msg}`)
return { connected: false, models: [], error: msg }
}
}
@ -205,23 +369,34 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
const { join } = await import('node:path')
const isWin = process.platform === 'win32'
serverLog.info(`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}`)
// 1. Try PATH lookup
try {
const cmd = isWin ? 'where opencode' : 'which opencode 2>/dev/null'
const cmd = isWin ? 'where opencode 2>nul' : 'which opencode 2>/dev/null'
serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`)
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim()
serverLog.info(`[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`)
if (result && existsSync(result)) return result
} catch { /* not in PATH */ }
} catch (err) {
serverLog.info(`[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. Try `npm prefix -g` to find actual npm global bin directory
// On Windows, must use `npm.cmd` since Electron spawns cmd.exe
try {
const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g'
serverLog.info(`[resolve-opencode] npm prefix lookup: ${npmCmd}`)
const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`)
if (prefix) {
const bin = isWin ? join(prefix, 'opencode.cmd') : join(prefix, 'bin', 'opencode')
serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
} catch { /* npm not available */ }
} catch (err) {
serverLog.info(`[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
// 3. Common install locations
// npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux)
@ -251,27 +426,35 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
join(home, '.local', 'bin', 'opencode'),
]
for (const c of candidates) {
if (c && existsSync(c)) return c
const exists = c ? existsSync(c) : false
serverLog.info(`[resolve-opencode] candidate: "${c}" (exists=${exists})`)
if (c && exists) return c
}
serverLog.info('[resolve-opencode] no opencode binary found')
return undefined
}
/** Connect to OpenCode and fetch its configured providers/models. */
async function connectOpenCode(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to OpenCode...')
try {
const binaryPath = await resolveOpencodeBinary()
serverLog.info(`[connect-agent] resolved opencode path: ${binaryPath ?? 'NOT FOUND'}`)
if (!binaryPath) {
return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' }
}
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
serverLog.info('[connect-agent] creating opencode client...')
const { client, server } = await getOpencodeClient()
serverLog.info('[connect-agent] fetching opencode providers...')
const { data, error } = await client.config.providers()
releaseOpencodeServer(server)
if (error) {
serverLog.error(`[connect-agent] opencode providers error: ${JSON.stringify(error)}`)
return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' }
}
@ -289,21 +472,34 @@ async function connectOpenCode(): Promise<ConnectResult> {
}
if (models.length === 0) {
serverLog.info('[connect-agent] opencode: no models found')
return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' }
}
return { connected: true, models }
const providerNames = (data?.providers ?? []).map((p) => p.name || p.id).filter(Boolean)
const providerSummary = providerNames.length > 0
? `Connected (${providerNames.slice(0, 3).join(', ')}${providerNames.length > 3 ? ` +${providerNames.length - 3}` : ''})`
: 'Connected via OpenCode server'
serverLog.info(`[connect-agent] opencode connected, ${models.length} models found`)
return {
connected: true, models,
connectionInfo: providerSummary,
hintPath: configPath('~/.opencode/config.json', '%USERPROFILE%\\.opencode\\config.json'),
}
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] opencode connection error: ${raw}`)
return { connected: false, models: [], error: friendlyOpenCodeError(raw) }
}
}
/** Connect to GitHub Copilot CLI via @github/copilot-sdk and fetch available models. */
async function connectCopilot(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Copilot...')
// Use standalone copilot binary to avoid Bun's node:sqlite issue
const { resolveCopilotCli } = await import('../../utils/copilot-client')
const cliPath = resolveCopilotCli()
serverLog.info(`[connect-agent] resolved copilot path: ${cliPath ?? 'NOT FOUND'}`)
if (!cliPath) {
return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' }
}
@ -312,10 +508,12 @@ async function connectCopilot(): Promise<ConnectResult> {
const { CopilotClient } = await import('@github/copilot-sdk')
const client = new CopilotClient({ autoStart: true, cliPath })
serverLog.info('[connect-agent] starting copilot client...')
await client.start()
let models: GroupedModel[] = []
try {
serverLog.info('[connect-agent] listing copilot models...')
const modelList = await client.listModels()
models = modelList
.filter((m) => !m.policy || m.policy.state === 'enabled')
@ -327,19 +525,39 @@ async function connectCopilot(): Promise<ConnectResult> {
}))
} catch (listErr) {
const msg = listErr instanceof Error ? listErr.message : 'Failed to list models'
serverLog.error(`[connect-agent] copilot listModels error: ${msg}`)
await client.stop().catch(() => {})
return { connected: false, models: [], error: friendlyCopilotError(msg) }
}
// Try to get auth status for user info
const copilotHintPath = configPath('~/.config/github-copilot/config.json', '%USERPROFILE%\\.config\\github-copilot\\config.json')
let copilotInfo: { connectionInfo: string; hintPath?: string } = { connectionInfo: 'Connected via GitHub', hintPath: copilotHintPath }
try {
const authStatus = await client.getAuthStatus()
serverLog.info(`[connect-agent] copilot auth: ${JSON.stringify(authStatus)}`)
if (authStatus?.login) {
const method = authStatus.authType ? ` (${authStatus.authType})` : ''
copilotInfo = { connectionInfo: `Connected as @${authStatus.login}${method}`, hintPath: copilotHintPath }
} else if (authStatus?.statusMessage) {
copilotInfo = { connectionInfo: authStatus.statusMessage, hintPath: copilotHintPath }
}
} catch (authErr) {
serverLog.warn(`[connect-agent] copilot getAuthStatus failed: ${authErr instanceof Error ? authErr.message : authErr}`)
}
await client.stop()
if (models.length === 0) {
serverLog.info('[connect-agent] copilot: no models found')
return { connected: false, models: [], error: 'No models found. Run "copilot login" to authenticate first.' }
}
return { connected: true, models }
serverLog.info(`[connect-agent] copilot connected, ${models.length} models found`)
return { connected: true, models, ...copilotInfo }
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] copilot connection error: ${raw}`)
return { connected: false, models: [], error: friendlyCopilotError(raw) }
}
}

View file

@ -128,7 +128,8 @@ async function tryNpmInstall(pkg: string, binary: string): Promise<InstallResult
}
try {
execSync(`npm install -g ${pkg}`, {
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execSync(`${npmBin} install -g ${pkg}`, {
encoding: 'utf-8',
timeout: 180_000,
stdio: 'pipe',

View file

@ -1,16 +1,64 @@
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { serverLog } from './server-logger'
const isWindows = process.platform === 'win32'
/** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */
export function resolveCopilotCli(): string | undefined {
serverLog.info(`[resolve-copilot] platform=${process.platform}, isWindows=${isWindows}`)
// 1. Try PATH lookup
try {
const cmd = isWindows ? 'where copilot 2>nul' : 'which copilot 2>/dev/null'
serverLog.info(`[resolve-copilot] PATH lookup: ${cmd}`)
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim()
// `where` on Windows may return multiple lines
const path = result.split(/\r?\n/)[0]?.trim()
return path || undefined
} catch {
return undefined
serverLog.info(`[resolve-copilot] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`)
if (path && existsSync(path)) return path
} catch (err) {
serverLog.info(`[resolve-copilot] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. Try `npm prefix -g` on Windows (npm install -g creates .cmd wrappers)
if (isWindows) {
try {
serverLog.info('[resolve-copilot] trying npm.cmd prefix -g')
const prefix = execSync('npm.cmd prefix -g', {
encoding: 'utf-8',
timeout: 5000,
}).trim()
serverLog.info(`[resolve-copilot] npm global prefix: "${prefix}"`)
if (prefix) {
const bin = join(prefix, 'copilot.cmd')
serverLog.info(`[resolve-copilot] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
} catch (err) {
serverLog.info(`[resolve-copilot] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
}
// 3. Common install locations
if (isWindows) {
const candidates = [
// npm global
join(process.env.APPDATA || '', 'npm', 'copilot.cmd'),
// nvm-windows / fnm
join(process.env.NVM_SYMLINK || '', 'copilot.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'copilot.cmd'),
// winget / native
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'copilot.exe'),
]
for (const c of candidates) {
const exists = c ? existsSync(c) : false
serverLog.info(`[resolve-copilot] candidate: "${c}" (exists=${exists})`)
if (c && exists) return c
}
}
serverLog.warn('[resolve-copilot] no copilot binary found')
return undefined
}

View file

@ -2,6 +2,7 @@ import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
import { serverLog } from './server-logger'
const isWindows = platform() === 'win32'
@ -15,19 +16,52 @@ const isWindows = platform() === 'win32'
* binaries and spawns them directly (no `node` wrapper needed).
*/
export function resolveClaudeCli(): string | undefined {
serverLog.info(`[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}`)
// 1. Try PATH lookup
try {
const cmd = isWindows ? 'where claude' : 'which claude 2>/dev/null'
const p = execSync(cmd, {
const cmd = isWindows ? 'where claude 2>nul' : 'which claude 2>/dev/null'
serverLog.info(`[resolve-claude-cli] PATH lookup: ${cmd}`)
const raw = execSync(cmd, {
encoding: 'utf-8',
timeout: 3000,
}).trim().split(/\r?\n/)[0] // `where` on Windows may return multiple lines
}).trim()
const p = raw.split(/\r?\n/)[0] // `where` on Windows may return multiple lines
serverLog.info(`[resolve-claude-cli] PATH lookup result: "${p}" (exists=${p ? existsSync(p) : false})`)
if (p && existsSync(p)) return p
} catch { /* not in PATH */ }
} catch (err) {
serverLog.info(`[resolve-claude-cli] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. Common install locations
// 2. Try `npm prefix -g` to find actual npm global bin directory
// On Windows, must use `npm.cmd` since Electron spawns cmd.exe
if (isWindows) {
try {
serverLog.info('[resolve-claude-cli] trying npm.cmd prefix -g')
const prefix = execSync('npm.cmd prefix -g', {
encoding: 'utf-8',
timeout: 5000,
}).trim()
serverLog.info(`[resolve-claude-cli] npm global prefix: "${prefix}"`)
if (prefix) {
const bin = join(prefix, 'claude.cmd')
serverLog.info(`[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
} catch (err) {
serverLog.info(`[resolve-claude-cli] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
}
}
// 3. Common install locations
const candidates = isWindows
? [
// npm global (npm install -g creates .cmd wrappers here)
join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
// nvm-windows / fnm
join(process.env.NVM_SYMLINK || '', 'claude.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'claude.cmd'),
// Native .exe install locations
join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'),
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'),
join(homedir(), '.claude', 'local', 'claude.exe'),
@ -39,8 +73,11 @@ export function resolveClaudeCli(): string | undefined {
'/opt/homebrew/bin/claude',
]
for (const c of candidates) {
if (c && existsSync(c)) return c
const exists = c ? existsSync(c) : false
serverLog.info(`[resolve-claude-cli] candidate: "${c}" (exists=${exists})`)
if (c && exists) return c
}
serverLog.warn('[resolve-claude-cli] no claude binary found')
return undefined
}

View file

@ -0,0 +1,88 @@
/**
* Simple file logger for the Nitro server process.
*
* Writes to `~/.openpencil/logs/server-{YYYY-MM-DD}.log`.
* Also forwards to console for dev mode visibility.
* Keeps the last 7 days of logs, auto-cleans on first write.
*/
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const MAX_LOG_DAYS = 7
const logDir = join(homedir(), '.openpencil', 'logs')
let dirEnsured = false
let cleanedUp = false
function ensureDir(): void {
if (dirEnsured) return
try {
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
dirEnsured = true
} catch {
// Silently fail — logging is best-effort
}
}
function todayStamp(): string {
return new Date().toISOString().slice(0, 10)
}
function timestamp(): string {
return new Date().toISOString()
}
function getLogFilePath(): string {
return join(logDir, `server-${todayStamp()}.log`)
}
function cleanOldLogs(): void {
if (cleanedUp) return
cleanedUp = true
try {
const files = readdirSync(logDir)
const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000
for (const file of files) {
if (!file.endsWith('.log')) continue
const filePath = join(logDir, file)
try {
const s = statSync(filePath)
if (s.mtimeMs < cutoff) {
unlinkSync(filePath)
}
} catch {
// ignore individual file errors
}
}
} catch {
// ignore
}
}
function writeLine(level: string, msg: string): void {
const line = `${timestamp()} [${level}] ${msg}\n`
// Forward to console
if (level === 'ERROR') {
process.stderr.write(line)
} else {
process.stdout.write(line)
}
// Write to file
try {
ensureDir()
cleanOldLogs()
appendFileSync(getLogFilePath(), line, 'utf-8')
} catch {
// Disk full or permission error — silently drop
}
}
export const serverLog = {
info: (msg: string) => writeLine('INFO', msg),
warn: (msg: string) => writeLine('WARN', msg),
error: (msg: string) => writeLine('ERROR', msg),
}

View file

@ -3,7 +3,7 @@ import type { SVGProps } from 'react'
export default function CopilotLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 98 96"
viewBox="-14 -14 126 124"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}

View file

@ -1,7 +1,10 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ComponentType, SVGProps } from 'react'
import { useTranslation } from 'react-i18next'
import { X, Check, Loader2, Unplug, AlertCircle, Zap, Terminal, Play, Square, Globe, Copy, RefreshCw, Download, ExternalLink } from 'lucide-react'
import {
X, Check, Loader2, Unplug, AlertCircle, Terminal, Play, Square,
Copy, Download, ExternalLink, Pen, Settings,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
@ -43,9 +46,11 @@ const PROVIDER_META: Record<
},
}
type SettingsTab = 'agents' | 'mcp' | 'system'
async function connectAgent(
agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot',
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string; notInstalled?: boolean }> {
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string; notInstalled?: boolean; connectionInfo?: string; hintPath?: string }> {
try {
const res = await fetch('/api/ai/connect-agent', {
method: 'POST',
@ -89,7 +94,8 @@ async function callMcpInstall(
return res.json()
}
function ProviderRow({ type }: { type: AIProviderType }) {
/* ---------- ProviderCard ---------- */
function ProviderCard({ type }: { type: AIProviderType }) {
const { t } = useTranslation()
const provider = useAgentSettingsStore((s) => s.providers[type])
const connect = useAgentSettingsStore((s) => s.connectProvider)
@ -111,7 +117,7 @@ function ProviderRow({ type }: { type: AIProviderType }) {
setInstallInfo(null)
const result = await connectAgent(meta.agent)
if (result.connected) {
connect(type, meta.agent, result.models)
connect(type, meta.agent, result.models, result.connectionInfo, result.hintPath)
persist()
} else if (result.notInstalled) {
setNotInstalled(true)
@ -134,7 +140,6 @@ function ProviderRow({ type }: { type: AIProviderType }) {
setInstallInfo(null)
const result = await installAgent(meta.agent)
if (result.success) {
// Auto-connect after successful install
setIsInstalling(false)
setNotInstalled(false)
handleConnect()
@ -160,7 +165,6 @@ function ProviderRow({ type }: { type: AIProviderType }) {
const { Icon } = meta
// Button logic: connected → Disconnect, installing → spinner, notInstalled (no instructions yet) → Install, else → Connect
const renderAction = () => {
if (provider.isConnected) {
return (
@ -211,41 +215,47 @@ function ProviderRow({ type }: { type: AIProviderType }) {
<div className="group">
<div
className={cn(
'flex items-center gap-2.5 px-3 py-1.5 rounded-lg transition-colors',
'flex items-center gap-3 px-3.5 py-2.5 rounded-lg border transition-colors',
provider.isConnected
? 'bg-secondary/40'
: 'hover:bg-secondary/30',
? 'bg-secondary/30 border-border'
: 'border-transparent hover:bg-secondary/20',
)}
>
{/* Icon */}
<div
className={cn(
'w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-colors',
provider.isConnected ? 'bg-foreground/10 text-foreground' : 'bg-secondary text-muted-foreground',
'w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors',
provider.isConnected ? 'bg-foreground/8 text-foreground' : 'bg-secondary text-muted-foreground',
)}
>
<Icon className="w-3.5 h-3.5" />
<Icon className="w-5 h-5" />
</div>
{/* Name + description */}
{/* Name + status */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground leading-tight">{t(meta.labelKey)}</span>
<span className="text-[10px] text-muted-foreground leading-tight hidden sm:inline">{t(meta.descriptionKey)}</span>
</div>
{provider.isConnected && (
<span className="text-[13px] font-medium text-foreground leading-tight block">{t(meta.labelKey)}</span>
{provider.isConnected && provider.connectionInfo && (
<span className="text-[11px] text-green-500 leading-tight flex items-center gap-1 mt-0.5">
<Check size={10} strokeWidth={2.5} />
{provider.connectionInfo}
</span>
)}
{provider.isConnected && !provider.connectionInfo && (
<span className="text-[11px] text-green-500 leading-tight flex items-center gap-1 mt-0.5">
<Check size={10} strokeWidth={2.5} />
{t('agents.modelCount', { count: provider.models.length })}
</span>
)}
{!provider.isConnected && !notInstalled && !error && (
<span className="text-[11px] text-muted-foreground leading-tight mt-0.5 block">{t(meta.descriptionKey)}</span>
)}
{notInstalled && !isInstalling && !error && (
<span className="text-[10px] text-amber-500 leading-tight mt-0.5 block">
<span className="text-[11px] text-amber-500 leading-tight mt-0.5 block">
{t('agents.notInstalled')}
</span>
)}
{error && (
<span className="text-[10px] text-destructive leading-tight mt-0.5 block">{error}</span>
<span className="text-[11px] text-destructive leading-tight mt-0.5 block">{error}</span>
)}
</div>
@ -274,10 +284,179 @@ function ProviderRow({ type }: { type: AIProviderType }) {
)}
</div>
)}
{/* Provider-specific hint */}
{provider.isConnected && provider.hintPath && (
<p className="text-[10px] text-muted-foreground/60 px-3.5 mt-1">
{t('settings.envHint', { path: provider.hintPath })}
</p>
)}
</div>
)
}
/* ---------- Sidebar nav item ---------- */
function NavItem({ icon: IconComp, label, active, onClick }: {
icon: ComponentType<{ size?: number; className?: string }>; label: string; active?: boolean; onClick: () => void
}) {
return (
<button onClick={onClick} className={cn(
'flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg text-[13px] transition-colors text-left',
active ? 'bg-secondary text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/40',
)}>
<IconComp size={14} className="shrink-0" />
{label}
</button>
)
}
/* ---------- Agents page ---------- */
function AgentsPage() {
const { t } = useTranslation()
return (
<div>
<h3 className="text-[15px] font-semibold text-foreground mb-4">{t('settings.agents')}</h3>
<div className="space-y-1">
<ProviderCard type="anthropic" />
<ProviderCard type="openai" />
<ProviderCard type="opencode" />
<ProviderCard type="copilot" />
</div>
</div>
)
}
/* ---------- MCP page ---------- */
interface McpPageProps {
mcpServerRunning: boolean; mcpServerLocalIp: string | null; mcpServerLoading: boolean
mcpServerError: string | null; mcpHttpPort: number; configCopied: boolean
mcpIntegrations: { tool: string; displayName: string; enabled: boolean }[]
mcpInstalling: string | null; mcpError: string | null; isBusy: boolean
onServerToggle: () => void; onCopyConfig: () => void
onPortBlur: (value: string) => void; onToggleMCP: (tool: string) => void
}
function McpPage(props: McpPageProps) {
const {
mcpServerRunning, mcpServerLocalIp, mcpServerLoading, mcpServerError,
mcpHttpPort, mcpIntegrations, mcpInstalling, mcpError, isBusy, configCopied,
onServerToggle, onCopyConfig, onPortBlur, onToggleMCP,
} = props
const { t } = useTranslation()
return (
<div>
{/* MCP Server */}
<div className="mb-6">
<h3 className="text-[15px] font-semibold text-foreground mb-3">{t('agents.mcpServer')}</h3>
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg border border-border bg-secondary/20">
<div
className={cn(
'w-2 h-2 rounded-full shrink-0',
mcpServerRunning ? 'bg-green-500' : 'bg-muted-foreground/30',
)}
/>
<span className="text-[13px] text-foreground flex-1">
{mcpServerRunning ? t('agents.mcpServerRunning') : t('agents.mcpServerStopped')}
</span>
<span className="text-[11px] text-muted-foreground shrink-0">{t('agents.port')}</span>
<input
type="text"
inputMode="numeric"
defaultValue={mcpHttpPort}
key={mcpHttpPort}
onBlur={(e) => onPortBlur(e.target.value)}
disabled={mcpServerRunning || mcpServerLoading}
className="h-6 w-[52px] text-[11px] text-center tabular-nums bg-secondary text-foreground rounded border border-input focus:border-ring outline-none transition-colors disabled:opacity-50"
/>
<Button
size="sm"
variant={mcpServerRunning ? 'outline' : 'default'}
onClick={onServerToggle}
disabled={mcpServerLoading}
className="h-7 px-3 text-[11px] shrink-0"
>
{mcpServerLoading ? (
<Loader2 size={11} className="animate-spin" />
) : mcpServerRunning ? (
<>
<Square size={10} className="mr-1" />
{t('agents.mcpServerStop')}
</>
) : (
<>
<Play size={10} className="mr-1" />
{t('agents.mcpServerStart')}
</>
)}
</Button>
</div>
{mcpServerRunning && mcpServerLocalIp && (
<div className="mt-2 px-3.5 py-2 rounded-lg bg-secondary/15 border border-border/50">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-muted-foreground">{t('agents.mcpClientConfig')}</span>
<Button variant="ghost" size="icon-sm" onClick={onCopyConfig} className="shrink-0 h-5 w-5">
{configCopied ? <Check size={9} className="text-green-500" /> : <Copy size={9} />}
</Button>
</div>
<code className="text-[10px] text-muted-foreground font-mono select-all leading-none">
{`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`}
</code>
</div>
)}
{mcpServerError && (
<div className="flex items-center gap-1.5 mt-2 px-1">
<AlertCircle size={11} className="text-destructive shrink-0" />
<p className="text-[11px] text-destructive">{mcpServerError}</p>
</div>
)}
</div>
{/* MCP Integrations */}
<div>
<h3 className="text-[15px] font-semibold text-foreground mb-1">{t('agents.mcpIntegrations')}</h3>
<p className="text-[11px] text-muted-foreground mb-3">{t('agents.mcpRestart')}</p>
<div className="grid grid-cols-2 gap-1.5">
{mcpIntegrations.map((m) => (
<div
key={m.tool}
className={cn(
'flex items-center justify-between py-2 px-3.5 rounded-lg border transition-colors',
m.enabled ? 'bg-secondary/30 border-border' : 'border-transparent hover:bg-secondary/20',
)}
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn(
'text-[12px] truncate',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{m.displayName}
</span>
{mcpInstalling === m.tool && (
<Loader2 size={10} className="animate-spin text-muted-foreground shrink-0" />
)}
</div>
<Switch
checked={m.enabled}
disabled={isBusy}
onCheckedChange={() => onToggleMCP(m.tool)}
className="shrink-0 ml-2"
/>
</div>
))}
</div>
{mcpError && (
<div className="flex items-center gap-1.5 mt-2 px-1">
<AlertCircle size={11} className="text-destructive shrink-0" />
<p className="text-[11px] text-destructive">{mcpError}</p>
</div>
)}
</div>
</div>
)
}
/* ---------- Main Dialog ---------- */
export default function AgentSettingsDialog() {
const { t } = useTranslation()
const open = useAgentSettingsStore((s) => s.dialogOpen)
@ -292,6 +471,8 @@ export default function AgentSettingsDialog() {
const setMcpServerStatus = useAgentSettingsStore((s) => s.setMcpServerStatus)
const dialogRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useState<SettingsTab>('agents')
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
@ -313,7 +494,6 @@ export default function AgentSettingsDialog() {
setIsElectron(!!window.electronAPI)
}, [])
// Fetch MCP server status on dialog open
useEffect(() => {
if (!open) return
fetch('/api/mcp/server')
@ -324,7 +504,6 @@ export default function AgentSettingsDialog() {
.catch(() => {})
}, [open, setMcpServerStatus])
// Fetch auto-update setting on dialog open (Electron only)
useEffect(() => {
if (!open || !window.electronAPI?.updater?.getAutoCheck) return
window.electronAPI.updater.getAutoCheck()
@ -389,11 +568,8 @@ export default function AgentSettingsDialog() {
if (result.success) {
toggleMCP(tool)
persist()
// When the server fell back to HTTP mode (no node installed),
// the MCP HTTP server was auto-started — sync UI status
if (result.fallbackHttp) {
setMcpServerStatus(true, null)
// Refresh actual status (localIp) after server finishes starting
setTimeout(() => {
fetch('/api/mcp/server')
.then((r) => r.json())
@ -437,188 +613,90 @@ export default function AgentSettingsDialog() {
/>
<div
ref={dialogRef}
className="relative bg-card rounded-xl border border-border w-[480px] max-h-[80vh] overflow-hidden shadow-xl flex flex-col"
className="relative bg-card rounded-xl border border-border w-[720px] h-[520px] overflow-hidden shadow-xl flex"
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2">
<h3 className="text-sm font-semibold text-foreground">
{t('agents.title')}
</h3>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDialogOpen(false)}
>
<X size={14} />
</Button>
{/* Sidebar */}
<div className="w-[200px] shrink-0 border-r border-border flex flex-col bg-card">
<div className="px-4 pt-4 pb-3">
<h2 className="text-[15px] font-semibold text-foreground">{t('settings.title')}</h2>
</div>
<nav className="flex-1 px-2 space-y-0.5">
<NavItem
icon={Pen}
label={t('settings.agents')}
active={activeTab === 'agents'}
onClick={() => setActiveTab('agents')}
/>
<NavItem
icon={Terminal}
label={t('settings.mcp')}
active={activeTab === 'mcp'}
onClick={() => setActiveTab('mcp')}
/>
<NavItem
icon={Settings}
label={t('settings.system')}
active={activeTab === 'system'}
onClick={() => setActiveTab('system')}
/>
</nav>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-5 pb-4">
{/* Agents section */}
<div className="mb-3">
<div className="flex items-center gap-2 mb-1 px-1">
<Zap size={12} className="text-muted-foreground" />
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('agents.agentsOnCanvas')}
</h4>
</div>
<div className="space-y-0.5">
<ProviderRow type="anthropic" />
<ProviderRow type="openai" />
<ProviderRow type="opencode" />
<ProviderRow type="copilot" />
</div>
{/* Content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Close button */}
<div className="flex justify-end px-4 pt-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDialogOpen(false)}
>
<X size={14} />
</Button>
</div>
{/* Divider */}
<div className="h-px bg-border mb-3" />
{/* Page content */}
<div className="flex-1 overflow-y-auto px-5 pb-5">
{activeTab === 'agents' && <AgentsPage />}
{/* MCP Server section */}
<div className="mb-3">
<div className="flex items-center gap-2 mb-1.5 px-1">
<Globe size={12} className="text-muted-foreground" />
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('agents.mcpServer')}
</h4>
</div>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary/30">
{/* Status indicator */}
<div
className={cn(
'w-2 h-2 rounded-full shrink-0',
mcpServerRunning ? 'bg-green-500' : 'bg-muted-foreground/30',
)}
{activeTab === 'mcp' && (
<McpPage
mcpServerRunning={mcpServerRunning}
mcpServerLocalIp={mcpServerLocalIp}
mcpServerLoading={mcpServerLoading}
mcpServerError={mcpServerError}
mcpHttpPort={mcpHttpPort}
mcpIntegrations={mcpIntegrations}
mcpInstalling={mcpInstalling}
mcpError={mcpError}
isBusy={isBusy}
configCopied={configCopied}
onServerToggle={handleMcpServerToggle}
onCopyConfig={handleCopyConfig}
onPortBlur={handlePortBlur}
onToggleMCP={handleToggleMCP}
/>
<span className="text-[12px] text-foreground flex-1">
{mcpServerRunning ? t('agents.mcpServerRunning') : t('agents.mcpServerStopped')}
</span>
<span className="text-[11px] text-muted-foreground shrink-0">{t('agents.port')}</span>
<input
type="text"
inputMode="numeric"
defaultValue={mcpHttpPort}
key={mcpHttpPort}
onBlur={(e) => handlePortBlur(e.target.value)}
disabled={mcpServerRunning || mcpServerLoading}
className="h-6 w-[52px] text-[11px] text-center tabular-nums bg-secondary text-foreground rounded border border-input focus:border-ring outline-none transition-colors disabled:opacity-50"
/>
<Button
size="sm"
variant={mcpServerRunning ? 'outline' : 'default'}
onClick={handleMcpServerToggle}
disabled={mcpServerLoading}
className="h-7 px-3 text-[11px] shrink-0"
>
{mcpServerLoading ? (
<Loader2 size={11} className="animate-spin" />
) : mcpServerRunning ? (
<>
<Square size={10} className="mr-1" />
{t('agents.mcpServerStop')}
</>
) : (
<>
<Play size={10} className="mr-1" />
{t('agents.mcpServerStart')}
</>
)}
</Button>
</div>
{mcpServerRunning && mcpServerLocalIp && (
<div className="mt-1.5 px-3 py-1.5 rounded-lg bg-secondary/20">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{t('agents.mcpClientConfig')}</span>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopyConfig}
className="shrink-0 h-5 w-5"
>
{configCopied ? <Check size={9} className="text-green-500" /> : <Copy size={9} />}
</Button>
</div>
<code className="text-[10px] text-muted-foreground font-mono select-all leading-none">{`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`}</code>
</div>
)}
{mcpServerError && (
<div className="flex items-center gap-1.5 mt-2 px-1">
<AlertCircle size={11} className="text-destructive shrink-0" />
<p className="text-[10px] text-destructive">{mcpServerError}</p>
</div>
)}
</div>
{/* Divider */}
<div className="h-px bg-border mb-3" />
{/* MCP integrations section */}
<div>
<div className="flex items-center gap-2 mb-1.5 px-1">
<Terminal size={12} className="text-muted-foreground" />
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('agents.mcpIntegrations')}
</h4>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0">
{mcpIntegrations.map((m) => (
<div
key={m.tool}
className={cn(
'flex items-center justify-between py-1.5 px-3 rounded-lg transition-colors',
m.enabled ? 'bg-secondary/40' : 'hover:bg-secondary/20',
)}
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn(
'text-[12px] truncate',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{m.displayName}
</span>
{mcpInstalling === m.tool && (
<Loader2 size={10} className="animate-spin text-muted-foreground shrink-0" />
)}
{activeTab === 'system' && (
<div>
<h3 className="text-[15px] font-semibold text-foreground mb-4">{t('settings.system')}</h3>
{isElectron && (
<div className="flex items-center justify-between px-3.5 py-2.5 rounded-lg border border-border bg-secondary/20">
<div>
<span className="text-[13px] text-foreground block leading-tight">{t('agents.autoUpdate')}</span>
<span className="text-[11px] text-muted-foreground mt-0.5 block">{t('settings.autoUpdateDesc')}</span>
</div>
<Switch checked={autoUpdateEnabled} onCheckedChange={handleAutoUpdateToggle} />
</div>
<Switch
checked={m.enabled}
disabled={isBusy}
onCheckedChange={() => handleToggleMCP(m.tool)}
className="shrink-0 ml-2"
/>
</div>
))}
</div>
{mcpError && (
<div className="flex items-center gap-1.5 mt-2 px-1">
<AlertCircle size={11} className="text-destructive shrink-0" />
<p className="text-[10px] text-destructive">{mcpError}</p>
)}
{!isElectron && (
<div className="rounded-lg border border-border bg-secondary/20 px-4 py-6 text-center">
<p className="text-[13px] text-muted-foreground">{t('settings.systemDesktopOnly')}</p>
</div>
)}
</div>
)}
<p className="text-[10px] text-muted-foreground/60 mt-2 px-1">
{t('agents.mcpRestart')}
</p>
</div>
{/* Auto-update toggle (Electron only) */}
{isElectron && (
<>
<div className="h-px bg-border my-3" />
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<RefreshCw size={12} className="text-muted-foreground" />
<span className="text-[12px] text-foreground">{t('agents.autoUpdate')}</span>
</div>
<Switch
checked={autoUpdateEnabled}
onCheckedChange={handleAutoUpdateToggle}
/>
</div>
</>
)}
</div>
</div>
</div>

View file

@ -365,6 +365,13 @@ const de: TranslationKeys = {
'agents.installing': 'Installiere...',
'agents.installFailed': 'Installation fehlgeschlagen',
'agents.viewDocs': 'Doku',
'settings.title': 'Einstellungen',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'System',
'settings.autoUpdateDesc': 'Beim Start automatisch nach neuen Versionen suchen',
'settings.systemDesktopOnly': 'Systemeinstellungen sind in der Desktop-App verfügbar.',
'settings.envHint': 'Sie können zusätzliche Umgebungsvariablen in {{path}} festlegen.',
// ── Figma Import ──
'figma.title': 'Aus Figma importieren',

View file

@ -361,6 +361,13 @@ const en = {
'agents.installing': 'Installing...',
'agents.installFailed': 'Installation failed',
'agents.viewDocs': 'Docs',
'settings.title': 'Settings',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'System',
'settings.autoUpdateDesc': 'Automatically check for new versions on startup',
'settings.systemDesktopOnly': 'System settings are available in the desktop app.',
'settings.envHint': 'You can set additional environment variables in {{path}}.',
// ── Figma Import ──
'figma.title': 'Import from Figma',

View file

@ -370,6 +370,13 @@ const es: TranslationKeys = {
'agents.installing': 'Instalando...',
'agents.installFailed': 'Instalación fallida',
'agents.viewDocs': 'Docs',
'settings.title': 'Configuración',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Sistema',
'settings.autoUpdateDesc': 'Buscar automáticamente nuevas versiones al iniciar',
'settings.systemDesktopOnly': 'La configuración del sistema está disponible en la aplicación de escritorio.',
'settings.envHint': 'Puedes establecer variables de entorno adicionales en {{path}}.',
// ── Figma Import ──
'figma.title': 'Importar desde Figma',

View file

@ -368,6 +368,13 @@ const fr: TranslationKeys = {
'agents.installing': 'Installation...',
'agents.installFailed': "Échec de l'installation",
'agents.viewDocs': 'Docs',
'settings.title': 'Paramètres',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Système',
'settings.autoUpdateDesc': 'Vérifier automatiquement les nouvelles versions au démarrage',
'settings.systemDesktopOnly': 'Les paramètres système sont disponibles dans l\'application de bureau.',
'settings.envHint': 'Vous pouvez définir des variables d\'environnement supplémentaires dans {{path}}.',
// ── Figma Import ──
'figma.title': 'Importer depuis Figma',

View file

@ -363,6 +363,13 @@ const hi: TranslationKeys = {
'agents.installing': 'स्थापित हो रहा है...',
'agents.installFailed': 'स्थापना विफल',
'agents.viewDocs': 'दस्तावेज़',
'settings.title': 'सेटिंग्स',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'सिस्टम',
'settings.autoUpdateDesc': 'स्टार्टअप पर स्वचालित रूप से नए संस्करणों की जाँच करें',
'settings.systemDesktopOnly': 'सिस्टम सेटिंग्स डेस्कटॉप ऐप में उपलब्ध हैं।',
'settings.envHint': 'आप {{path}} में अतिरिक्त पर्यावरण चर सेट कर सकते हैं।',
// ── Figma Import ──
'figma.title': 'Figma से आयात करें',

View file

@ -363,6 +363,13 @@ const id: TranslationKeys = {
'agents.installing': 'Memasang...',
'agents.installFailed': 'Pemasangan gagal',
'agents.viewDocs': 'Dokumen',
'settings.title': 'Pengaturan',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Sistem',
'settings.autoUpdateDesc': 'Periksa versi baru secara otomatis saat memulai',
'settings.systemDesktopOnly': 'Pengaturan sistem tersedia di aplikasi desktop.',
'settings.envHint': 'Anda dapat mengatur variabel lingkungan tambahan di {{path}}.',
// ── Figma Import ──
'figma.title': 'Impor dari Figma',

View file

@ -366,6 +366,13 @@ const ja: TranslationKeys = {
'agents.installing': 'インストール中...',
'agents.installFailed': 'インストール失敗',
'agents.viewDocs': 'ドキュメント',
'settings.title': '設定',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'システム',
'settings.autoUpdateDesc': '起動時に新しいバージョンを自動的に確認する',
'settings.systemDesktopOnly': 'システム設定はデスクトップアプリで利用できます。',
'settings.envHint': '{{path}} で追加の環境変数を設定できます。',
// ── Figma Import ──
'figma.title': 'Figma からインポート',

View file

@ -363,6 +363,13 @@ const ko: TranslationKeys = {
'agents.installing': '설치 중...',
'agents.installFailed': '설치 실패',
'agents.viewDocs': '문서',
'settings.title': '설정',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': '시스템',
'settings.autoUpdateDesc': '시작 시 자동으로 새 버전 확인',
'settings.systemDesktopOnly': '시스템 설정은 데스크톱 앱에서 사용할 수 있습니다.',
'settings.envHint': '{{path}}에서 추가 환경 변수를 설정할 수 있습니다.',
// ── Figma Import ──
'figma.title': 'Figma에서 가져오기',

View file

@ -365,6 +365,13 @@ const pt: TranslationKeys = {
'agents.installing': 'Instalando...',
'agents.installFailed': 'Falha na instalação',
'agents.viewDocs': 'Docs',
'settings.title': 'Configurações',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Sistema',
'settings.autoUpdateDesc': 'Verificar automaticamente novas versões ao iniciar',
'settings.systemDesktopOnly': 'As configurações do sistema estão disponíveis no aplicativo de desktop.',
'settings.envHint': 'Você pode definir variáveis de ambiente adicionais em {{path}}.',
// ── Figma Import ──
'figma.title': 'Importar do Figma',

View file

@ -365,6 +365,13 @@ const ru: TranslationKeys = {
'agents.installing': 'Установка...',
'agents.installFailed': 'Ошибка установки',
'agents.viewDocs': 'Документация',
'settings.title': 'Настройки',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Система',
'settings.autoUpdateDesc': 'Автоматически проверять наличие новых версий при запуске',
'settings.systemDesktopOnly': 'Системные настройки доступны в настольном приложении.',
'settings.envHint': 'Вы можете задать дополнительные переменные окружения в {{path}}.',
// ── Figma Import ──
'figma.title': 'Импорт из Figma',

View file

@ -363,6 +363,13 @@ const th: TranslationKeys = {
'agents.installing': 'กำลังติดตั้ง...',
'agents.installFailed': 'การติดตั้งล้มเหลว',
'agents.viewDocs': 'เอกสาร',
'settings.title': 'การตั้งค่า',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'ระบบ',
'settings.autoUpdateDesc': 'ตรวจสอบเวอร์ชันใหม่โดยอัตโนมัติเมื่อเริ่มต้น',
'settings.systemDesktopOnly': 'การตั้งค่าระบบใช้ได้ในแอปเดสก์ท็อป',
'settings.envHint': 'คุณสามารถตั้งค่าตัวแปรสภาพแวดล้อมเพิ่มเติมได้ใน {{path}}',
// ── Figma Import ──
'figma.title': 'นำเข้าจาก Figma',

View file

@ -363,6 +363,13 @@ const tr: TranslationKeys = {
'agents.installing': 'Yükleniyor...',
'agents.installFailed': 'Yükleme başarısız',
'agents.viewDocs': 'Belgeler',
'settings.title': 'Ayarlar',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Sistem',
'settings.autoUpdateDesc': 'Başlangıçta yeni sürümleri otomatik olarak kontrol et',
'settings.systemDesktopOnly': 'Sistem ayarları masaüstü uygulamasında kullanılabilir.',
'settings.envHint': '{{path}} dosyasında ek ortam değişkenleri ayarlayabilirsiniz.',
// ── Figma Import ──
'figma.title': 'Figma\'dan İçe Aktar',

View file

@ -363,6 +363,13 @@ const vi: TranslationKeys = {
'agents.installing': 'Đang cài đặt...',
'agents.installFailed': 'Cài đặt thất bại',
'agents.viewDocs': 'Tài liệu',
'settings.title': 'Cài đặt',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': 'Hệ thống',
'settings.autoUpdateDesc': 'Tự động kiểm tra phiên bản mới khi khởi động',
'settings.systemDesktopOnly': 'Cài đặt hệ thống khả dụng trong ứng dụng máy tính.',
'settings.envHint': 'Bạn có thể đặt thêm biến môi trường trong {{path}}.',
// ── Figma Import ──
'figma.title': 'Nhập từ Figma',

View file

@ -355,6 +355,13 @@ const zhTW: TranslationKeys = {
'agents.installing': '安裝中...',
'agents.installFailed': '安裝失敗',
'agents.viewDocs': '文件',
'settings.title': '設定',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': '系統',
'settings.autoUpdateDesc': '啟動時自動檢查新版本',
'settings.systemDesktopOnly': '系統設定僅在桌面應用程式中可用。',
'settings.envHint': '你可以在 {{path}} 中設定額外的環境變數。',
// ── Figma Import ──
'figma.title': '從 Figma 匯入',

View file

@ -355,6 +355,13 @@ const zh: TranslationKeys = {
'agents.installing': '安装中...',
'agents.installFailed': '安装失败',
'agents.viewDocs': '文档',
'settings.title': '设置',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
'settings.system': '系统',
'settings.autoUpdateDesc': '启动时自动检查新版本',
'settings.systemDesktopOnly': '系统设置仅在桌面应用中可用。',
'settings.envHint': '你可以在 {{path}} 中设置额外的环境变量。',
// ── Figma Import ──
'figma.title': '从 Figma 导入',

View file

@ -28,6 +28,8 @@ interface AgentSettingsState extends PersistedState {
provider: AIProviderType,
method: AIProviderConfig['connectionMethod'],
models: GroupedModel[],
connectionInfo?: string,
hintPath?: string,
) => void
disconnectProvider: (provider: AIProviderType) => void
toggleMCPIntegration: (tool: string) => void
@ -88,7 +90,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
mcpServerRunning: false,
mcpServerLocalIp: null,
connectProvider: (provider, method, models) =>
connectProvider: (provider, method, models, connectionInfo, hintPath) =>
set((s) => ({
providers: {
...s.providers,
@ -97,6 +99,8 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
isConnected: true,
connectionMethod: method,
models,
connectionInfo,
hintPath,
},
},
})),

View file

@ -7,6 +7,10 @@ export interface AIProviderConfig {
connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | null
/** Models fetched when the user connects this provider */
models: GroupedModel[]
/** Human-readable connection status, e.g. "Connected via API key" */
connectionInfo?: string
/** Config file path for the hint (client renders localized text) */
hintPath?: string
}
export type MCPCliTool =