diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 2d544d4f0..795fa5eba 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -41,10 +41,11 @@ jobs:
with:
go-version-file: go.mod
+ - name: Install govulncheck
+ run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
+
- name: Run Govulncheck
- uses: golang/govulncheck-action@v1
- with:
- go-package: ./...
+ run: govulncheck -C . -format text ./...
test:
name: Tests
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ce341770..aab9cf874 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -110,6 +110,17 @@ jobs:
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
+ - name: Build and upload Android arm64
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ sudo apt-get install -y zip
+ make build-android-bundle
+ gh release upload "${{ inputs.tag }}" \
+ build/picoclaw-android-universal.zip \
+ --clobber
+
- name: Apply release flags
shell: bash
env:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ceff723d2..cbb6a6347 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -108,7 +108,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
- Reference the related issue when relevant: `Fix session leak (#123)`.
- Keep commits focused. One logical change per commit is preferred.
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
-- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
+- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
### Keeping Up to Date
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
index 196aecc65..ca6c66b3d 100644
--- a/CONTRIBUTING.zh.md
+++ b/CONTRIBUTING.zh.md
@@ -108,7 +108,7 @@ git checkout -b 你的功能分支名
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
- 保持 commit 专注,每个 commit 只做一件事。
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
-- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
+- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写
### 保持与上游同步
diff --git a/Makefile b/Makefile
index f7ebc7411..beddd1138 100644
--- a/Makefile
+++ b/Makefile
@@ -205,6 +205,37 @@ build-linux-mipsle: generate
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
+## build-android-arm64: Build core for Android ARM64
+build-android-arm64: generate
+ @echo "Building for android/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
+ @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
+
+## build-launcher-android-arm64: Build launcher for Android ARM64
+build-launcher-android-arm64:
+ @echo "Building picoclaw-launcher for android/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ @$(MAKE) -C web build-android-arm64 \
+ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64"
+ @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
+
+## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
+build-android-bundle: generate
+ @echo "Building core for all Android architectures..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
+ @echo "Building launcher for Android arm64..."
+ @$(MAKE) build-launcher-android-arm64
+ @echo "Staging JNI libs..."
+ @rm -rf $(BUILD_DIR)/android-staging
+ @mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
+ @cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
+ @cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
+ @cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
+ @rm -rf $(BUILD_DIR)/android-staging
+ @echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
+
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
@@ -226,6 +257,7 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
+ @$(MAKE) build-android-bundle
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills
diff --git a/README.fr.md b/README.fr.md
index a26c89f14..3b2552f6d 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
@@ -622,4 +622,3 @@ WeChat :
-
diff --git a/README.id.md b/README.id.md
index d3c556dde..5aa7b58f5 100644
--- a/README.id.md
+++ b/README.id.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Malay](README.my.md) | [English](README.md) | **Bahasa Indonesia**
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.my.md) | [English](README.md)
@@ -615,4 +615,3 @@ Discord:
WeChat:
-
diff --git a/README.it.md b/README.it.md
index 6fe6c5e17..57dd014b3 100644
--- a/README.it.md
+++ b/README.it.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
diff --git a/README.ja.md b/README.ja.md
index 793c41fcb..64bff9ee9 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
+[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
diff --git a/README.ko.md b/README.ko.md
new file mode 100644
index 000000000..341c09812
--- /dev/null
+++ b/README.ko.md
@@ -0,0 +1,626 @@
+
+
+---
+
+> **PicoClaw**는 [Sipeed](https://sipeed.com)가 시작한 독립적인 오픈소스 프로젝트입니다. 처음부터 끝까지 **Go**로 새로 작성되었으며, OpenClaw, NanoBot, 혹은 다른 어떤 프로젝트의 포크도 아닙니다.
+
+**PicoClaw**는 [NanoBot](https://github.com/HKUDS/nanobot)에서 영감을 받은 초경량 개인용 AI 어시스턴트입니다. **Go**로 처음부터 다시 구현되었고, "셀프 부트스트래핑" 방식으로 만들어졌습니다. 즉, AI 에이전트 자체가 아키텍처 전환과 코드 최적화를 주도했습니다.
+
+**$10 하드웨어에서 10MB 미만 RAM으로 동작**합니다. OpenClaw보다 메모리를 99% 적게 쓰고, Mac mini보다 98% 저렴합니다!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!CAUTION]
+> **보안 안내**
+>
+> * **암호화폐 없음:** PicoClaw는 공식 토큰이나 암호화폐를 **발행한 적이 없습니다**. `pump.fun` 또는 기타 거래 플랫폼에서의 모든 주장은 **사기**입니다.
+> * **공식 도메인:** **유일한** 공식 웹사이트는 **[picoclaw.io](https://picoclaw.io)** 이며, 회사 웹사이트는 **[sipeed.com](https://sipeed.com)** 입니다.
+> * **주의:** 많은 `.ai/.org/.com/.net/...` 도메인이 제3자에 의해 등록되어 있습니다. 신뢰하지 마세요.
+> * **참고:** PicoClaw는 빠르게 초기 개발이 진행 중입니다. 아직 해결되지 않은 보안 문제가 있을 수 있습니다. v1.0 이전에는 프로덕션 배포를 권장하지 않습니다.
+> * **참고:** PicoClaw는 최근 많은 PR을 병합했습니다. 최근 빌드는 10~20MB RAM을 사용할 수 있습니다. 기능이 안정화된 뒤 리소스 최적화를 진행할 예정입니다.
+
+## 📢 뉴스
+
+2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요.
+
+2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다!
+
+2026-03-17 🚀 **v0.2.3 출시!** 시스템 트레이 UI(Windows 및 Linux), 서브에이전트 상태 조회(`spawn_status`), 실험적 게이트웨이 핫 리로드, Cron 보안 게이트, 그리고 2건의 보안 수정이 추가되었습니다. PicoClaw는 **25K 스타**를 달성했습니다!
+
+2026-03-09 🎉 **v0.2.1 — 역대 최대 업데이트!** MCP 프로토콜 지원, 4개의 새 채널(Matrix/IRC/WeCom/Discord Proxy), 3개의 새 프로바이더(Kimi/Minimax/Avian), 비전 파이프라인, JSONL 메모리 저장소, 모델 라우팅이 추가되었습니다.
+
+2026-02-28 📦 **v0.2.0** 이 Docker Compose 및 WebUI 런처 지원과 함께 출시되었습니다.
+
+
+이전 뉴스...
+
+2026-02-26 🎉 PicoClaw가 단 17일 만에 **20K 스타**를 달성했습니다! 채널 자동 오케스트레이션과 기능 인터페이스가 적용되었습니다.
+
+2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](ROADMAP.md)이 공식적으로 공개되었습니다.
+
+2026-02-13 🎉 PicoClaw가 4일 만에 **5000 스타**를 돌파했습니다! 프로젝트 로드맵과 개발자 그룹이 준비 중입니다.
+
+2026-02-09 🎉 **PicoClaw 출시!** $10 하드웨어와 10MB 미만 RAM에서 동작하는 AI 에이전트를 단 1일 만에 만들었습니다. Let's Go, PicoClaw!
+
+
+
+## ✨ 기능
+
+🪶 **초경량**: 코어 메모리 사용량이 10MB 미만으로 OpenClaw보다 99% 작습니다.*
+
+💰 **최소 비용**: $10짜리 하드웨어에서도 충분히 구동되어 Mac mini보다 98% 저렴합니다.
+
+⚡️ **초고속 부팅**: 시작 속도가 400배 빠릅니다. 0.6GHz 싱글코어 프로세서에서도 1초 미만에 부팅됩니다.
+
+🌍 **진정한 이식성**: RISC-V, ARM, MIPS, x86 아키텍처 전반에 단일 바이너리로 동작합니다. 하나의 바이너리로 어디서나 실행됩니다!
+
+🤖 **AI 부트스트래핑**: 순수 Go 네이티브 구현입니다. 코어 코드의 95%는 에이전트가 생성했고, 사람이 검토하며 다듬었습니다.
+
+🔌 **MCP 지원**: 네이티브 [Model Context Protocol](https://modelcontextprotocol.io/) 통합을 제공하여 어떤 MCP 서버든 연결해 에이전트 기능을 확장할 수 있습니다.
+
+👁️ **비전 파이프라인**: 이미지와 파일을 에이전트에 직접 보낼 수 있으며, 멀티모달 LLM용 base64 인코딩이 자동으로 처리됩니다.
+
+🧠 **스마트 라우팅**: 규칙 기반 모델 라우팅으로 간단한 질의는 경량 모델에 보내 API 비용을 절약합니다.
+
+_*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있습니다. 리소스 최적화는 계획되어 있습니다. 부팅 속도 비교는 0.8GHz 싱글코어 벤치마크를 기준으로 합니다(아래 표 참고)._
+
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **언어** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **부팅 시간**(0.8GHz 코어) | >500초 | >30초 | **<1초** |
+| **비용** | Mac Mini $599 | 대부분의 Linux 보드 ~$50 | **모든 Linux 보드****최저 $10부터** |
+
+
+
+
+
+> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!
+
+
+
+
+
+## 🦾 데모
+
+### 🛠️ 표준 어시스턴트 워크플로
+
+
+
+풀스택 엔지니어 모드
+로깅 및 계획
+웹 검색 및 학습
+
+
+
+
+
+
+
+개발 · 배포 · 확장
+스케줄링 · 자동화 · 기억
+탐색 · 인사이트 · 트렌드
+
+
+
+### 🐜 혁신적인 초저사양 배포
+
+PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
+
+- 최소형 홈 어시스턴트를 위해 $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(이더넷) 또는 W(WiFi6) 에디션
+- 서버 자동 운영을 위해 $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) 또는 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)
+- 스마트 감시를 위해 $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 또는 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)
+
+
+
+🌟 더 많은 배포 사례가 기다리고 있습니다!
+
+## 📦 설치
+
+### picoclaw.io에서 다운로드(권장)
+
+**[picoclaw.io](https://picoclaw.io)** 를 방문하세요. 공식 웹사이트가 플랫폼을 자동 감지하고 원클릭 다운로드를 제공합니다. 아키텍처를 직접 고를 필요가 없습니다.
+
+### 사전 컴파일된 바이너리 다운로드
+
+또는 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 페이지에서 플랫폼에 맞는 바이너리를 다운로드할 수 있습니다.
+
+### 소스에서 빌드(개발용)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# 코어 바이너리 빌드
+make build
+
+# WebUI 런처 빌드 (WebUI 모드에 필요)
+make build-launcher
+
+# 여러 플랫폼용 빌드
+make build-all
+
+# Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64)
+make build-pi-zero
+
+# 빌드 후 설치
+make install
+```
+
+**Raspberry Pi Zero 2 W:** OS에 맞는 바이너리를 사용하세요. 32비트 Raspberry Pi OS는 `make build-linux-arm`, 64비트는 `make build-linux-arm64`입니다. 또는 `make build-pi-zero`로 둘 다 빌드할 수 있습니다.
+
+## 🚀 빠른 시작 가이드
+
+### 🌐 WebUI Launcher (데스크톱 권장)
+
+WebUI Launcher는 설정과 채팅을 위한 브라우저 기반 인터페이스를 제공합니다. 명령줄을 몰라도 가장 쉽게 시작할 수 있는 방법입니다.
+
+**옵션 1: 더블클릭(데스크톱)**
+
+[picoclaw.io](https://picoclaw.io)에서 다운로드한 뒤 `picoclaw-launcher`를 더블클릭하세요(Windows에서는 `picoclaw-launcher.exe`). 브라우저가 자동으로 `http://localhost:18800`을 엽니다.
+
+**옵션 2: 명령줄**
+
+```bash
+picoclaw-launcher
+# 브라우저에서 http://localhost:18800 열기
+```
+
+> [!TIP]
+> **원격 접속 / Docker / VM:** 모든 인터페이스에서 수신하려면 `-public` 플래그를 추가하세요.
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**시작 방법:**
+
+WebUI를 연 뒤 다음 순서로 진행하세요. **1)** 프로바이더 설정(LLM API 키 추가) -> **2)** 채널 설정(예: Telegram) -> **3)** 게이트웨이 시작 -> **4)** 채팅!
+
+자세한 WebUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
+
+
+Docker(대안)
+
+```bash
+# 1. 이 저장소를 클론
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. 첫 실행 - docker/data/config.json을 자동 생성한 뒤 종료
+# (config.json과 workspace/가 모두 없을 때만 실행됨)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# 컨테이너가 "First-run setup complete."를 출력하고 종료됩니다.
+
+# 3. API 키 설정
+vim docker/data/config.json
+
+# 4. 시작
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# http://localhost:18800 열기
+```
+
+> **Docker / VM 사용자:** 게이트웨이는 기본적으로 `127.0.0.1`에서 수신합니다. 호스트에서 접근 가능하게 하려면 `PICOCLAW_GATEWAY_HOST=0.0.0.0`을 설정하거나 `-public` 플래그를 사용하세요.
+
+```bash
+# 로그 확인
+docker compose -f docker/docker-compose.yml logs -f
+
+# 중지
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# 업데이트
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+
+macOS - 첫 실행 보안 경고
+
+macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 거치지 않았기 때문에, 첫 실행 시 `picoclaw-launcher`가 차단될 수 있습니다.
+
+**1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다.
+
+
+
+
+
+> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.*
+
+**2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다.
+
+
+
+
+
+이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다.
+
+
+
+### 💻 TUI Launcher (헤드리스 / SSH 권장)
+
+TUI(Terminal UI) Launcher는 설정과 관리를 위한 모든 기능을 갖춘 터미널 인터페이스를 제공합니다. 서버, Raspberry Pi, 기타 헤드리스 환경에 적합합니다.
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**시작 방법:**
+
+TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 설정 -> **2)** 채널 설정 -> **3)** 게이트웨이 시작 -> **4)** 채팅!
+
+자세한 TUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
+
+### 📱 Android
+
+오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다.
+
+**옵션 1: APK 설치**
+
+미리보기:
+
+
+
+[picoclaw.io](https://picoclaw.io/download/)에서 APK를 다운로드해 바로 설치하세요. Termux가 필요 없습니다!
+
+**옵션 2: Termux**
+
+
+터미널 런처 (리소스 제약 환경용)
+
+1. [Termux](https://github.com/termux/termux-app)를 설치합니다([GitHub Releases](https://github.com/termux/termux-app/releases)에서 다운로드하거나 F-Droid / Google Play에서 검색).
+2. 다음 명령을 실행합니다.
+
+```bash
+# 최신 릴리스 다운로드
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+pkg install proot
+termux-chroot ./picoclaw onboard # chroot가 표준 Linux 파일시스템 레이아웃을 제공합니다
+```
+
+그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요.
+
+
+
+런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다.
+
+**1. 초기화**
+
+```bash
+picoclaw onboard
+```
+
+그러면 `~/.picoclaw/config.json`과 워크스페이스 디렉터리가 생성됩니다.
+
+**2. 설정** (`~/.picoclaw/config.json`)
+
+```jsonc
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ // api_key는 이제 .security.yml에서 로드됩니다.
+ }
+ ]
+}
+```
+
+> 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요.
+>
+> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `docs/security_configuration.md`를 참고하세요.
+
+**3. 채팅**
+
+```bash
+# 단발성 질문
+picoclaw agent -m "2+2는 얼마야?"
+
+# 대화형 모드
+picoclaw agent
+
+# 채팅 앱 연동용 게이트웨이 시작
+picoclaw gateway
+```
+
+
+
+## 🔌 프로바이더(LLM)
+
+PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 지원합니다. 형식은 `protocol/model`입니다.
+
+| 프로바이더 | 프로토콜 | API Key | 비고 |
+|----------|----------|---------|------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 필수 | GPT-5.4, GPT-4o, o3 등 |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 필수 | Claude Opus 4.6, Sonnet 4.6 등 |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 필수 | Gemini 3 Flash, 2.5 Pro 등 |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 필수 | 200개 이상의 모델, 통합 API |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 필수 | GLM-4.7, GLM-5 등 |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 필수 | DeepSeek-V3, DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | 필수 | Doubao, Ark 모델 |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 필수 | Qwen3, Qwen-Max 등 |
+| [Groq](https://console.groq.com/keys) | `groq/` | 필수 | 빠른 추론(Llama, Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 필수 | Kimi 모델 |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 필수 | MiniMax 모델 |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 필수 | Mistral Large, Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 필수 | NVIDIA 호스팅 모델 |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 필수 | 빠른 추론 |
+| [Novita AI](https://novita.ai/) | `novita/` | 필수 | 다양한 오픈 모델 |
+| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | 필수 | MiMo 모델 |
+| [Ollama](https://ollama.com/) | `ollama/` | 불필요 | 로컬 모델, 셀프 호스팅 |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | 불필요 | 로컬 배포, OpenAI 호환 |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 환경에 따라 다름 | 100개 이상의 프로바이더를 위한 프록시 |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 필수 | 엔터프라이즈 Azure 배포 |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 디바이스 코드 로그인 |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS 자격 증명 | AWS에서 Claude, Llama, Mistral 사용 |
+
+> \* AWS Bedrock은 빌드 태그 `go build -tags bedrock`이 필요합니다. 모든 AWS 파티션(aws, aws-cn, aws-us-gov)에서 엔드포인트를 자동 해석하려면 `api_base`를 리전명(예: `us-east-1`)으로 설정하세요. 전체 엔드포인트 URL을 직접 사용할 경우에는 환경 변수 또는 AWS config/profile을 통해 `AWS_REGION`도 함께 설정해야 합니다.
+
+
+로컬 배포(Ollama, vLLM 등)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+프로바이더 전체 설정은 [프로바이더와 모델](docs/providers.md)을 참고하세요.
+
+
+
+## 💬 채널(채팅 앱)
+
+18개 이상의 메시징 플랫폼을 통해 PicoClaw와 대화할 수 있습니다.
+
+| 채널 | 설정 | 프로토콜 | 문서 |
+|---------|------|----------|------|
+| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](docs/channels/telegram/README.md) |
+| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](docs/channels/discord/README.md) |
+| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](docs/chat-apps.md#whatsapp) |
+| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](docs/chat-apps.md#weixin) |
+| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](docs/channels/qq/README.md) |
+| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](docs/channels/slack/README.md) |
+| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](docs/channels/matrix/README.md) |
+| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](docs/channels/dingtalk/README.md) |
+| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](docs/channels/feishu/README.md) |
+| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](docs/channels/line/README.md) |
+| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](docs/channels/wecom/README.md) |
+| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](docs/channels/vk/README.md) |
+| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](docs/chat-apps.md#irc) |
+| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](docs/channels/onebot/README.md) |
+| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](docs/channels/maixcam/README.md) |
+| **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 |
+| **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 |
+
+> webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다.
+
+> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](docs/configuration.md#gateway-log-level)를 참고하세요.
+
+자세한 채널 설정 방법은 [채팅 앱 설정 가이드](docs/chat-apps.md)를 참고하세요.
+
+## 🔧 도구
+
+### 🔍 웹 검색
+
+PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있습니다. `tools.web`에서 설정하세요.
+
+| 검색 엔진 | API Key | 무료 제공량 | 링크 |
+|-----------|---------|-------------|------|
+| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 |
+| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 |
+| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
+| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |
+| [SearXNG](https://github.com/searxng/searxng) | 불필요 | 셀프 호스팅 | 무료 메타 검색 엔진 |
+| [GLM Search](https://open.bigmodel.cn/) | 필수 | 상이함 | Zhipu 웹 검색 |
+
+### ⚙️ 기타 도구
+
+PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](docs/tools_configuration.md)을 참고하세요.
+
+## 🎯 스킬
+
+스킬은 에이전트 기능을 확장하는 모듈형 구성 요소입니다. 워크스페이스 안의 `SKILL.md` 파일에서 로드됩니다.
+
+**ClawHub에서 스킬 설치:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**ClawHub 토큰 설정**(선택 사항, 더 높은 호출 한도용):
+
+`config.json`에 다음을 추가하세요.
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+자세한 내용은 [도구 설정 - 스킬](docs/tools_configuration.md#skills-tool)를 참고하세요.
+
+## 🔗 MCP (Model Context Protocol)
+
+PicoClaw는 [MCP](https://modelcontextprotocol.io/)를 기본 지원합니다. 어떤 MCP 서버든 연결하여 외부 도구와 데이터 소스로 에이전트 기능을 확장할 수 있습니다.
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](docs/tools_configuration.md#mcp-tool)를 참고하세요.
+
+## 에이전트 소셜 네트워크 참여하기
+
+CLI 또는 통합된 채팅 앱에서 메시지를 한 번만 보내면 PicoClaw를 에이전트 소셜 네트워크에 연결할 수 있습니다.
+
+**`https://clawdchat.ai/skill.md`를 읽고 안내에 따라 [ClawdChat.ai](https://clawdchat.ai)에 참여하세요**
+
+## 🖥️ CLI 레퍼런스
+
+| 명령어 | 설명 |
+| ------------------------- | ------------------------------ |
+| `picoclaw onboard` | 설정 및 워크스페이스 초기화 |
+| `picoclaw auth weixin` | QR로 WeChat 계정 연결 |
+| `picoclaw agent -m "..."` | 에이전트와 채팅 |
+| `picoclaw agent` | 대화형 채팅 모드 |
+| `picoclaw gateway` | 게이트웨이 시작 |
+| `picoclaw status` | 상태 표시 |
+| `picoclaw version` | 버전 정보 표시 |
+| `picoclaw model` | 기본 모델 조회 또는 변경 |
+| `picoclaw cron list` | 모든 예약 작업 목록 표시 |
+| `picoclaw cron add ...` | 예약 작업 추가 |
+| `picoclaw cron disable` | 예약 작업 비활성화 |
+| `picoclaw cron remove` | 예약 작업 삭제 |
+| `picoclaw skills list` | 설치된 스킬 목록 표시 |
+| `picoclaw skills install` | 스킬 설치 |
+| `picoclaw migrate` | 이전 버전 데이터 마이그레이션 |
+| `picoclaw auth login` | 프로바이더 인증 |
+
+### ⏰ 예약 작업 / 리마인더
+
+PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지원합니다.
+
+* **1회성 리마인더**: "10분 후에 알려줘" -> 10분 후 한 번 실행
+* **반복 작업**: "2시간마다 알려줘" -> 2시간마다 실행
+* **Cron 표현식**: "매일 오전 9시에 알려줘" -> cron 표현식 사용
+
+현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/cron.md](docs/cron.md)를 참고하세요.
+
+## 📚 문서
+
+이 README보다 더 자세한 가이드는 다음 문서를 참고하세요.
+
+| 주제 | 설명 |
+|------|------|
+| [도커 & 빠른 시작](docs/docker.md) | Docker Compose 설정, 런처/에이전트 모드 |
+| [채팅 앱](docs/chat-apps.md) | 17개 이상의 채널 설정 가이드 |
+| [설정](docs/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 |
+| [예약 작업과 Cron](docs/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 |
+| [프로바이더와 모델](docs/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 |
+| [Spawn & 비동기 작업](docs/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 |
+| [Hooks](docs/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 |
+| [Steering](docs/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 |
+| [SubTurn](docs/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 |
+| [문제 해결](docs/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 |
+| [도구 설정](docs/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 |
+| [하드웨어 호환성](docs/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 |
+
+## 🤝 기여 & 로드맵
+
+PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽기 쉽게 유지하고 있습니다.
+
+가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
+
+개발자 그룹도 준비 중입니다. 첫 PR이 머지되면 함께할 수 있습니다!
+
+커뮤니티 그룹:
+
+Discord:
+
+WeChat:
+
diff --git a/README.md b/README.md
index a48a53d47..eb0d389d2 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English**
diff --git a/README.my.md b/README.my.md
index f00fb438c..f8e602f83 100644
--- a/README.my.md
+++ b/README.my.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
diff --git a/README.pt-br.md b/README.pt-br.md
index db11d4d82..65d23d1d1 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
diff --git a/README.vi.md b/README.vi.md
index 78b8a9a59..1d70d0615 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
diff --git a/README.zh.md b/README.zh.md
index 2ba0913fc..e61ff7e28 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -18,7 +18,7 @@
-**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
+**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
@@ -620,4 +620,3 @@ WeChat:
-
diff --git a/cmd/picoclaw/internal/cliui/cliui.go b/cmd/picoclaw/internal/cliui/cliui.go
new file mode 100644
index 000000000..b1ba636c9
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/cliui.go
@@ -0,0 +1,147 @@
+// Package cliui renders human-oriented CLI output: bordered panels and columns
+// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
+// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
+// stdout falls back to plain line-oriented output.
+package cliui
+
+import (
+ "os"
+ "sync"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/termenv"
+ "golang.org/x/term"
+)
+
+// Minimum terminal width (columns) for bordered / structured layout.
+// Below this, plain line-oriented output is used so boxes do not wrap badly.
+const minWidthFancy = 88
+
+// Minimum width to lay out some views in two columns (e.g. status providers).
+const minWidthColumns = 104
+
+var initMu sync.Mutex
+
+// Init configures lipgloss for this process. When disableAnsiColors is true
+// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
+// borders still render when UseFancyLayout() is true.
+func Init(disableAnsiColors bool) {
+ initMu.Lock()
+ defer initMu.Unlock()
+ if disableAnsiColors {
+ lipgloss.SetColorProfile(termenv.Ascii)
+ return
+ }
+ lipgloss.SetColorProfile(termenv.EnvColorProfile())
+}
+
+// StdoutWidth returns the terminal width or a sane default if unknown.
+func StdoutWidth() int {
+ w, _, err := term.GetSize(int(os.Stdout.Fd()))
+ if err != nil || w < 20 {
+ return 80
+ }
+ return w
+}
+
+// UseFancyLayout is true when styled boxes/columns should be used.
+func UseFancyLayout() bool {
+ if !term.IsTerminal(int(os.Stdout.Fd())) {
+ return false
+ }
+ return StdoutWidth() >= minWidthFancy
+}
+
+// UseColumnLayout is true when a second content column is viable.
+func UseColumnLayout() bool {
+ return UseFancyLayout() && StdoutWidth() >= minWidthColumns
+}
+
+// InnerWidth is the target content width inside borders/margins.
+func InnerWidth() int {
+ w := StdoutWidth()
+ // Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
+ const borderBudget = 8
+ if w > borderBudget+48 {
+ return w - borderBudget
+ }
+ return 48
+}
+
+// StderrWidth returns stderr terminal width or a sane default.
+func StderrWidth() int {
+ w, _, err := term.GetSize(int(os.Stderr.Fd()))
+ if err != nil || w < 20 {
+ return 80
+ }
+ return w
+}
+
+// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
+func UseFancyStderr() bool {
+ if !term.IsTerminal(int(os.Stderr.Fd())) {
+ return false
+ }
+ return StderrWidth() >= minWidthFancy
+}
+
+// InnerStderrWidth mirrors InnerWidth but for stderr.
+func InnerStderrWidth() int {
+ w := StderrWidth()
+ const borderBudget = 8
+ if w > borderBudget+48 {
+ return w - borderBudget
+ }
+ return 48
+}
+
+var (
+ accentBlue = lipgloss.Color("#3E5DB9")
+ accentRed = lipgloss.Color("#D54646")
+ colorMuted = lipgloss.Color("#6B6B6B")
+ colorOK = lipgloss.Color("#2E7D32")
+)
+
+func borderStyle() lipgloss.Style {
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(accentBlue).
+ Padding(0, 1)
+}
+
+func titleBarStyle() lipgloss.Style {
+ return lipgloss.NewStyle().
+ Foreground(accentRed).
+ Bold(true)
+}
+
+func mutedStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(colorMuted)
+}
+
+func bodyStyle() lipgloss.Style {
+ return lipgloss.NewStyle()
+}
+
+func kvKeyStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
+}
+
+func kvValStyle() lipgloss.Style {
+ return lipgloss.NewStyle()
+}
+
+// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
+func helpIntroStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
+}
+
+// helpIdentStyle is the left column for commands and flags (blue identifiers).
+func helpIdentStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
+}
+
+// helpPlaceholderStyle highlights in usage lines (red accent).
+func helpPlaceholderStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
+}
diff --git a/cmd/picoclaw/internal/cliui/cliui_test.go b/cmd/picoclaw/internal/cliui/cliui_test.go
new file mode 100644
index 000000000..c07e220ee
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/cliui_test.go
@@ -0,0 +1,180 @@
+package cliui
+
+import (
+ "testing"
+
+ flag "github.com/spf13/pflag"
+)
+
+func init() {
+ // Disable ANSI colors in tests so output is predictable plain text.
+ Init(true)
+}
+
+// ---------------------------------------------------------------------------
+// showErrHint
+// ---------------------------------------------------------------------------
+
+func TestShowErrHint(t *testing.T) {
+ cases := []struct {
+ msg string
+ want bool
+ }{
+ // Cobra flag errors — should show hint
+ {"unknown flag: --foo", true},
+ {"unknown shorthand flag: 'f' in -f", true},
+ {"flag needs an argument: --output", true},
+ {"required flag(s) \"model\" not set", true},
+ // Generic invalid-argument errors — should show hint
+ {"invalid argument \"abc\" for --count", true},
+ // required flag errors — should show hint
+ {"required flag(s) \"model\" not set", true},
+ // usage: in message — should show hint
+ {"bad input\nusage: picoclaw ...", true},
+ // Should NOT false-positive on broad words
+ {"connection flagged by remote", false},
+ {"feature flag not set", false},
+ {"invalid API key provided", false},
+ {"authentication required", false},
+ // Unrelated messages — no hint
+ {"something went wrong", false},
+ {"network timeout", false},
+ }
+
+ for _, tc := range cases {
+ got := showErrHint(tc.msg)
+ if got != tc.want {
+ t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// styleUsageTokens
+// ---------------------------------------------------------------------------
+
+func TestStyleUsageTokensContainsTokens(t *testing.T) {
+ cases := []struct {
+ input string
+ contains []string // substrings that must appear in plain output
+ }{
+ {
+ "picoclaw agent ",
+ []string{"picoclaw agent", ""},
+ },
+ {
+ "picoclaw [command] [flags]",
+ []string{"picoclaw", "[command]", "[flags]"},
+ },
+ {
+ "picoclaw",
+ []string{"picoclaw"},
+ },
+ {
+ "cmd [--flag]",
+ []string{"cmd", "", "[--flag]"},
+ },
+ }
+
+ for _, tc := range cases {
+ out := styleUsageTokens(tc.input)
+ for _, sub := range tc.contains {
+ if !containsStripped(out, sub) {
+ t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
+ }
+ }
+ }
+}
+
+// containsStripped checks whether plain contains sub after stripping ANSI escapes.
+// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
+// so this is just a plain substring check.
+func containsStripped(plain, sub string) bool {
+ return len(plain) >= len(sub) && findSubstring(plain, sub)
+}
+
+func findSubstring(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+// ---------------------------------------------------------------------------
+// collectFlagRows
+// ---------------------------------------------------------------------------
+
+func TestCollectFlagRows_Empty(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ rows := collectFlagRows(fs)
+ if len(rows) != 0 {
+ t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
+ }
+}
+
+func TestCollectFlagRows_BasicFlags(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.String("output", "", "output file path")
+ fs.Bool("verbose", false, "enable verbose mode")
+ fs.Int("count", 1, "number of items")
+
+ rows := collectFlagRows(fs)
+
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ // Rows must be sorted alphabetically by flag name.
+ names := make([]string, 0, len(rows))
+ for _, r := range rows {
+ names = append(names, r[0])
+ }
+ if names[0] > names[1] || names[1] > names[2] {
+ t.Errorf("rows not sorted: %v", names)
+ }
+}
+
+func TestCollectFlagRows_Shorthand(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.StringP("model", "m", "", "model name")
+
+ rows := collectFlagRows(fs)
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+ left := rows[0][0]
+ if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
+ t.Errorf("expected shorthand and long form in %q", left)
+ }
+}
+
+func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.String("visible", "", "this shows up")
+ hidden := fs.String("hidden", "", "this should not show up")
+ _ = hidden
+ _ = fs.MarkHidden("hidden")
+
+ rows := collectFlagRows(fs)
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
+ }
+ if !findSubstring(rows[0][0], "visible") {
+ t.Errorf("expected visible flag in rows, got %q", rows[0][0])
+ }
+}
+
+func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.String("format", "json", "output format: json or text")
+
+ rows := collectFlagRows(fs)
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+ if rows[0][1] != "output format: json or text" {
+ t.Errorf("expected usage in right column, got %q", rows[0][1])
+ }
+}
diff --git a/cmd/picoclaw/internal/cliui/help_cmd.go b/cmd/picoclaw/internal/cliui/help_cmd.go
new file mode 100644
index 000000000..72956afaa
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/help_cmd.go
@@ -0,0 +1,298 @@
+package cliui
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/spf13/cobra"
+ flag "github.com/spf13/pflag"
+)
+
+// RenderCommandHelp builds Ruff-style sectioned, two-column help when
+// UseFancyLayout(); otherwise plain Cobra-style text.
+func RenderCommandHelp(c *cobra.Command) string {
+ if !UseFancyLayout() {
+ return plainCommandHelp(c)
+ }
+ syncFlags(c)
+
+ var b strings.Builder
+ head, sub := helpIntro(c)
+ if head != "" {
+ b.WriteString(helpIntroStyle().Render(head))
+ b.WriteString("\n")
+ }
+ if sub != "" {
+ b.WriteString(mutedStyle().Render(sub))
+ b.WriteString("\n")
+ }
+ if head != "" || sub != "" {
+ b.WriteString("\n")
+ }
+
+ inner := InnerWidth()
+ contentW := inner - 6
+ if contentW < 36 {
+ contentW = 36
+ }
+
+ // Usage
+ usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
+ b.WriteString(sectionPanel("Usage", usageBody, inner))
+ b.WriteString("\n")
+
+ // Examples
+ if ex := strings.TrimSpace(c.Example); ex != "" {
+ exBody := bodyStyle().Width(contentW).Render(ex)
+ b.WriteString(sectionPanel("Examples", exBody, inner))
+ b.WriteString("\n")
+ }
+
+ // Subcommands
+ subs := visibleSubcommands(c)
+ if len(subs) > 0 {
+ rows := make([][2]string, 0, len(subs))
+ for _, sub := range subs {
+ left := sub.Name()
+ if a := sub.Aliases; len(a) > 0 {
+ left += " (" + strings.Join(a, ", ") + ")"
+ }
+ rows = append(rows, [2]string{left, sub.Short})
+ }
+ b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
+ b.WriteString("\n")
+ }
+
+ // Local options
+ local := c.LocalFlags()
+ opts := collectFlagRows(local)
+ if len(opts) > 0 {
+ title := "Options"
+ if !c.HasParent() {
+ title = "Flags"
+ }
+ b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
+ b.WriteString("\n")
+ }
+
+ // Global (inherited) options
+ if c.HasAvailableInheritedFlags() {
+ inh := collectFlagRows(c.InheritedFlags())
+ if len(inh) > 0 {
+ b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
+ b.WriteString("\n")
+ }
+ }
+
+ return b.String()
+}
+
+// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
+// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
+func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
+ if c == nil || outerW < 40 {
+ return ""
+ }
+ syncFlags(c)
+ contentW := outerW - 6
+ if contentW < 36 {
+ contentW = 36
+ }
+ var b strings.Builder
+ usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
+ b.WriteString(sectionPanel("Usage", usageBody, outerW))
+ b.WriteString("\n")
+ if len(c.Aliases) > 0 {
+ al := "Aliases: " + strings.Join(c.Aliases, ", ")
+ alBody := mutedStyle().MaxWidth(contentW).Render(al)
+ b.WriteString(sectionPanel("Aliases", alBody, outerW))
+ b.WriteString("\n")
+ }
+ opts := collectFlagRows(c.LocalFlags())
+ if len(opts) > 0 {
+ title := "Options"
+ if !c.HasParent() {
+ title = "Flags"
+ }
+ b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
+ b.WriteString("\n")
+ }
+ if c.HasAvailableInheritedFlags() {
+ inh := collectFlagRows(c.InheritedFlags())
+ if len(inh) > 0 {
+ b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
+ b.WriteString("\n")
+ }
+ }
+ return b.String()
+}
+
+func syncFlags(c *cobra.Command) {
+ _ = c.LocalFlags()
+ if c.HasAvailableInheritedFlags() {
+ _ = c.InheritedFlags()
+ }
+}
+
+func plainCommandHelp(c *cobra.Command) string {
+ desc := c.Long
+ if desc == "" {
+ desc = c.Short
+ }
+ desc = strings.TrimRight(desc, " \t\n\r")
+ var b strings.Builder
+ if desc != "" {
+ fmt.Fprintln(&b, desc)
+ fmt.Fprintln(&b)
+ }
+ if c.Runnable() || c.HasSubCommands() {
+ b.WriteString(c.UsageString())
+ }
+ return b.String()
+}
+
+func helpIntro(c *cobra.Command) (head, sub string) {
+ head = strings.TrimSpace(c.Short)
+ long := strings.TrimSpace(c.Long)
+ if long == "" || long == head {
+ return head, ""
+ }
+ lines := strings.Split(long, "\n")
+ var rest []string
+ for i, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ if ln == "" {
+ continue
+ }
+ if i == 0 && ln == head {
+ continue
+ }
+ rest = append(rest, ln)
+ }
+ sub = strings.Join(rest, "\n")
+ return head, sub
+}
+
+func visibleSubcommands(c *cobra.Command) []*cobra.Command {
+ var out []*cobra.Command
+ for _, sub := range c.Commands() {
+ if sub.Hidden {
+ continue
+ }
+ out = append(out, sub)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
+ return out
+}
+
+func sectionPanel(title, body string, width int) string {
+ head := titleBarStyle().Render(title) + "\n\n"
+ return borderStyle().Width(width).Render(head + body)
+}
+
+// styleUsageTokens highlights PicoClaw-blue command tokens and red /[groups].
+func styleUsageTokens(s string) string {
+ var b strings.Builder
+ for len(s) > 0 {
+ ia := strings.Index(s, "<")
+ ib := strings.Index(s, "[")
+ next, kind := -1, 0 // 1 = angle, 2 = bracket
+ switch {
+ case ia >= 0 && (ib < 0 || ia < ib):
+ next, kind = ia, 1
+ case ib >= 0:
+ next, kind = ib, 2
+ }
+ if next < 0 {
+ b.WriteString(helpIdentStyle().Render(s))
+ break
+ }
+ if next > 0 {
+ b.WriteString(helpIdentStyle().Render(s[:next]))
+ }
+ s = s[next:]
+ if kind == 1 {
+ j := strings.Index(s, ">")
+ if j < 0 {
+ b.WriteString(helpIdentStyle().Render(s))
+ break
+ }
+ b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
+ s = s[j+1:]
+ continue
+ }
+ j := strings.Index(s, "]")
+ if j < 0 {
+ b.WriteString(helpIdentStyle().Render(s))
+ break
+ }
+ b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
+ s = s[j+1:]
+ }
+ return b.String()
+}
+
+func collectFlagRows(fs *flag.FlagSet) [][2]string {
+ var names []string
+ seen := map[string][2]string{}
+ fs.VisitAll(func(f *flag.Flag) {
+ if f.Hidden {
+ return
+ }
+ left := formatFlagLeft(f)
+ right := f.Usage
+ if f.Deprecated != "" {
+ right += " (deprecated: " + f.Deprecated + ")"
+ }
+ names = append(names, f.Name)
+ seen[f.Name] = [2]string{left, right}
+ })
+ sort.Strings(names)
+ rows := make([][2]string, 0, len(names))
+ for _, n := range names {
+ rows = append(rows, seen[n])
+ }
+ return rows
+}
+
+func formatFlagLeft(f *flag.Flag) string {
+ if len(f.Shorthand) > 0 {
+ return "-" + f.Shorthand + ", --" + f.Name
+ }
+ return "--" + f.Name
+}
+
+func renderTwoColPairs(rows [][2]string, contentW int) string {
+ if len(rows) == 0 {
+ return ""
+ }
+ leftW := 0
+ for _, r := range rows {
+ if w := lipgloss.Width(r[0]); w > leftW {
+ leftW = w
+ }
+ }
+ const minLeft, maxLeft = 16, 34
+ if leftW < minLeft {
+ leftW = minLeft
+ }
+ if leftW > maxLeft {
+ leftW = maxLeft
+ }
+ gap := " "
+ rightW := contentW - leftW - lipgloss.Width(gap)
+ if rightW < 24 {
+ rightW = 24
+ }
+
+ var b strings.Builder
+ for _, r := range rows {
+ left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
+ right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
+ b.WriteString("\n")
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
diff --git a/cmd/picoclaw/internal/cliui/help_error.go b/cmd/picoclaw/internal/cliui/help_error.go
new file mode 100644
index 000000000..1e859b08f
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/help_error.go
@@ -0,0 +1,75 @@
+package cliui
+
+import (
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// FormatCLIError formats errors with the same boxed sections as help. When ctx
+// is the command that was running when the error occurred, Usage / Flags panels
+// are appended so styling matches picoclaw -h.
+func FormatCLIError(msg string, ctx *cobra.Command) string {
+ msg = strings.TrimRight(msg, "\n")
+ if !UseFancyStderr() {
+ s := "Error: " + msg + "\n"
+ if ctx != nil && showErrHint(msg) {
+ s += "\n" + plainCommandHelp(ctx)
+ }
+ return s
+ }
+ w := InnerStderrWidth()
+ contentW := w - 6
+ if contentW < 36 {
+ contentW = 36
+ }
+
+ title := titleBarStyle().Render("Error") + "\n\n"
+
+ paras := strings.Split(msg, "\n")
+ var body strings.Builder
+ for i, p := range paras {
+ p = strings.TrimRight(p, " ")
+ if p == "" {
+ continue
+ }
+ st := bodyStyle().Width(contentW)
+ if i > 0 {
+ body.WriteString("\n")
+ }
+ if i == 0 {
+ body.WriteString(st.Render(p))
+ } else {
+ body.WriteString(mutedStyle().Width(contentW).Render(p))
+ }
+ }
+
+ foot := ""
+ if showErrHint(msg) {
+ if ctx != nil {
+ foot = "\n\n" + mutedStyle().Width(contentW).
+ Render("Full command help: "+ctx.CommandPath()+" --help")
+ } else {
+ foot = "\n\n" + mutedStyle().Width(contentW).
+ Render("Tip: picoclaw --help · picoclaw --help")
+ }
+ }
+
+ out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
+ if ctx != nil && showErrHint(msg) {
+ if ref := RenderCommandQuickRef(ctx, w); ref != "" {
+ out += "\n" + ref
+ }
+ }
+ return out
+}
+
+func showErrHint(msg string) bool {
+ m := strings.ToLower(msg)
+ return strings.Contains(m, "unknown flag") ||
+ strings.Contains(m, "unknown shorthand flag") ||
+ strings.Contains(m, "flag needs an argument") ||
+ strings.Contains(m, "invalid argument") ||
+ strings.Contains(m, "required flag") ||
+ strings.Contains(m, "usage:")
+}
diff --git a/cmd/picoclaw/internal/cliui/onboard.go b/cmd/picoclaw/internal/cliui/onboard.go
new file mode 100644
index 000000000..e74cf68c6
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/onboard.go
@@ -0,0 +1,110 @@
+package cliui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
+func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
+ if !UseFancyLayout() {
+ printOnboardPlain(logo, encrypt, configPath)
+ return
+ }
+ printOnboardFancy(logo, encrypt, configPath)
+}
+
+func printOnboardPlain(logo string, encrypt bool, configPath string) {
+ fmt.Printf("\n%s picoclaw is ready!\n", logo)
+ fmt.Println("\nNext steps:")
+ if encrypt {
+ fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
+ fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS")
+ fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd")
+ fmt.Println("")
+ fmt.Println(" 2. Add your API key to", configPath)
+ } else {
+ fmt.Println(" 1. Add your API key to", configPath)
+ }
+ fmt.Println("")
+ fmt.Println(" Recommended:")
+ fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
+ fmt.Println(" - Ollama: https://ollama.com (local, free)")
+ fmt.Println("")
+ fmt.Println(" See README.md for 17+ supported providers.")
+ fmt.Println("")
+ if encrypt {
+ fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
+ } else {
+ fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
+ }
+}
+
+func printOnboardFancy(logo string, encrypt bool, configPath string) {
+ inner := InnerWidth()
+ box := borderStyle().MaxWidth(inner + 8)
+
+ ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
+ fmt.Println()
+ fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
+ fmt.Println()
+
+ steps := buildOnboardingSteps(encrypt, configPath)
+ rec := recommendedBlock()
+ chat := chatStep(encrypt)
+
+ if UseColumnLayout() {
+ leftW := min(inner/2-2, 52)
+ rightW := inner - leftW - 4
+ if rightW < 36 {
+ rightW = 36
+ }
+ leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
+ Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
+ rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
+ Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
+ gap := strings.Repeat(" ", 2)
+ fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
+ fmt.Println()
+ full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
+ fmt.Println(full)
+ return
+ }
+
+ // Same order as plain output: numbered steps → recommended → chat line.
+ next := titleBarStyle().Render("Next steps") + "\n\n" +
+ bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
+ fmt.Println(borderStyle().Width(inner).Render(next))
+}
+
+func buildOnboardingSteps(encrypt bool, configPath string) string {
+ var b strings.Builder
+ if encrypt {
+ b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
+ b.WriteString(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS\n")
+ b.WriteString(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd\n\n")
+ b.WriteString("2. Add your API key to\n ")
+ b.WriteString(configPath)
+ b.WriteString("\n")
+ } else {
+ b.WriteString("1. Add your API key to\n ")
+ b.WriteString(configPath)
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+func recommendedBlock() string {
+ return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
+ "• Ollama: https://ollama.com\n (local, free)\n\n" +
+ "See README.md for 17+ supported providers."
+}
+
+func chatStep(encrypt bool) string {
+ if encrypt {
+ return "3. Chat:\n picoclaw agent -m \"Hello!\""
+ }
+ return "2. Chat:\n picoclaw agent -m \"Hello!\""
+}
diff --git a/cmd/picoclaw/internal/cliui/status.go b/cmd/picoclaw/internal/cliui/status.go
new file mode 100644
index 000000000..f01fe296d
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/status.go
@@ -0,0 +1,168 @@
+package cliui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// ProviderRow holds one provider's display name and status value.
+type ProviderRow struct {
+ Name string
+ Val string
+}
+
+// StatusReport is a structured status view for PrintStatus.
+type StatusReport struct {
+ Logo string
+ Version string
+ Build string
+ ConfigPath string
+ ConfigOK bool
+ WorkspacePath string
+ WorkspaceOK bool
+ Model string
+ Providers []ProviderRow
+ OAuthLines []string // each full line "provider (method): state"
+}
+
+// PrintStatus renders picoclaw status (plain or fancy).
+func PrintStatus(r StatusReport) {
+ if !UseFancyLayout() {
+ printStatusPlain(r)
+ return
+ }
+ printStatusFancy(r)
+}
+
+func printStatusPlain(r StatusReport) {
+ fmt.Printf("%s picoclaw Status\n", r.Logo)
+ fmt.Printf("Version: %s\n", r.Version)
+ if r.Build != "" {
+ fmt.Printf("Build: %s\n", r.Build)
+ }
+ fmt.Println()
+
+ printPathLine("Config", r.ConfigPath, r.ConfigOK)
+ printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
+
+ if r.ConfigOK {
+ fmt.Printf("Model: %s\n", r.Model)
+ for _, p := range r.Providers {
+ fmt.Printf("%s: %s\n", p.Name, p.Val)
+ }
+ if len(r.OAuthLines) > 0 {
+ fmt.Println("\nOAuth/Token Auth:")
+ for _, line := range r.OAuthLines {
+ fmt.Printf(" %s\n", line)
+ }
+ }
+ }
+}
+
+func printPathLine(label, path string, ok bool) {
+ mark := "✗"
+ if ok {
+ mark = "✓"
+ }
+ fmt.Println(label+":", path, mark)
+}
+
+func printStatusFancy(r StatusReport) {
+ inner := InnerWidth()
+ topBox := borderStyle().Width(inner)
+
+ var head strings.Builder
+ head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
+ head.WriteString("\n\n")
+ head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
+ if r.Build != "" {
+ head.WriteString("\n")
+ head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
+ }
+ fmt.Println(topBox.Render(head.String()))
+ fmt.Println()
+
+ if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
+ leftW := (inner - 2) / 2
+ rightW := inner - leftW - 2
+ pathsNarrow := pathStatusPanel(r, leftW)
+ prov := providerTablePanel(r, rightW)
+ gap := strings.Repeat(" ", 2)
+ fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
+ } else {
+ fmt.Println(pathStatusPanel(r, inner))
+ if len(r.Providers) > 0 && r.ConfigOK {
+ fmt.Println(providerTablePanel(r, inner))
+ }
+ }
+
+ if len(r.OAuthLines) > 0 && r.ConfigOK {
+ var ob strings.Builder
+ ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
+ for _, line := range r.OAuthLines {
+ ob.WriteString(" • " + line + "\n")
+ }
+ fmt.Println()
+ fmt.Println(borderStyle().Width(inner).Render(ob.String()))
+ }
+}
+
+func pathStatusPanel(r StatusReport, inner int) string {
+ cfgMark := statusMark(r.ConfigOK)
+ wsMark := statusMark(r.WorkspaceOK)
+ var b strings.Builder
+ b.WriteString(kvKeyStyle().Render("Config") + "\n")
+ b.WriteString(mutedStyle().Render(r.ConfigPath))
+ b.WriteString(" " + cfgMark + "\n\n")
+ b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
+ b.WriteString(mutedStyle().Render(r.WorkspacePath))
+ b.WriteString(" " + wsMark + "\n")
+ if r.ConfigOK {
+ b.WriteString("\n")
+ b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
+ }
+ return borderStyle().Width(inner).Render(b.String())
+}
+
+func statusMark(ok bool) string {
+ if ok {
+ return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
+ }
+ return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
+}
+
+func providerTablePanel(r StatusReport, colW int) string {
+ if len(r.Providers) == 0 {
+ return ""
+ }
+ keyW := min(22, colW/3)
+ if keyW < 14 {
+ keyW = 14
+ }
+ valW := colW - keyW - 3
+ if valW < 12 {
+ valW = 12
+ }
+
+ var b strings.Builder
+ b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
+ for _, p := range r.Providers {
+ k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
+ v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
+ b.WriteString("\n")
+ }
+ return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
+}
+
+func styleProviderVal(s string) lipgloss.Style {
+ if s == "✓" || strings.HasPrefix(s, "✓ ") {
+ return lipgloss.NewStyle().Foreground(colorOK)
+ }
+ if s == "not set" {
+ return mutedStyle()
+ }
+ return lipgloss.NewStyle()
+}
diff --git a/cmd/picoclaw/internal/cliui/version.go b/cmd/picoclaw/internal/cliui/version.go
new file mode 100644
index 000000000..7ecbdae7f
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/version.go
@@ -0,0 +1,61 @@
+package cliui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// PrintVersion prints version, optional build info, and Go toolchain line.
+func PrintVersion(logo, versionLine string, build, goVer string) {
+ if !UseFancyLayout() {
+ fmt.Printf("%s %s\n", logo, versionLine)
+ if build != "" {
+ fmt.Printf(" Build: %s\n", build)
+ }
+ if goVer != "" {
+ fmt.Printf(" Go: %s\n", goVer)
+ }
+ return
+ }
+
+ inner := InnerWidth()
+ box := borderStyle().Width(inner)
+
+ if UseColumnLayout() {
+ leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
+ rightW := inner - 16
+ rightStyle := kvValStyle().Width(rightW)
+
+ rows := [][]string{
+ {leftCol.Render("Version"), rightStyle.Render(versionLine)},
+ }
+ if build != "" {
+ rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
+ }
+ if goVer != "" {
+ rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
+ }
+ var body strings.Builder
+ for _, r := range rows {
+ body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
+ body.WriteString("\n")
+ }
+ header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
+ fmt.Println(box.Render(header + body.String()))
+ return
+ }
+
+ var lines []string
+ lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
+ lines = append(lines, "")
+ lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
+ if build != "" {
+ lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
+ }
+ if goVer != "" {
+ lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
+ }
+ fmt.Println(box.Render(strings.Join(lines, "\n")))
+}
diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go
index 626698fec..721d74552 100644
--- a/cmd/picoclaw/internal/onboard/helpers.go
+++ b/cmd/picoclaw/internal/onboard/helpers.go
@@ -9,6 +9,7 @@ import (
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
)
@@ -79,29 +80,7 @@ func onboard(encrypt bool) {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
- fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
- fmt.Println("\nNext steps:")
- if encrypt {
- fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
- fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS")
- fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd")
- fmt.Println("")
- fmt.Println(" 2. Add your API key to", configPath)
- } else {
- fmt.Println(" 1. Add your API key to", configPath)
- }
- fmt.Println("")
- fmt.Println(" Recommended:")
- fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
- fmt.Println(" - Ollama: https://ollama.com (local, free)")
- fmt.Println("")
- fmt.Println(" See README.md for 17+ supported providers.")
- fmt.Println("")
- if encrypt {
- fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
- } else {
- fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
- }
+ cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
}
// promptPassphrase reads the encryption passphrase twice from the terminal
diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go
index 43c5786a8..e8e4fee9a 100644
--- a/cmd/picoclaw/internal/status/helpers.go
+++ b/cmd/picoclaw/internal/status/helpers.go
@@ -3,8 +3,10 @@ package status
import (
"fmt"
"os"
+ "strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -17,43 +19,125 @@ func statusCmd() {
}
configPath := internal.GetConfigPath()
-
- fmt.Printf("%s picoclaw Status\n", internal.Logo)
- fmt.Printf("Version: %s\n", config.FormatVersion())
build, _ := config.FormatBuildInfo()
- if build != "" {
- fmt.Printf("Build: %s\n", build)
- }
- fmt.Println()
- if _, err := os.Stat(configPath); err == nil {
- fmt.Println("Config:", configPath, "✓")
- } else {
- fmt.Println("Config:", configPath, "✗")
- }
+ _, configStatErr := os.Stat(configPath)
+ configOK := configStatErr == nil
workspace := cfg.WorkspacePath()
- if _, err := os.Stat(workspace); err == nil {
- fmt.Println("Workspace:", workspace, "✓")
- } else {
- fmt.Println("Workspace:", workspace, "✗")
+ _, wsErr := os.Stat(workspace)
+ wsOK := wsErr == nil
+
+ report := cliui.StatusReport{
+ Logo: internal.Logo,
+ Version: config.FormatVersion(),
+ Build: build,
+ ConfigPath: configPath,
+ ConfigOK: configOK,
+ WorkspacePath: workspace,
+ WorkspaceOK: wsOK,
+ Model: cfg.Agents.Defaults.GetModelName(),
}
- if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
+ if configOK {
+ // PicoClaw moved to a model-centric configuration (model_list). Status should
+ // not depend on a legacy cfg.Providers field (which may not exist under some
+ // build tags). We infer provider availability from model_list entries.
+ hasProtocolKey := func(protocol string) bool {
+ prefix := protocol + "/"
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
+ return true
+ }
+ }
+ return false
+ }
+ findLocalModelBase := func(modelName string) (string, bool) {
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if m.ModelName == modelName && m.APIBase != "" {
+ return m.APIBase, true
+ }
+ }
+ return "", false
+ }
+ findProtocolBase := func(protocol string) (string, bool) {
+ prefix := protocol + "/"
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
+ return m.APIBase, true
+ }
+ }
+ return "", false
+ }
+
+ hasOpenRouter := hasProtocolKey("openrouter")
+ hasAnthropic := hasProtocolKey("anthropic")
+ hasOpenAI := hasProtocolKey("openai")
+ hasGemini := hasProtocolKey("gemini")
+ hasZhipu := hasProtocolKey("zhipu")
+ hasQwen := hasProtocolKey("qwen")
+ hasGroq := hasProtocolKey("groq")
+ hasMoonshot := hasProtocolKey("moonshot")
+ hasDeepSeek := hasProtocolKey("deepseek")
+ hasVolcEngine := hasProtocolKey("volcengine")
+ hasNvidia := hasProtocolKey("nvidia")
+
+ // Local endpoints: allow both the special reserved name and protocol-based entries.
+ vllmBase, hasVLLM := findLocalModelBase("local-model")
+ if !hasVLLM {
+ vllmBase, hasVLLM = findProtocolBase("vllm")
+ }
+ ollamaBase, hasOllama := findProtocolBase("ollama")
+
+ val := func(enabled bool, extra ...string) string {
+ if enabled {
+ if len(extra) > 0 && extra[0] != "" {
+ return "✓ " + extra[0]
+ }
+ return "✓"
+ }
+ return "not set"
+ }
+
+ report.Providers = []cliui.ProviderRow{
+ {Name: "OpenRouter API", Val: val(hasOpenRouter)},
+ {Name: "Anthropic API", Val: val(hasAnthropic)},
+ {Name: "OpenAI API", Val: val(hasOpenAI)},
+ {Name: "Gemini API", Val: val(hasGemini)},
+ {Name: "Zhipu API", Val: val(hasZhipu)},
+ {Name: "Qwen API", Val: val(hasQwen)},
+ {Name: "Groq API", Val: val(hasGroq)},
+ {Name: "Moonshot API", Val: val(hasMoonshot)},
+ {Name: "DeepSeek API", Val: val(hasDeepSeek)},
+ {Name: "VolcEngine API", Val: val(hasVolcEngine)},
+ {Name: "Nvidia API", Val: val(hasNvidia)},
+ {Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
+ {Name: "Ollama", Val: val(hasOllama, ollamaBase)},
+ }
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
- fmt.Println("\nOAuth/Token Auth:")
for provider, cred := range store.Credentials {
- status := "authenticated"
+ st := "authenticated"
if cred.IsExpired() {
- status = "expired"
+ st = "expired"
} else if cred.NeedsRefresh() {
- status = "needs refresh"
+ st = "needs refresh"
}
- fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
+ report.OAuthLines = append(report.OAuthLines,
+ fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
}
}
}
+
+ cliui.PrintStatus(report)
}
diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go
index 71c7dd2f8..81da4b878 100644
--- a/cmd/picoclaw/internal/version/command.go
+++ b/cmd/picoclaw/internal/version/command.go
@@ -1,11 +1,10 @@
package version
import (
- "fmt"
-
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command {
}
func printVersion() {
- fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
build, goVer := config.FormatBuildInfo()
- if build != "" {
- fmt.Printf(" Build: %s\n", build)
- }
- if goVer != "" {
- fmt.Printf(" Go: %s\n", goVer)
- }
+ cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 543577e68..0867203a6 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -16,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
@@ -28,15 +29,57 @@ import (
"github.com/sipeed/picoclaw/pkg/updater"
)
+var rootNoColor bool
+
+func syncCliUIColor(root *cobra.Command) {
+ no, _ := root.PersistentFlags().GetBool("no-color")
+ cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
+}
+
+// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
+func earlyColorDisabled() bool {
+ if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
+ return true
+ }
+ for i := 1; i < len(os.Args); i++ {
+ arg := os.Args[i]
+ if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
+ return true
+ }
+ }
+ return false
+}
+
func NewPicoclawCommand() *cobra.Command {
- short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
+ short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
+ long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
+
+Version: %s`, internal.Logo, config.FormatVersion())
cmd := &cobra.Command{
- Use: "picoclaw",
- Short: short,
- Example: "picoclaw version",
+ Use: "picoclaw",
+ Short: short,
+ Long: long,
+ Example: `picoclaw version
+picoclaw onboard
+picoclaw --no-color status`,
+ SilenceErrors: true,
+ // Avoid plain UsageString() on stderr/stdout when a command fails; cliui
+ // renders matching panels on stderr instead.
+ SilenceUsage: true,
+ PersistentPreRun: func(c *cobra.Command, _ []string) {
+ syncCliUIColor(c.Root())
+ },
}
+ cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
+ "Disable colors (boxed layout unchanged)")
+
+ cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
+ syncCliUIColor(c.Root())
+ fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
+ })
+
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
@@ -65,17 +108,31 @@ const (
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
+ plainBanner = "\r\n" +
+ "██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
+ "██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
+ "██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
+ "██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
+ "██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
+ "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
+ "\r\n"
)
func main() {
- fmt.Printf("%s", banner)
+ cliui.Init(earlyColorDisabled())
- tz_env := os.Getenv("TZ")
- if tz_env != "" {
- fmt.Println("TZ environment:", tz_env)
- zoneinfo_env := os.Getenv("ZONEINFO")
- fmt.Println("ZONEINFO environment:", zoneinfo_env)
- loc, err := time.LoadLocation(tz_env)
+ if earlyColorDisabled() {
+ fmt.Print(plainBanner)
+ } else {
+ fmt.Printf("%s", banner)
+ }
+
+ tzEnv := os.Getenv("TZ")
+ if tzEnv != "" {
+ fmt.Println("TZ environment:", tzEnv)
+ zoneinfoEnv := os.Getenv("ZONEINFO")
+ fmt.Println("ZONEINFO environment:", zoneinfoEnv)
+ loc, err := time.LoadLocation(tzEnv)
if err != nil {
fmt.Println("Error loading time zone:", err)
} else {
@@ -85,7 +142,10 @@ func main() {
}
cmd := NewPicoclawCommand()
- if err := cmd.Execute(); err != nil {
+ last, err := cmd.ExecuteC()
+ if err != nil {
+ syncCliUIColor(cmd)
+ fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
os.Exit(1)
}
}
diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go
index 3e147cbfe..309e60ba9 100644
--- a/cmd/picoclaw/main_test.go
+++ b/cmd/picoclaw/main_test.go
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"slices"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd)
- short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
+ short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
+ longHas := strings.Contains(cmd.Long, config.FormatVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
+ assert.True(t, longHas)
assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasAvailableSubCommands())
- assert.False(t, cmd.HasFlags())
+ assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
assert.Nil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
- assert.Nil(t, cmd.PersistentPreRun)
+ assert.NotNil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
allowedCommands := []string{
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 480244127..f36a98ff6 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1
-# Copy binary
+# Copy binary and first-run entrypoint (same as release image).
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
-# Create non-root user and group
-RUN addgroup -g 1000 picoclaw && \
- adduser -D -u 1000 -G picoclaw picoclaw
-
-# Switch to non-root user
-USER picoclaw
-
-# Run onboard to create initial directories and config
-RUN /usr/local/bin/picoclaw onboard
-
-ENTRYPOINT ["picoclaw"]
-CMD ["gateway"]
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker/Dockerfile.heavy b/docker/Dockerfile.heavy
index cbc243e39..2a9fc742d 100644
--- a/docker/Dockerfile.heavy
+++ b/docker/Dockerfile.heavy
@@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
-# Reuse existing node user (UID/GID 1000) — rename to picoclaw
-RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
- addgroup -g 1000 picoclaw 2>/dev/null; \
- adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
-
-USER picoclaw
-
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
# Copy default workspace
-COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
+COPY workspace/ /root/.picoclaw/workspace/
-VOLUME /home/picoclaw/.picoclaw/workspace
+VOLUME /root/.picoclaw/workspace
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
diff --git a/docs/hooks/README.md b/docs/hooks/README.md
index ec3bbc46a..5be0f30b5 100644
--- a/docs/hooks/README.md
+++ b/docs/hooks/README.md
@@ -28,6 +28,69 @@ The currently exposed synchronous hook points are:
Everything else is exposed as read-only events.
+## Hook Actions
+
+Hooks can return different actions to control the flow:
+
+| Action | Applicable Stages | Effect |
+| --- | --- | --- |
+| `continue` | All interceptors | Pass through without modification |
+| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | Modify request/response and continue |
+| `respond` | `before_tool` | Return a tool result directly, skip actual tool execution |
+| `deny_tool` | `before_tool` | Deny tool execution, return error message |
+| `abort_turn` | All interceptors | Abort the current turn |
+| `hard_abort` | All interceptors | Force stop the entire agent loop |
+
+### The `respond` Action
+
+The `respond` action is special: it allows a `before_tool` hook to provide the tool result directly, skipping the actual tool execution. This is useful for:
+
+1. **Plugin tool injection**: External hooks can implement tools without registering them in the tool registry
+2. **Tool result caching**: Return cached results for repeated tool calls
+3. **Tool mocking**: Return mock results for testing purposes
+
+When a hook returns `respond` with a `HookResult`, the agent loop:
+1. Skips the actual tool execution
+2. Uses the provided result as if the tool had executed
+3. Continues the turn normally with the result
+
+Example (Go in-process hook):
+
+```go
+func (h *MyHook) BeforeTool(
+ ctx context.Context,
+ call *agent.ToolCallHookRequest,
+) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
+ if call.Tool == "my_plugin_tool" {
+ next := call.Clone()
+ next.HookResult = &tools.ToolResult{
+ ForLLM: "Plugin tool executed successfully",
+ Silent: false,
+ IsError: false,
+ }
+ return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
+ }
+ return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
+}
+```
+
+Example (Python process hook):
+
+```python
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+ if tool == "my_plugin_tool":
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": "Plugin tool executed successfully",
+ "silent": False,
+ "is_error": False
+ }
+ }
+ return {"action": "continue"}
+```
+
## Execution Order
`HookManager` sorts hooks like this:
diff --git a/docs/hooks/README.zh.md b/docs/hooks/README.zh.md
index 46c7c9392..2170d45c8 100644
--- a/docs/hooks/README.zh.md
+++ b/docs/hooks/README.zh.md
@@ -28,6 +28,69 @@
其余 lifecycle 通过事件形式只读暴露。
+## Hook Actions
+
+Hook 可以返回不同的 action 来控制流程:
+
+| Action | 适用阶段 | 效果 |
+| --- | --- | --- |
+| `continue` | 所有拦截型 | 放行,不做修改 |
+| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | 改写请求/响应后放行 |
+| `respond` | `before_tool` | 直接返回工具结果,跳过实际工具执行 |
+| `deny_tool` | `before_tool` | 拒绝工具执行,返回错误信息 |
+| `abort_turn` | 所有拦截型 | 中止当前 turn |
+| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
+
+### `respond` Action
+
+`respond` action 是特殊的:它允许 `before_tool` hook 直接提供工具结果,跳过实际工具执行。适用于:
+
+1. **插件工具注入**:外部 hook 可以实现工具,无需在 ToolRegistry 注册
+2. **工具结果缓存**:对重复调用返回缓存结果
+3. **工具模拟**:测试时返回模拟结果
+
+当 hook 返回 `respond` 并携带 `HookResult` 时,agent loop 会:
+1. 跳过实际工具执行
+2. 使用提供的结果作为工具执行结果
+3. 正常继续 turn 流程
+
+示例(Go 进程内 hook):
+
+```go
+func (h *MyHook) BeforeTool(
+ ctx context.Context,
+ call *agent.ToolCallHookRequest,
+) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
+ if call.Tool == "my_plugin_tool" {
+ next := call.Clone()
+ next.HookResult = &tools.ToolResult{
+ ForLLM: "Plugin tool executed successfully",
+ Silent: false,
+ IsError: false,
+ }
+ return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
+ }
+ return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
+}
+```
+
+示例(Python process hook):
+
+```python
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+ if tool == "my_plugin_tool":
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": "Plugin tool executed successfully",
+ "silent": False,
+ "is_error": False
+ }
+ }
+ return {"action": "continue"}
+```
+
## 执行顺序
HookManager 的排序规则是:
diff --git a/docs/hooks/hook-json-protocol.md b/docs/hooks/hook-json-protocol.md
new file mode 100644
index 000000000..58b6e323b
--- /dev/null
+++ b/docs/hooks/hook-json-protocol.md
@@ -0,0 +1,568 @@
+# Hook JSON-RPC Protocol Details
+
+All hooks use `JSON-RPC 2.0` format, with one JSON message per line, transmitted via stdio.
+
+---
+
+## Basic Protocol Structure
+
+### Request (PicoClaw → Hook)
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
+```
+
+### Response (Hook → PicoClaw)
+
+Success:
+```json
+{"jsonrpc":"2.0","id":1,"result":{...}}
+```
+
+Error:
+```json
+{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error message"}}
+```
+
+---
+
+## 1. `hook.hello` (Handshake)
+
+Handshake must be completed at startup, otherwise the hook process will be terminated.
+
+### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "hook.hello",
+ "params": {
+ "name": "py_review_gate",
+ "version": 1,
+ "modes": ["observe", "tool", "approve"]
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `name` | hook name (from configuration) |
+| `version` | protocol version, currently `1` |
+| `modes` | capability modes supported by the hook |
+
+### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "ok": true,
+ "name": "python-review-gate"
+ }
+}
+```
+
+---
+
+## 2. `hook.before_llm`
+
+Triggered before sending request to LLM. Can be used to inject tools.
+
+### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "hook.before_llm",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "ParentTurnID": "",
+ "SessionKey": "session-1",
+ "Iteration": 0,
+ "TracePath": "runTurn",
+ "Source": "turn.llm.request"
+ },
+ "model": "claude-sonnet",
+ "messages": [
+ {"role": "user", "content": "hello"}
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "description": "echo text",
+ "parameters": {"type": "object"}
+ }
+ }
+ ],
+ "options": {
+ "temperature": 0.7
+ },
+ "channel": "cli",
+ "chat_id": "chat-1",
+ "graceful_terminal": false
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `meta` | event metadata for tracing |
+| `model` | requested model name |
+| `messages` | conversation history |
+| `tools` | list of available tool definitions |
+| `options` | LLM parameters (temperature, max_tokens, etc.) |
+| `channel` | request source channel |
+| `chat_id` | session ID |
+
+### Response (Tool Injection Example)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "action": "modify",
+ "request": {
+ "model": "claude-sonnet",
+ "messages": [{"role": "user", "content": "hello"}],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "description": "echo",
+ "parameters": {}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "my_plugin_tool",
+ "description": "Plugin injected tool",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"}
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `action` | decision action (see table below) |
+| `request` | modified request object |
+
+---
+
+## 3. `hook.after_llm`
+
+Triggered after receiving LLM response. Can modify response content.
+
+### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "hook.after_llm",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "model": "claude-sonnet",
+ "response": {
+ "role": "assistant",
+ "content": "Hi!",
+ "tool_calls": [
+ {
+ "id": "tc-1",
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "arguments": "{\"text\":\"hi\"}"
+ }
+ }
+ ]
+ },
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "result": {
+ "action": "continue"
+ }
+}
+```
+
+---
+
+## 4. `hook.before_tool`
+
+Triggered before tool execution. Can modify tool name and arguments, deny execution, or return result directly.
+
+### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "method": "hook.before_tool",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "tool": "echo_text",
+ "arguments": {
+ "text": "hello"
+ },
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `tool` | tool name |
+| `arguments` | tool arguments |
+
+### Response (Modify Arguments)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "action": "modify",
+ "call": {
+ "tool": "echo_text",
+ "arguments": {
+ "text": "modified hello"
+ }
+ }
+ }
+}
+```
+
+### Response (Deny Execution)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "action": "deny_tool",
+ "reason": "Invalid arguments"
+ }
+}
+```
+
+### Response (Return Result Directly - respond)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "action": "respond",
+ "call": {
+ "tool": "my_plugin_tool",
+ "arguments": {
+ "query": "hello"
+ }
+ },
+ "result": {
+ "for_llm": "Plugin tool executed successfully",
+ "for_user": "",
+ "silent": false,
+ "is_error": false
+ }
+ }
+}
+```
+
+The `respond` action allows hooks to return tool results directly, skipping actual tool execution. Use cases:
+1. **Plugin tool injection**: External hooks can implement tools without registering in ToolRegistry
+2. **Tool result caching**: Return cached results for repeated calls
+3. **Tool mocking**: Return mock results during testing
+
+| Field | Description |
+|-------|-------------|
+| `action` | must be `respond` |
+| `call` | modified call information (optional) |
+| `result` | tool result to return directly |
+
+---
+
+## 5. `hook.after_tool`
+
+Triggered after tool execution completes. Can modify the result returned to LLM.
+
+### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 5,
+ "method": "hook.after_tool",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "tool": "echo_text",
+ "arguments": {
+ "text": "hello"
+ },
+ "result": {
+ "for_llm": "echoed: hello",
+ "for_user": "",
+ "silent": false,
+ "is_error": false,
+ "async": false,
+ "media": [],
+ "artifact_tags": [],
+ "response_handled": false
+ },
+ "duration": 15000000,
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `result.for_llm` | content returned to LLM |
+| `result.for_user` | content sent to user |
+| `result.silent` | whether silent (not sent to user) |
+| `result.is_error` | whether it's an error |
+| `result.async` | whether executed asynchronously |
+| `result.media` | list of media references |
+| `result.artifact_tags` | local artifact path tags |
+| `result.response_handled` | whether response has been handled |
+| `duration` | execution time (nanoseconds) |
+
+### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 5,
+ "result": {
+ "action": "continue"
+ }
+}
+```
+
+---
+
+## 6. `hook.approve_tool`
+
+Approval hook for deciding whether to allow execution of sensitive tools.
+
+### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "method": "hook.approve_tool",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "tool": "bash",
+ "arguments": {
+ "command": "rm -rf /"
+ },
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+### Response (Approved)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "result": {
+ "approved": true
+ }
+}
+```
+
+### Response (Denied)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "result": {
+ "approved": false,
+ "reason": "Dangerous command, execution denied"
+ }
+}
+```
+
+---
+
+## 7. `hook.event` (notification)
+
+Observer event, broadcast only, no response required. `id` is `0` or absent.
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "hook.event",
+ "params": {
+ "Kind": "tool_exec_start",
+ "Meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1"
+ },
+ "Payload": {
+ "Tool": "echo_text",
+ "Arguments": {"text": "hello"}
+ }
+ }
+}
+```
+
+Common `Kind` values:
+- `turn_start` / `turn_end`
+- `llm_request` / `llm_response`
+- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
+- `steering_injected`
+- `interrupt_received`
+- `error`
+
+---
+
+## Action Options
+
+| action | Applicable hooks | Effect |
+|--------|-----------------|--------|
+| `continue` | All interceptor types | Pass through without modification |
+| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | Modify request/response and pass through |
+| `respond` | `before_tool` | Return tool result directly, skip actual execution. **Note: AfterTool is NOT called (design decision - respond provides final answer).** |
+| `deny_tool` | `before_tool` | Deny tool execution |
+| `abort_turn` | All interceptor types | Abort current turn, return error |
+| `hard_abort` | All interceptor types | Force stop entire agent loop |
+
+---
+
+## Complete Flow Example
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
+{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
+{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
+{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
+{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
+{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
+{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
+{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
+{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
+{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"Files listed"}}}
+{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
+```
+
+---
+
+## Plugin Tool Injection via `before_llm` and `before_tool`
+
+Standard flow for plugin tool injection:
+
+1. In `before_llm`, inject tool definition to let LLM know the tool is available
+2. In `before_tool`, use `respond` action to return tool execution result directly
+
+### `before_llm` Inject Tool Definition
+
+```python
+def handle_before_llm(params: dict) -> dict:
+ tools = params.get("tools", [])
+
+ # Add plugin tool definition
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "my_plugin_tool",
+ "description": "Plugin provided tool",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "input": {"type": "string", "description": "Input content"}
+ },
+ "required": ["input"]
+ }
+ }
+ })
+
+ return {
+ "action": "modify",
+ "request": {
+ "model": params["model"],
+ "messages": params["messages"],
+ "tools": tools,
+ "options": params.get("options", {})
+ }
+ }
+```
+
+### `before_tool` Return Execution Result
+
+```python
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+
+ if tool == "my_plugin_tool":
+ # Implement tool logic here
+ args = params.get("arguments", {})
+ input_text = args.get("input", "")
+
+ # Return result directly, no need to register in ToolRegistry
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": f"Plugin tool executed successfully, input: {input_text}",
+ "silent": False,
+ "is_error": False
+ }
+ }
+
+ return {"action": "continue"}
+```
+
+This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
\ No newline at end of file
diff --git a/docs/hooks/hook-json-protocol.zh.md b/docs/hooks/hook-json-protocol.zh.md
new file mode 100644
index 000000000..675e0a429
--- /dev/null
+++ b/docs/hooks/hook-json-protocol.zh.md
@@ -0,0 +1,568 @@
+# Hook JSON-RPC 协议详解
+
+所有 hook 使用 `JSON-RPC 2.0` 格式,每行一个 JSON 消息,通过 stdio 传输。
+
+---
+
+## 基础协议结构
+
+### 请求(PicoClaw → Hook)
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
+```
+
+### 响应(Hook → PicoClaw)
+
+成功:
+```json
+{"jsonrpc":"2.0","id":1,"result":{...}}
+```
+
+错误:
+```json
+{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"错误信息"}}
+```
+
+---
+
+## 1. `hook.hello`(握手)
+
+启动时必须完成握手,否则 hook 进程会被终止。
+
+### 请求
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "hook.hello",
+ "params": {
+ "name": "py_review_gate",
+ "version": 1,
+ "modes": ["observe", "tool", "approve"]
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `name` | hook 名称(来自配置) |
+| `version` | 协议版本,当前为 `1` |
+| `modes` | hook 支持的能力模式 |
+
+### 响应
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "ok": true,
+ "name": "python-review-gate"
+ }
+}
+```
+
+---
+
+## 2. `hook.before_llm`
+
+在发送请求给 LLM 之前触发。可用于注入工具。
+
+### 请求
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "hook.before_llm",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "ParentTurnID": "",
+ "SessionKey": "session-1",
+ "Iteration": 0,
+ "TracePath": "runTurn",
+ "Source": "turn.llm.request"
+ },
+ "model": "claude-sonnet",
+ "messages": [
+ {"role": "user", "content": "hello"}
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "description": "echo text",
+ "parameters": {"type": "object"}
+ }
+ }
+ ],
+ "options": {
+ "temperature": 0.7
+ },
+ "channel": "cli",
+ "chat_id": "chat-1",
+ "graceful_terminal": false
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `meta` | 事件元数据,用于追踪 |
+| `model` | 请求的模型名称 |
+| `messages` | 对话历史 |
+| `tools` | 可用工具定义列表 |
+| `options` | LLM 参数(temperature、max_tokens 等) |
+| `channel` | 请求来源通道 |
+| `chat_id` | 会话 ID |
+
+### 响应(注入工具示例)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "action": "modify",
+ "request": {
+ "model": "claude-sonnet",
+ "messages": [{"role": "user", "content": "hello"}],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "description": "echo",
+ "parameters": {}
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "my_plugin_tool",
+ "description": "插件注入的工具",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"}
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `action` | 决策动作(见下表) |
+| `request` | 修改后的请求对象 |
+
+---
+
+## 3. `hook.after_llm`
+
+在收到 LLM 响应后触发。可修改响应内容。
+
+### 请求
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "hook.after_llm",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "model": "claude-sonnet",
+ "response": {
+ "role": "assistant",
+ "content": "Hi!",
+ "tool_calls": [
+ {
+ "id": "tc-1",
+ "type": "function",
+ "function": {
+ "name": "echo",
+ "arguments": "{\"text\":\"hi\"}"
+ }
+ }
+ ]
+ },
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+### 响应
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 3,
+ "result": {
+ "action": "continue"
+ }
+}
+```
+
+---
+
+## 4. `hook.before_tool`
+
+在执行工具前触发。可修改工具名称和参数,或拒绝执行,或直接返回结果。
+
+### 请求
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "method": "hook.before_tool",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "tool": "echo_text",
+ "arguments": {
+ "text": "hello"
+ },
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `tool` | 工具名称 |
+| `arguments` | 工具参数 |
+
+### 响应(改写参数)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "action": "modify",
+ "call": {
+ "tool": "echo_text",
+ "arguments": {
+ "text": "modified hello"
+ }
+ }
+ }
+}
+```
+
+### 响应(拒绝执行)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "action": "deny_tool",
+ "reason": "参数不合法"
+ }
+}
+```
+
+### 响应(直接返回结果 - respond)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 4,
+ "result": {
+ "action": "respond",
+ "call": {
+ "tool": "my_plugin_tool",
+ "arguments": {
+ "query": "hello"
+ }
+ },
+ "result": {
+ "for_llm": "Plugin tool executed successfully",
+ "for_user": "",
+ "silent": false,
+ "is_error": false
+ }
+ }
+}
+```
+
+`respond` action 允许 hook 直接返回工具结果,跳过实际工具执行。适用于:
+1. **插件工具注入**:外部 hook 可实现工具,无需在 ToolRegistry 注册
+2. **工具结果缓存**:对重复调用返回缓存结果
+3. **工具模拟**:测试时返回模拟结果
+
+| 字段 | 说明 |
+|------|------|
+| `action` | 必须为 `respond` |
+| `call` | 修改后的调用信息(可选) |
+| `result` | 直接返回的工具结果 |
+
+---
+
+## 5. `hook.after_tool`
+
+在工具执行完成后触发。可修改返回给 LLM 的结果。
+
+### 请求
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 5,
+ "method": "hook.after_tool",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "tool": "echo_text",
+ "arguments": {
+ "text": "hello"
+ },
+ "result": {
+ "for_llm": "echoed: hello",
+ "for_user": "",
+ "silent": false,
+ "is_error": false,
+ "async": false,
+ "media": [],
+ "artifact_tags": [],
+ "response_handled": false
+ },
+ "duration": 15000000,
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `result.for_llm` | 返回给 LLM 的内容 |
+| `result.for_user` | 发送给用户的内容 |
+| `result.silent` | 是否静默(不发送给用户) |
+| `result.is_error` | 是否为错误 |
+| `result.async` | 是否异步执行 |
+| `result.media` | 媒体引用列表 |
+| `result.artifact_tags` | 本地产物路径标签 |
+| `result.response_handled` | 是否已处理响应 |
+| `duration` | 执行耗时(纳秒) |
+
+### 响应
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 5,
+ "result": {
+ "action": "continue"
+ }
+}
+```
+
+---
+
+## 6. `hook.approve_tool`
+
+审批型 hook,用于决定是否允许执行敏感工具。
+
+### 请求
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "method": "hook.approve_tool",
+ "params": {
+ "meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1",
+ "SessionKey": "session-1"
+ },
+ "tool": "bash",
+ "arguments": {
+ "command": "rm -rf /"
+ },
+ "channel": "cli",
+ "chat_id": "chat-1"
+ }
+}
+```
+
+### 响应(批准)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "result": {
+ "approved": true
+ }
+}
+```
+
+### 响应(拒绝)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 6,
+ "result": {
+ "approved": false,
+ "reason": "危险命令,禁止执行"
+ }
+}
+```
+
+---
+
+## 7. `hook.event`(notification)
+
+观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "hook.event",
+ "params": {
+ "Kind": "tool_exec_start",
+ "Meta": {
+ "AgentID": "agent-1",
+ "TurnID": "turn-1"
+ },
+ "Payload": {
+ "Tool": "echo_text",
+ "Arguments": {"text": "hello"}
+ }
+ }
+}
+```
+
+常见 `Kind` 值:
+- `turn_start` / `turn_end`
+- `llm_request` / `llm_response`
+- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
+- `steering_injected`
+- `interrupt_received`
+- `error`
+
+---
+
+## action 可选值
+
+| action | 适用 hook | 效果 |
+|--------|----------|------|
+| `continue` | 所有拦截型 | 放行,不做修改 |
+| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | 改写请求/响应后放行 |
+| `respond` | `before_tool` | 直接返回工具结果,跳过实际执行 |
+| `deny_tool` | `before_tool` | 拒绝执行该工具 |
+| `abort_turn` | 所有拦截型 | 中止当前 turn,返回错误 |
+| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
+
+---
+
+## 完整流程示例
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
+{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
+{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
+{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
+{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
+{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
+{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
+{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
+{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
+{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"已列出文件"}}}
+{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
+```
+
+---
+
+## 通过 `before_llm` 和 `before_tool` 实现插件工具注入
+
+插件工具注入的标准流程:
+
+1. 在 `before_llm` 中注入工具定义,让 LLM 知道有这个工具可用
+2. 在 `before_tool` 中使用 `respond` action 直接返回工具执行结果
+
+### `before_llm` 注入工具定义
+
+```python
+def handle_before_llm(params: dict) -> dict:
+ tools = params.get("tools", [])
+
+ # 添加插件工具定义
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "my_plugin_tool",
+ "description": "插件提供的工具",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "input": {"type": "string", "description": "输入内容"}
+ },
+ "required": ["input"]
+ }
+ }
+ })
+
+ return {
+ "action": "modify",
+ "request": {
+ "model": params["model"],
+ "messages": params["messages"],
+ "tools": tools,
+ "options": params.get("options", {})
+ }
+ }
+```
+
+### `before_tool` 返回执行结果
+
+```python
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+
+ if tool == "my_plugin_tool":
+ # 在这里实现工具逻辑
+ args = params.get("arguments", {})
+ input_text = args.get("input", "")
+
+ # 直接返回结果,无需在 ToolRegistry 注册
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": f"插件工具执行成功,输入: {input_text}",
+ "silent": False,
+ "is_error": False
+ }
+ }
+
+ return {"action": "continue"}
+```
+
+通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
\ No newline at end of file
diff --git a/docs/hooks/plugin-tool-injection.md b/docs/hooks/plugin-tool-injection.md
new file mode 100644
index 000000000..9e699867b
--- /dev/null
+++ b/docs/hooks/plugin-tool-injection.md
@@ -0,0 +1,587 @@
+# Plugin Tool Injection Example
+
+This document demonstrates how to use PicoClaw's hook system to implement external plugin tool injection, allowing LLM to call tools implemented by external hook processes.
+
+---
+
+## Core Principle
+
+Through the hook system's `respond` action, external hooks can:
+
+1. Inject tool **definitions** in `before_llm`, letting LLM know the tool is available
+2. Return tool **execution results** directly in `before_tool` using `respond` action, skipping ToolRegistry
+
+This way, external hooks can fully implement plugin tools without registering any tools inside PicoClaw.
+
+---
+
+## Complete Example: Weather Query Plugin
+
+Below is a complete Python hook example implementing a weather query plugin tool.
+
+### 1. Hook Script Implementation
+
+Save as `/tmp/weather_plugin.py`:
+
+```python
+#!/usr/bin/env python3
+"""Weather query plugin hook example"""
+from __future__ import annotations
+
+import json
+import sys
+import signal
+from typing import Any
+
+# Simulated weather data
+WEATHER_DATA = {
+ "Beijing": {"temp": 15, "weather": "Sunny", "humidity": 45},
+ "Shanghai": {"temp": 18, "weather": "Cloudy", "humidity": 60},
+ "Guangzhou": {"temp": 25, "weather": "Sunny", "humidity": 70},
+ "Shenzhen": {"temp": 26, "weather": "Cloudy", "humidity": 75},
+}
+
+
+def get_weather(city: str) -> dict:
+ """Get weather data (simulated)"""
+ data = WEATHER_DATA.get(city)
+ if data:
+ return {
+ "for_llm": f"{city} weather: {data['weather']}, temperature {data['temp']}°C, humidity {data['humidity']}%",
+ "for_user": "",
+ "silent": False,
+ "is_error": False,
+ }
+ return {
+ "for_llm": f"Weather data not found for city {city}",
+ "for_user": "",
+ "silent": False,
+ "is_error": True,
+ }
+
+
+def handle_hello(params: dict) -> dict:
+ return {"ok": True, "name": "weather-plugin"}
+
+
+def handle_before_llm(params: dict) -> dict:
+ """Inject weather query tool definition"""
+ tools = params.get("tools", [])
+
+ # Add weather query tool
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Query weather information for a specified city",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string",
+ "description": "City name, e.g.: Beijing, Shanghai, Guangzhou"
+ }
+ },
+ "required": ["city"]
+ }
+ }
+ })
+
+ return {
+ "action": "modify",
+ "request": {
+ "model": params.get("model"),
+ "messages": params.get("messages", []),
+ "tools": tools,
+ "options": params.get("options", {}),
+ }
+ }
+
+
+def handle_before_tool(params: dict) -> dict:
+ """Handle tool call, return result directly"""
+ tool = params.get("tool", "")
+ args = params.get("arguments", {})
+
+ if tool == "get_weather":
+ city = args.get("city", "")
+ result = get_weather(city)
+
+ # Use respond action to return result directly, skip ToolRegistry
+ return {
+ "action": "respond",
+ "result": result,
+ }
+
+ # Other tools continue normal flow
+ return {"action": "continue"}
+
+
+def handle_request(method: str, params: dict) -> dict:
+ if method == "hook.hello":
+ return handle_hello(params)
+ if method == "hook.before_llm":
+ return handle_before_llm(params)
+ if method == "hook.before_tool":
+ return handle_before_tool(params)
+ if method == "hook.after_llm":
+ return {"action": "continue"}
+ if method == "hook.after_tool":
+ return {"action": "continue"}
+ if method == "hook.approve_tool":
+ return {"approved": True}
+ raise KeyError(f"method not found: {method}")
+
+
+def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
+ payload: dict[str, Any] = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ }
+ if error is not None:
+ payload["error"] = {"code": -32000, "message": error}
+ else:
+ payload["result"] = result if result is not None else {}
+
+ sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
+ sys.stdout.flush()
+
+
+def main() -> int:
+ for raw_line in sys.stdin:
+ line = raw_line.strip()
+ if not line:
+ continue
+
+ try:
+ message = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ method = message.get("method")
+ message_id = message.get("id", 0)
+ params = message.get("params") or {}
+
+ if not message_id:
+ continue
+
+ try:
+ result = handle_request(str(method or ""), params)
+ send_response(int(message_id), result=result)
+ except KeyError as exc:
+ send_response(int(message_id), error=str(exc))
+ except Exception as exc:
+ send_response(int(message_id), error=f"unexpected error: {exc}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
+ signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
+ raise SystemExit(main())
+```
+
+### 2. Configure PicoClaw
+
+Add hook configuration in the config file:
+
+```json
+{
+ "hooks": {
+ "enabled": true,
+ "processes": {
+ "weather_plugin": {
+ "enabled": true,
+ "priority": 100,
+ "transport": "stdio",
+ "command": ["python3", "/tmp/weather_plugin.py"],
+ "intercept": ["before_llm", "before_tool"]
+ }
+ }
+ }
+}
+```
+
+### 3. Test Results
+
+When user asks "What's the weather in Beijing today?":
+
+1. PicoClaw sends `hook.before_llm`, hook injects `get_weather` tool definition
+2. LLM sees tool definition, decides to call `get_weather(city="Beijing")`
+3. PicoClaw sends `hook.before_tool`, hook uses `respond` action to return weather data
+4. LLM receives result, replies to user "Beijing is sunny today, temperature 15°C"
+
+---
+
+## Flow Diagram
+
+```
+User: "What's the weather in Beijing today?"
+ ↓
+ PicoClaw
+ ↓
+ hook.before_llm
+ ↓ (inject get_weather tool definition)
+ LLM request
+ ↓
+ LLM decides to call get_weather(city="Beijing")
+ ↓
+ hook.before_tool
+ ↓ (respond action returns weather data)
+ Return result directly to LLM
+ ↓ (skip ToolRegistry)
+ LLM replies: "Beijing is sunny today, temperature 15°C"
+```
+
+---
+
+## Key Points
+
+### `before_llm` Inject Tool Definition
+
+Tool definition follows OpenAI function calling format:
+
+```json
+{
+ "type": "function",
+ "function": {
+ "name": "tool_name",
+ "description": "tool description",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "param_name": {
+ "type": "string",
+ "description": "parameter description"
+ }
+ },
+ "required": ["list of required parameters"]
+ }
+ }
+}
+```
+
+### `before_tool` Use respond Action
+
+`respond` action response format:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "Content returned to LLM",
+ "for_user": "Optional, content sent to user",
+ "silent": false,
+ "is_error": false,
+ "media": ["Optional, media reference list"],
+ "response_handled": false
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `for_llm` | Required, LLM will see this content |
+| `for_user` | Optional, sent directly to user |
+| `silent` | When true, not sent to user |
+| `is_error` | When true, indicates execution failure |
+| `media` | Optional, media file references (images, files, etc.) |
+| `response_handled` | When true, indicates user request is handled, turn will end |
+
+---
+
+## Media File Handling
+
+The `respond` action supports returning media files (images, files, etc.). There are two processing modes:
+
+### 1. Automatic Delivery (`response_handled=true`)
+
+When `response_handled=true`, media files are automatically sent to the user and the turn ends:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "Image sent to user",
+ "for_user": "",
+ "media": ["media://abc123"],
+ "response_handled": true
+ }
+}
+```
+
+Use cases:
+- Image generation plugin directly returning results
+- File download plugin sending files to user
+
+### 2. LLM Visible (`response_handled=false`)
+
+When `response_handled=false`, media references are passed to the LLM, which can see the content in the next request:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "Image loaded, path: /tmp/image.png [file:/tmp/image.png]",
+ "media": ["media://abc123"]
+ }
+}
+```
+
+After seeing the content, the LLM can decide:
+- Use `send_file` tool to send to user
+- Analyze image content and reply to user
+- Other processing approaches
+
+### Media Reference Format
+
+Media references use the `media://` protocol:
+
+```
+media://
+```
+
+These references are managed by PicoClaw's MediaStore and can be:
+- Sent to user via channel
+- Converted to base64 in LLM vision requests
+
+### Alternative: Use Existing Tools
+
+If the plugin generates files, you can return the file path and let the LLM call `send_file` or similar tools:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "Image generated, saved at /tmp/generated_image.png. Use send_file tool to send to user.",
+ "for_user": "",
+ "silent": false
+ }
+}
+```
+
+This approach:
+- More decoupled, LLM decides when to send
+- Leverages existing tool mechanisms
+- Supports batch sending, delayed sending, etc.
+
+---
+
+## Multi-Tool Injection Example
+
+Multiple tools can be injected simultaneously:
+
+```python
+def handle_before_llm(params: dict) -> dict:
+ tools = params.get("tools", [])
+
+ # Tool 1: Weather query
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Query city weather",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string", "description": "City name"}
+ },
+ "required": ["city"]
+ }
+ }
+ })
+
+ # Tool 2: Calculator
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "calculate",
+ "description": "Perform mathematical calculations",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "expression": {"type": "string", "description": "Mathematical expression"}
+ },
+ "required": ["expression"]
+ }
+ }
+ })
+
+ return {
+ "action": "modify",
+ "request": {
+ "model": params.get("model"),
+ "messages": params.get("messages", []),
+ "tools": tools,
+ "options": params.get("options", {}),
+ }
+ }
+
+
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+ args = params.get("arguments", {})
+
+ if tool == "get_weather":
+ return {
+ "action": "respond",
+ "result": get_weather(args.get("city", "")),
+ }
+
+ if tool == "calculate":
+ # Simple calculation example
+ try:
+ expr = args.get("expression", "")
+ result = eval(expr) # Note: needs security handling in actual use
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": f"Calculation result: {result}",
+ "silent": False,
+ "is_error": False,
+ },
+ }
+ except Exception as e:
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": f"Calculation error: {e}",
+ "silent": False,
+ "is_error": True,
+ },
+ }
+
+ return {"action": "continue"}
+```
+
+---
+
+## Coexistence with Built-in Tools
+
+Injected plugin tools coexist with PicoClaw built-in tools:
+
+- Built-in tools (like `bash`, `read_file`) execute normally through ToolRegistry
+- Plugin tools return results through hook's `respond` action
+- `handle_before_tool` only handles plugin tools, other tools return `continue`
+
+---
+
+## Go In-Process Hook Example
+
+If you need to implement plugin tool injection in Go code:
+
+```go
+package myhooks
+
+import (
+ "context"
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+type WeatherPluginHook struct{}
+
+func (h *WeatherPluginHook) BeforeLLM(
+ ctx context.Context,
+ req *agent.LLMHookRequest,
+) (*agent.LLMHookRequest, agent.HookDecision, error) {
+ // Inject tool definition
+ req.Tools = append(req.Tools, agent.ToolDefinition{
+ Type: "function",
+ Function: agent.FunctionDefinition{
+ Name: "get_weather",
+ Description: "Query city weather",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "city": map[string]any{
+ "type": "string",
+ "description": "City name",
+ },
+ },
+ "required": []string{"city"},
+ },
+ },
+ })
+
+ return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
+}
+
+func (h *WeatherPluginHook) BeforeTool(
+ ctx context.Context,
+ call *agent.ToolCallHookRequest,
+) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
+ if call.Tool == "get_weather" {
+ city := call.Arguments["city"].(string)
+
+ // Set HookResult, use respond action
+ next := call.Clone()
+ next.HookResult = &tools.ToolResult{
+ ForLLM: getWeatherData(city),
+ Silent: false,
+ IsError: false,
+ }
+
+ return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
+ }
+
+ return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
+}
+
+func getWeatherData(city string) string {
+ // Implement weather query logic
+ return fmt.Sprintf("%s weather: Sunny, temperature 20°C", city)
+}
+```
+
+---
+
+## Summary
+
+Through the hook system's `respond` action, external processes can:
+
+1. **Inject tool definitions**: Let LLM know new tools are available
+2. **Provide tool implementation**: Return execution results directly, no need to register in ToolRegistry
+3. **Coexist with built-in tools**: Does not affect normal operation of PicoClaw's original tools
+
+This provides a flexible and elegant solution for plugin development.
+
+---
+
+## Security Boundaries
+
+### Bypassing Approval Checks
+
+**Important**: The `respond` action bypasses `ApproveTool` approval checks.
+
+This means:
+- A `before_tool` hook can return `respond` for **any tool name**, including sensitive tools (like `bash`)
+- The tool won't go through the approval process, directly returning the hook-provided result
+- This is designed for plugin tools but introduces security risks
+
+### Security Recommendations
+
+1. **Review hook configuration**: Ensure only trusted hook processes are enabled
+2. **Limit hook scope**: Add your own security checks in hook implementation
+3. **Use `deny_tool` for rejection**: Use `deny_tool` action instead of `respond` with error for denying execution
+
+### Example: Hook-Internal Security Check
+
+```python
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+ args = params.get("arguments", {})
+
+ # Security check: only handle plugin tools
+ if tool in ["get_weather", "calculate"]:
+ return {
+ "action": "respond",
+ "result": execute_plugin_tool(tool, args),
+ }
+
+ # Other tools continue normal flow (will go through approval)
+ return {"action": "continue"}
+```
+
+This ensures the hook only affects plugin tools, not system tool approval flow.
\ No newline at end of file
diff --git a/docs/hooks/plugin-tool-injection.zh.md b/docs/hooks/plugin-tool-injection.zh.md
new file mode 100644
index 000000000..ccc7ff7f6
--- /dev/null
+++ b/docs/hooks/plugin-tool-injection.zh.md
@@ -0,0 +1,587 @@
+# 插件工具注入示例
+
+本文档展示如何利用 PicoClaw 的 hook 系统实现外部插件工具注入,让 LLM 能调用由外部 hook 进程实现的工具。
+
+---
+
+## 核心原理
+
+通过 hook 系统的 `respond` action,外部 hook 可以:
+
+1. 在 `before_llm` 中注入工具**定义**,让 LLM 知道有这个工具可用
+2. 在 `before_tool` 中使用 `respond` action 直接返回工具**执行结果**,跳过 ToolRegistry
+
+这样,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具。
+
+---
+
+## 完整示例:天气查询插件
+
+下面是一个完整的 Python hook 示例,实现一个天气查询插件工具。
+
+### 1. Hook 脚本实现
+
+保存为 `/tmp/weather_plugin.py`:
+
+```python
+#!/usr/bin/env python3
+"""天气查询插件 hook 示例"""
+from __future__ import annotations
+
+import json
+import sys
+import signal
+from typing import Any
+
+# 模拟天气数据
+WEATHER_DATA = {
+ "北京": {"temp": 15, "weather": "晴", "humidity": 45},
+ "上海": {"temp": 18, "weather": "多云", "humidity": 60},
+ "广州": {"temp": 25, "weather": "晴", "humidity": 70},
+ "深圳": {"temp": 26, "weather": "多云", "humidity": 75},
+}
+
+
+def get_weather(city: str) -> dict:
+ """获取天气数据(模拟)"""
+ data = WEATHER_DATA.get(city)
+ if data:
+ return {
+ "for_llm": f"{city}天气:{data['weather']},温度{data['temp']}°C,湿度{data['humidity']}%",
+ "for_user": "",
+ "silent": False,
+ "is_error": False,
+ }
+ return {
+ "for_llm": f"未找到城市 {city} 的天气数据",
+ "for_user": "",
+ "silent": False,
+ "is_error": True,
+ }
+
+
+def handle_hello(params: dict) -> dict:
+ return {"ok": True, "name": "weather-plugin"}
+
+
+def handle_before_llm(params: dict) -> dict:
+ """注入天气查询工具定义"""
+ tools = params.get("tools", [])
+
+ # 添加天气查询工具
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "查询指定城市的天气信息",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string",
+ "description": "城市名称,如:北京、上海、广州"
+ }
+ },
+ "required": ["city"]
+ }
+ }
+ })
+
+ return {
+ "action": "modify",
+ "request": {
+ "model": params.get("model"),
+ "messages": params.get("messages", []),
+ "tools": tools,
+ "options": params.get("options", {}),
+ }
+ }
+
+
+def handle_before_tool(params: dict) -> dict:
+ """处理工具调用,直接返回结果"""
+ tool = params.get("tool", "")
+ args = params.get("arguments", {})
+
+ if tool == "get_weather":
+ city = args.get("city", "")
+ result = get_weather(city)
+
+ # 使用 respond action 直接返回结果,跳过 ToolRegistry
+ return {
+ "action": "respond",
+ "result": result,
+ }
+
+ # 其他工具继续正常流程
+ return {"action": "continue"}
+
+
+def handle_request(method: str, params: dict) -> dict:
+ if method == "hook.hello":
+ return handle_hello(params)
+ if method == "hook.before_llm":
+ return handle_before_llm(params)
+ if method == "hook.before_tool":
+ return handle_before_tool(params)
+ if method == "hook.after_llm":
+ return {"action": "continue"}
+ if method == "hook.after_tool":
+ return {"action": "continue"}
+ if method == "hook.approve_tool":
+ return {"approved": True}
+ raise KeyError(f"method not found: {method}")
+
+
+def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
+ payload: dict[str, Any] = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ }
+ if error is not None:
+ payload["error"] = {"code": -32000, "message": error}
+ else:
+ payload["result"] = result if result is not None else {}
+
+ sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
+ sys.stdout.flush()
+
+
+def main() -> int:
+ for raw_line in sys.stdin:
+ line = raw_line.strip()
+ if not line:
+ continue
+
+ try:
+ message = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ method = message.get("method")
+ message_id = message.get("id", 0)
+ params = message.get("params") or {}
+
+ if not message_id:
+ continue
+
+ try:
+ result = handle_request(str(method or ""), params)
+ send_response(int(message_id), result=result)
+ except KeyError as exc:
+ send_response(int(message_id), error=str(exc))
+ except Exception as exc:
+ send_response(int(message_id), error=f"unexpected error: {exc}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
+ signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
+ raise SystemExit(main())
+```
+
+### 2. 配置 PicoClaw
+
+在配置文件中添加 hook 配置:
+
+```json
+{
+ "hooks": {
+ "enabled": true,
+ "processes": {
+ "weather_plugin": {
+ "enabled": true,
+ "priority": 100,
+ "transport": "stdio",
+ "command": ["python3", "/tmp/weather_plugin.py"],
+ "intercept": ["before_llm", "before_tool"]
+ }
+ }
+ }
+}
+```
+
+### 3. 测试效果
+
+当用户问"北京今天天气怎么样?"时:
+
+1. PicoClaw 发送 `hook.before_llm`,hook 注入 `get_weather` 工具定义
+2. LLM 看到工具定义,决定调用 `get_weather(city="北京")`
+3. PicoClaw 发送 `hook.before_tool`,hook 使用 `respond` action 返回天气数据
+4. LLM 收到结果,回复用户"北京今天晴天,温度15°C"
+
+---
+
+## 流程图解
+
+```
+用户: "北京今天天气怎么样?"
+ ↓
+ PicoClaw
+ ↓
+ hook.before_llm
+ ↓ (注入 get_weather 工具定义)
+ LLM 请求
+ ↓
+ LLM 决定调用 get_weather(city="北京")
+ ↓
+ hook.before_tool
+ ↓ (respond action 返回天气数据)
+ 直接返回结果给 LLM
+ ↓ (跳过 ToolRegistry)
+ LLM 回复: "北京今天晴天,温度15°C"
+```
+
+---
+
+## 关键点说明
+
+### `before_llm` 注入工具定义
+
+工具定义遵循 OpenAI function calling 格式:
+
+```json
+{
+ "type": "function",
+ "function": {
+ "name": "工具名称",
+ "description": "工具描述",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "参数名": {
+ "type": "string",
+ "description": "参数描述"
+ }
+ },
+ "required": ["必需参数列表"]
+ }
+ }
+}
+```
+
+### `before_tool` 使用 respond action
+
+`respond` action 的响应格式:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "返回给 LLM 的内容",
+ "for_user": "可选,发送给用户的内容",
+ "silent": false,
+ "is_error": false,
+ "media": ["可选,媒体引用列表"],
+ "response_handled": false
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `for_llm` | 必须,LLM 会看到这个内容 |
+| `for_user` | 可选,直接发送给用户 |
+| `silent` | 为 true 时不发送给用户 |
+| `is_error` | 为 true 时表示执行失败 |
+| `media` | 可选,媒体文件引用列表(如图片、文件) |
+| `response_handled` | 为 true 时表示已处理用户请求,轮次将结束 |
+
+---
+
+## 媒体文件处理
+
+`respond` action 支持返回媒体文件(图片、文件等)。有两种处理方式:
+
+### 1. 自动发送(`response_handled=true`)
+
+当 `response_handled=true` 时,媒体文件会自动发送给用户,轮次结束:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "图片已发送给用户",
+ "for_user": "",
+ "media": ["media://abc123"],
+ "response_handled": true
+ }
+}
+```
+
+适用场景:
+- 图像生成插件直接返回结果
+- 文件下载插件发送文件给用户
+
+### 2. LLM 可见(`response_handled=false`)
+
+当 `response_handled=false` 时,媒体引用会传递给 LLM,LLM 可以在下一轮请求中看到内容:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "图片已加载,路径:/tmp/image.png [file:/tmp/image.png]",
+ "media": ["media://abc123"]
+ }
+}
+```
+
+LLM 看到内容后,可以自主决定:
+- 使用 `send_file` 工具发送给用户
+- 分析图片内容并回复用户
+- 其他处理方式
+
+### 媒体引用格式
+
+媒体引用使用 `media://` 协议:
+
+```
+media://
+```
+
+这些引用由 PicoClaw 的 MediaStore 管理,可以:
+- 通过 channel 发送给用户
+- 在 LLM vision 请求中转换为 base64
+
+### 替代方案:使用现有工具
+
+如果插件生成文件,可以返回文件路径让 LLM 调用 `send_file` 等工具:
+
+```json
+{
+ "action": "respond",
+ "result": {
+ "for_llm": "图片已生成,保存在 /tmp/generated_image.png。使用 send_file 工具发送给用户。",
+ "for_user": "",
+ "silent": false
+ }
+}
+```
+
+这种方式:
+- 更解耦,LLM 自主决策发送时机
+- 利用现有工具机制
+- 支持批量发送、延迟发送等场景
+
+---
+
+## 多工具注入示例
+
+可以同时注入多个工具:
+
+```python
+def handle_before_llm(params: dict) -> dict:
+ tools = params.get("tools", [])
+
+ # 工具1:天气查询
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "查询城市天气",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string", "description": "城市名称"}
+ },
+ "required": ["city"]
+ }
+ }
+ })
+
+ # 工具2:计算器
+ tools.append({
+ "type": "function",
+ "function": {
+ "name": "calculate",
+ "description": "执行数学计算",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "expression": {"type": "string", "description": "数学表达式"}
+ },
+ "required": ["expression"]
+ }
+ }
+ })
+
+ return {
+ "action": "modify",
+ "request": {
+ "model": params.get("model"),
+ "messages": params.get("messages", []),
+ "tools": tools,
+ "options": params.get("options", {}),
+ }
+ }
+
+
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+ args = params.get("arguments", {})
+
+ if tool == "get_weather":
+ return {
+ "action": "respond",
+ "result": get_weather(args.get("city", "")),
+ }
+
+ if tool == "calculate":
+ # 简单计算示例
+ try:
+ expr = args.get("expression", "")
+ result = eval(expr) # 注意:实际使用时需要安全处理
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": f"计算结果: {result}",
+ "silent": False,
+ "is_error": False,
+ },
+ }
+ except Exception as e:
+ return {
+ "action": "respond",
+ "result": {
+ "for_llm": f"计算错误: {e}",
+ "silent": False,
+ "is_error": True,
+ },
+ }
+
+ return {"action": "continue"}
+```
+
+---
+
+## 与内置工具共存
+
+注入的插件工具与 PicoClaw 内置工具共存:
+
+- 内置工具(如 `bash`、`read_file`)正常通过 ToolRegistry 执行
+- 插件工具通过 hook 的 `respond` action 返回结果
+- `handle_before_tool` 中只处理插件工具,其他工具返回 `continue`
+
+---
+
+## Go 进程内 Hook 示例
+
+如果需要在 Go 代码中实现插件工具注入:
+
+```go
+package myhooks
+
+import (
+ "context"
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+type WeatherPluginHook struct{}
+
+func (h *WeatherPluginHook) BeforeLLM(
+ ctx context.Context,
+ req *agent.LLMHookRequest,
+) (*agent.LLMHookRequest, agent.HookDecision, error) {
+ // 注入工具定义
+ req.Tools = append(req.Tools, agent.ToolDefinition{
+ Type: "function",
+ Function: agent.FunctionDefinition{
+ Name: "get_weather",
+ Description: "查询城市天气",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "city": map[string]any{
+ "type": "string",
+ "description": "城市名称",
+ },
+ },
+ "required": []string{"city"},
+ },
+ },
+ })
+
+ return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
+}
+
+func (h *WeatherPluginHook) BeforeTool(
+ ctx context.Context,
+ call *agent.ToolCallHookRequest,
+) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
+ if call.Tool == "get_weather" {
+ city := call.Arguments["city"].(string)
+
+ // 设置 HookResult,使用 respond action
+ next := call.Clone()
+ next.HookResult = &tools.ToolResult{
+ ForLLM: getWeatherData(city),
+ Silent: false,
+ IsError: false,
+ }
+
+ return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
+ }
+
+ return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
+}
+
+func getWeatherData(city string) string {
+ // 实现天气查询逻辑
+ return fmt.Sprintf("%s天气:晴,温度20°C", city)
+}
+```
+
+---
+
+## 总结
+
+通过 hook 系统的 `respond` action,外部进程可以:
+
+1. **注入工具定义**:让 LLM 知道有新工具可用
+2. **提供工具实现**:直接返回执行结果,无需注册到 ToolRegistry
+3. **与内置工具共存**:不影响 PicoClaw 原有工具的正常运行
+
+这为插件开发提供了灵活、优雅的解决方案。
+
+---
+
+## 安全边界说明
+
+### 绕过审批检查
+
+**重要**:`respond` action 会绕过 `ApproveTool` 审批检查。
+
+这意味着:
+- `before_tool` hook 可以为**任何工具名称**返回 `respond`,包括敏感工具(如 `bash`)
+- 工具不会经过审批流程,直接返回 hook 提供的结果
+- 这是为了支持插件工具而设计,但也带来了安全风险
+
+### 安全建议
+
+1. **审查 hook 配置**:确保只有可信的 hook 进程被启用
+2. **限制 hook 权限**:在 hook 实现中添加自己的安全检查
+3. **优先使用 `deny_tool`**:对于拒绝执行,使用 `deny_tool` action 而非 `respond` 返回错误
+
+### 示例:hook 内置安全检查
+
+```python
+def handle_before_tool(params: dict) -> dict:
+ tool = params.get("tool", "")
+ args = params.get("arguments", {})
+
+ # 安全检查:只处理插件工具
+ if tool in ["get_weather", "calculate"]:
+ return {
+ "action": "respond",
+ "result": execute_plugin_tool(tool, args),
+ }
+
+ # 其他工具继续正常流程(会经过审批)
+ return {"action": "continue"}
+```
+
+这样可以确保 hook 只影响插件工具,不影响系统工具的审批流程。
\ No newline at end of file
diff --git a/go.mod b/go.mod
index a9f4bb7cb..b7259bde7 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/sipeed/picoclaw
-go 1.25.8
+go 1.25.9
require (
fyne.io/systray v1.12.0
@@ -9,12 +9,12 @@ require (
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/atc0005/go-teams-notify/v2 v2.14.0
- github.com/atotto/clipboard v0.1.4
github.com/aws/aws-sdk-go-v2 v1.41.5
- github.com/aws/aws-sdk-go-v2/config v1.32.12
+ github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
@@ -26,8 +26,9 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
- github.com/modelcontextprotocol/go-sdk v1.4.1
- github.com/mymmrac/telego v1.7.0
+ github.com/muesli/termenv v0.16.0
+ github.com/modelcontextprotocol/go-sdk v1.5.0
+ github.com/mymmrac/telego v1.8.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.1
@@ -36,6 +37,7 @@ require (
github.com/rs/zerolog v1.35.0
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
+ github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/util v0.9.7
@@ -46,7 +48,7 @@ require (
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.4
- modernc.org/sqlite v1.48.0
+ modernc.org/sqlite v1.48.2
rsc.io/qr v0.2.0
)
@@ -54,19 +56,24 @@ require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
- github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/x/ansi v0.8.0 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -80,6 +87,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
@@ -89,10 +97,10 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
- github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
@@ -131,7 +139,7 @@ require (
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
- golang.org/x/sys v0.42.0
+ golang.org/x/sys v0.43.0
)
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532
diff --git a/go.sum b/go.sum
index 765a3211a..8306976c4 100644
--- a/go.sum
+++ b/go.sum
@@ -23,18 +23,16 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
-github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
-github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
-github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
-github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
+github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
+github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
@@ -45,18 +43,20 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ7
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -69,6 +69,16 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -117,8 +127,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
-github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -181,16 +191,20 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
-github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
-github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
-github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
-github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
+github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
+github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
+github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -220,6 +234,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -278,6 +293,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@@ -375,8 +392,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -458,8 +475,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
-modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
+modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
+modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go
index a2e09095a..327c6162a 100644
--- a/pkg/agent/context_seahorse.go
+++ b/pkg/agent/context_seahorse.go
@@ -1,4 +1,4 @@
-//go:build !mipsle && !netbsd
+//go:build !mipsle && !netbsd && !(freebsd && arm)
package agent
diff --git a/pkg/agent/context_seahorse_unsupported.go b/pkg/agent/context_seahorse_unsupported.go
index 882a973b9..7528f79bc 100644
--- a/pkg/agent/context_seahorse_unsupported.go
+++ b/pkg/agent/context_seahorse_unsupported.go
@@ -1,4 +1,4 @@
-//go:build mipsle || netbsd
+//go:build mipsle || netbsd || (freebsd && arm)
package agent
diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go
index e5632913d..ace95f44d 100644
--- a/pkg/agent/hook_process.go
+++ b/pkg/agent/hook_process.go
@@ -12,7 +12,9 @@ import (
"sync/atomic"
"time"
+ "github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/tools"
)
const (
@@ -90,7 +92,8 @@ type processHookAfterLLMResponse struct {
type processHookBeforeToolResponse struct {
processHookDecisionResponse
- Call *ToolCallHookRequest `json:"call,omitempty"`
+ Call *ToolCallHookRequest `json:"call,omitempty"`
+ Result *tools.ToolResult `json:"result,omitempty"` // Result returned directly by hook (for respond action)
}
type processHookAfterToolResponse struct {
@@ -120,7 +123,9 @@ func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (
if err != nil {
return nil, fmt.Errorf("create process hook stderr: %w", err)
}
- if err := cmd.Start(); err != nil {
+ // Route hook subprocess startup through the shared isolation entry point so
+ // process hooks inherit the same isolation behavior as other child processes.
+ if err := isolation.Start(cmd); err != nil {
return nil, fmt.Errorf("start process hook: %w", err)
}
@@ -241,6 +246,10 @@ func (ph *ProcessHook) BeforeTool(
if resp.Call == nil {
resp.Call = call
}
+ // If hook returned a Result, carry it in ToolCallHookRequest
+ if resp.Result != nil {
+ resp.Call.HookResult = resp.Result
+ }
return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
}
diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go
index 50f89811f..9e95d105e 100644
--- a/pkg/agent/hook_process_test.go
+++ b/pkg/agent/hook_process_test.go
@@ -7,10 +7,13 @@ import (
"fmt"
"os"
"path/filepath"
+ "runtime"
"strings"
"testing"
"time"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -178,6 +181,76 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) {
}
}
+func TestAgentLoop_MountProcessHook_IsolationSupportsRelativeDirAndCommand(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("linux-only isolation path handling")
+ }
+
+ provider := &llmHookTestProvider{}
+ al, agent, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ root := t.TempDir()
+ t.Setenv(config.EnvHome, filepath.Join(root, "picoclaw-home"))
+ binDir := filepath.Join(root, "bin")
+ hookDir := filepath.Join(root, "hooks")
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(hookDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFakeBwrap(t, filepath.Join(binDir, "bwrap"))
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ linkTestBinary(t, os.Args[0], filepath.Join(hookDir, "hook-helper"))
+
+ cfg := config.DefaultConfig()
+ cfg.Isolation.Enabled = true
+ isolation.Configure(cfg)
+ t.Cleanup(func() { isolation.Configure(config.DefaultConfig()) })
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ relHookDir, err := filepath.Rel(cwd, hookDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ mountErr := al.MountProcessHook(context.Background(), "ipc-relative", ProcessHookOptions{
+ Command: []string{"./hook-helper", "-test.run=TestProcessHook_HelperProcess", "--"},
+ Dir: relHookDir,
+ Env: processHookHelperEnv("rewrite", ""),
+ InterceptLLM: true,
+ })
+ if mountErr != nil {
+ t.Fatalf("MountProcessHook failed with relative dir/command under isolation: %v", mountErr)
+ }
+
+ resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
+ SessionKey: "session-relative",
+ Channel: "cli",
+ ChatID: "direct",
+ UserMessage: "hello",
+ DefaultResponse: defaultResponse,
+ EnableSummary: false,
+ SendResponse: false,
+ })
+ if err != nil {
+ t.Fatalf("runAgentLoop failed: %v", err)
+ }
+ if resp != "provider content|ipc" {
+ t.Fatalf("expected process-hooked llm content, got %q", resp)
+ }
+ provider.mu.Lock()
+ lastModel := provider.lastModel
+ provider.mu.Unlock()
+ if lastModel != "process-model" {
+ t.Fatalf("expected process model, got %q", lastModel)
+ }
+}
+
func processHookHelperCommand() []string {
return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"}
}
@@ -193,6 +266,59 @@ func processHookHelperEnv(mode, eventLog string) []string {
return env
}
+func writeFakeBwrap(t *testing.T, path string) {
+ t.Helper()
+ script := `#!/bin/sh
+set -eu
+workdir=
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --)
+ shift
+ break
+ ;;
+ --chdir)
+ workdir="$2"
+ shift 2
+ ;;
+ --bind|--ro-bind)
+ shift 3
+ ;;
+ --proc|--dev)
+ shift 2
+ ;;
+ --die-with-parent|--unshare-ipc)
+ shift
+ ;;
+ *)
+ shift
+ ;;
+ esac
+done
+if [ -n "$workdir" ]; then
+ cd "$workdir"
+fi
+exec "$@"
+`
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
+ t.Fatalf("write fake bwrap: %v", err)
+ }
+}
+
+func linkTestBinary(t *testing.T, source, target string) {
+ t.Helper()
+ if err := os.Symlink(source, target); err == nil {
+ return
+ }
+ data, err := os.ReadFile(source)
+ if err != nil {
+ t.Fatalf("read test binary: %v", err)
+ }
+ if err := os.WriteFile(target, data, 0o755); err != nil {
+ t.Fatalf("create hook helper binary: %v", err)
+ }
+}
+
func waitForFileContains(t *testing.T, path, substring string) {
t.Helper()
diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go
index 0e0c139ae..687e54532 100644
--- a/pkg/agent/hooks.go
+++ b/pkg/agent/hooks.go
@@ -25,6 +25,7 @@ type HookAction string
const (
HookActionContinue HookAction = "continue"
HookActionModify HookAction = "modify"
+ HookActionRespond HookAction = "respond" // Return result directly, skip tool execution. SECURITY: This bypasses ApproveTool checks, allowing hooks to return results for any tool (including sensitive ones like bash) without approval. Use with caution.
HookActionDenyTool HookAction = "deny_tool"
HookActionAbortTurn HookAction = "abort_turn"
HookActionHardAbort HookAction = "hard_abort"
@@ -129,10 +130,13 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse {
}
type ToolCallHookRequest struct {
- Meta EventMeta `json:"meta"`
- Context *TurnContext `json:"context,omitempty"`
- Tool string `json:"tool"`
- Arguments map[string]any `json:"arguments,omitempty"`
+ Meta EventMeta `json:"meta"`
+ Context *TurnContext `json:"context,omitempty"`
+ Tool string `json:"tool"`
+ Arguments map[string]any `json:"arguments,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ ChatID string `json:"chat_id,omitempty"`
+ HookResult *tools.ToolResult `json:"hook_result,omitempty"` // Result returned directly by hook (for respond action). Media is supported - see Media handling section in docs.
}
func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
@@ -143,6 +147,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
cloned.Meta = cloneEventMeta(r.Meta)
cloned.Context = cloneTurnContext(r.Context)
cloned.Arguments = cloneStringAnyMap(r.Arguments)
+ cloned.HookResult = cloneToolResult(r.HookResult)
return &cloned
}
@@ -387,6 +392,10 @@ func (hm *HookManager) BeforeTool(
if next != nil {
current = next
}
+ case HookActionRespond:
+ // Hook returns result directly, skip tool execution
+ // Carry HookResult in ToolCallHookRequest and return
+ return next, decision
case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort:
return current, decision
default:
@@ -798,6 +807,13 @@ func cloneToolResult(result *tools.ToolResult) *tools.ToolResult {
if len(result.Media) > 0 {
cloned.Media = append([]string(nil), result.Media...)
}
+ if len(result.ArtifactTags) > 0 {
+ cloned.ArtifactTags = append([]string(nil), result.ArtifactTags...)
+ }
+ if len(result.Messages) > 0 {
+ cloned.Messages = make([]providers.Message, len(result.Messages))
+ copy(cloned.Messages, result.Messages)
+ }
return &cloned
}
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 6f61da65a..6979fbf1e 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -2,6 +2,7 @@ package agent
import (
"context"
+ "errors"
"os"
"sync"
"testing"
@@ -392,3 +393,517 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) {
t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason)
}
}
+
+// respondHook is a test hook for testing HookActionRespond functionality
+type respondHook struct {
+ respondTools map[string]bool // tool names to respond to
+}
+
+func (h *respondHook) BeforeTool(
+ ctx context.Context,
+ call *ToolCallHookRequest,
+) (*ToolCallHookRequest, HookDecision, error) {
+ if h.respondTools[call.Tool] {
+ next := call.Clone()
+ next.HookResult = &tools.ToolResult{
+ ForLLM: "hook-responded: " + call.Tool,
+ ForUser: "",
+ Silent: false,
+ IsError: false,
+ }
+ return next, HookDecision{Action: HookActionRespond}, nil
+ }
+ return call, HookDecision{Action: HookActionContinue}, nil
+}
+
+func (h *respondHook) AfterTool(
+ ctx context.Context,
+ result *ToolResultHookResponse,
+) (*ToolResultHookResponse, HookDecision, error) {
+ // Should not be called since respond skips tool execution
+ return result, HookDecision{Action: HookActionContinue}, nil
+}
+
+func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) {
+ provider := &toolHookProvider{}
+ al, agent, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ al.RegisterTool(&echoTextTool{})
+ if err := al.MountHook(NamedHook("respond-hook", &respondHook{
+ respondTools: map[string]bool{"echo_text": true},
+ })); err != nil {
+ t.Fatalf("MountHook failed: %v", err)
+ }
+
+ sub := al.SubscribeEvents(16)
+ defer al.UnsubscribeEvents(sub.ID)
+
+ resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
+ SessionKey: "session-1",
+ Channel: "cli",
+ ChatID: "direct",
+ UserMessage: "run tool",
+ DefaultResponse: defaultResponse,
+ EnableSummary: false,
+ SendResponse: false,
+ })
+ if err != nil {
+ t.Fatalf("runAgentLoop failed: %v", err)
+ }
+
+ // Verify response comes from hook, not tool
+ expected := "hook-responded: echo_text"
+ if resp != expected {
+ t.Fatalf("expected %q, got %q", expected, resp)
+ }
+
+ // Verify event stream has ToolExecEnd, not actual tool execution
+ events := collectEventStream(sub.C)
+ endEvt, ok := findEvent(events, EventKindToolExecEnd)
+ if !ok {
+ t.Fatal("expected tool exec end event")
+ }
+ payload, ok := endEvt.Payload.(ToolExecEndPayload)
+ if !ok {
+ t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
+ }
+ if payload.Tool != "echo_text" {
+ t.Fatalf("expected tool echo_text, got %q", payload.Tool)
+ }
+ if payload.ForLLMLen != len(expected) {
+ t.Fatalf("expected ForLLMLen %d, got %d", len(expected), payload.ForLLMLen)
+ }
+}
+
+// denyToolHook tests HookActionDenyTool functionality
+type denyToolHook struct {
+ denyTools map[string]bool
+}
+
+func (h *denyToolHook) BeforeTool(
+ ctx context.Context,
+ call *ToolCallHookRequest,
+) (*ToolCallHookRequest, HookDecision, error) {
+ if h.denyTools[call.Tool] {
+ return call, HookDecision{Action: HookActionDenyTool, Reason: "tool denied by hook"}, nil
+ }
+ return call, HookDecision{Action: HookActionContinue}, nil
+}
+
+func (h *denyToolHook) AfterTool(
+ ctx context.Context,
+ result *ToolResultHookResponse,
+) (*ToolResultHookResponse, HookDecision, error) {
+ return result, HookDecision{Action: HookActionContinue}, nil
+}
+
+func TestAgentLoop_Hooks_ToolDenyAction(t *testing.T) {
+ provider := &toolHookProvider{}
+ al, agent, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ al.RegisterTool(&echoTextTool{})
+ if err := al.MountHook(NamedHook("deny-hook", &denyToolHook{
+ denyTools: map[string]bool{"echo_text": true},
+ })); err != nil {
+ t.Fatalf("MountHook failed: %v", err)
+ }
+
+ resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
+ SessionKey: "session-1",
+ Channel: "cli",
+ ChatID: "direct",
+ UserMessage: "run tool",
+ DefaultResponse: defaultResponse,
+ EnableSummary: false,
+ SendResponse: false,
+ })
+ if err != nil {
+ t.Fatalf("runAgentLoop failed: %v", err)
+ }
+
+ expected := "Tool execution denied by hook: tool denied by hook"
+ if resp != expected {
+ t.Fatalf("expected %q, got %q", expected, resp)
+ }
+}
+
+func TestHookManager_BeforeTool_RespondAction(t *testing.T) {
+ hm := NewHookManager(nil)
+ defer hm.Close()
+
+ hook := &respondHook{
+ respondTools: map[string]bool{"test_tool": true},
+ }
+ if err := hm.Mount(NamedHook("respond-test", hook)); err != nil {
+ t.Fatalf("mount hook: %v", err)
+ }
+
+ req := &ToolCallHookRequest{
+ Tool: "test_tool",
+ Arguments: map[string]any{"arg": "value"},
+ }
+ result, decision := hm.BeforeTool(context.Background(), req)
+
+ if decision.Action != HookActionRespond {
+ t.Fatalf("expected action %q, got %q", HookActionRespond, decision.Action)
+ }
+
+ if result.HookResult == nil {
+ t.Fatal("expected HookResult to be set")
+ }
+ if result.HookResult.ForLLM != "hook-responded: test_tool" {
+ t.Fatalf("unexpected HookResult.ForLLM: %q", result.HookResult.ForLLM)
+ }
+}
+
+type respondWithMediaHook struct {
+ respondTools map[string]bool
+ media []string
+ responseHandled bool
+ forLLM string
+}
+
+func (h *respondWithMediaHook) BeforeTool(
+ ctx context.Context,
+ call *ToolCallHookRequest,
+) (*ToolCallHookRequest, HookDecision, error) {
+ if h.respondTools[call.Tool] {
+ next := call.Clone()
+ next.HookResult = &tools.ToolResult{
+ ForLLM: h.forLLM,
+ ForUser: "media result",
+ Media: h.media,
+ ResponseHandled: h.responseHandled,
+ Silent: false,
+ IsError: false,
+ }
+ return next, HookDecision{Action: HookActionRespond}, nil
+ }
+ return call, HookDecision{Action: HookActionContinue}, nil
+}
+
+func (h *respondWithMediaHook) AfterTool(
+ ctx context.Context,
+ result *ToolResultHookResponse,
+) (*ToolResultHookResponse, HookDecision, error) {
+ return result, HookDecision{Action: HookActionContinue}, nil
+}
+
+type errorMediaChannel struct {
+ fakeChannel
+ sendErr error
+}
+
+func (f *errorMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
+ return nil, f.sendErr
+}
+
+func TestAgentLoop_HookRespond_MediaError(t *testing.T) {
+ provider := &multiToolProvider{
+ toolCalls: []providers.ToolCall{
+ {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}},
+ },
+ finalContent: "done",
+ }
+ al, agent, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ hook := &respondWithMediaHook{
+ respondTools: map[string]bool{"media_tool": true},
+ media: []string{"media://test/image.png"},
+ responseHandled: true,
+ forLLM: "media sent successfully",
+ }
+ if err := al.MountHook(NamedHook("media-hook", hook)); err != nil {
+ t.Fatalf("MountHook failed: %v", err)
+ }
+
+ al.channelManager = newStartedTestChannelManager(t, al.bus, al.mediaStore, "discord", &errorMediaChannel{
+ sendErr: errors.New("channel unavailable"),
+ })
+
+ sub := al.SubscribeEvents(16)
+ defer al.UnsubscribeEvents(sub.ID)
+
+ _, err := al.runAgentLoop(context.Background(), agent, processOptions{
+ SessionKey: "session-media-err",
+ Channel: "discord",
+ ChatID: "chat1",
+ UserMessage: "send media",
+ DefaultResponse: defaultResponse,
+ EnableSummary: false,
+ SendResponse: false,
+ })
+ if err != nil {
+ t.Fatalf("runAgentLoop failed: %v", err)
+ }
+
+ events := collectEventStream(sub.C)
+ endEvt, ok := findEvent(events, EventKindToolExecEnd)
+ if !ok {
+ t.Fatal("expected ToolExecEnd event")
+ }
+ payload, ok := endEvt.Payload.(ToolExecEndPayload)
+ if !ok {
+ t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
+ }
+
+ if !payload.IsError {
+ t.Fatal("expected IsError=true when SendMedia fails")
+ }
+
+ if payload.ForLLMLen < 30 {
+ t.Fatalf("expected ForLLM to contain error message, got ForLLMLen=%d", payload.ForLLMLen)
+ }
+}
+
+func TestAgentLoop_HookRespond_BusFallback(t *testing.T) {
+ provider := &multiToolProvider{
+ toolCalls: []providers.ToolCall{
+ {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}},
+ },
+ finalContent: "done",
+ }
+ al, agent, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ hook := &respondWithMediaHook{
+ respondTools: map[string]bool{"media_tool": true},
+ media: []string{"media://test/image.png"},
+ responseHandled: true,
+ forLLM: "media queued",
+ }
+ if err := al.MountHook(NamedHook("media-hook", hook)); err != nil {
+ t.Fatalf("MountHook failed: %v", err)
+ }
+
+ sub := al.SubscribeEvents(16)
+ defer al.UnsubscribeEvents(sub.ID)
+
+ resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
+ SessionKey: "session-bus-fallback",
+ Channel: "cli",
+ ChatID: "chat1",
+ UserMessage: "send media",
+ DefaultResponse: defaultResponse,
+ EnableSummary: false,
+ SendResponse: false,
+ })
+ if err != nil {
+ t.Fatalf("runAgentLoop failed: %v", err)
+ }
+
+ events := collectEventStream(sub.C)
+ endEvt, ok := findEvent(events, EventKindToolExecEnd)
+ if !ok {
+ t.Fatal("expected ToolExecEnd event")
+ }
+ payload, ok := endEvt.Payload.(ToolExecEndPayload)
+ if !ok {
+ t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
+ }
+
+ if payload.IsError {
+ t.Fatal("expected IsError=false for bus fallback (media queued, not delivered)")
+ }
+
+ if resp != "done" {
+ t.Fatalf("expected response 'done', got %q", resp)
+ }
+}
+
+type multiToolProvider struct {
+ mu sync.Mutex
+ callCount int
+ toolCalls []providers.ToolCall
+ finalContent string
+}
+
+func (p *multiToolProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ p.callCount++
+ if p.callCount == 1 && len(p.toolCalls) > 0 {
+ return &providers.LLMResponse{
+ ToolCalls: p.toolCalls,
+ }, nil
+ }
+
+ return &providers.LLMResponse{
+ Content: p.finalContent,
+ }, nil
+}
+
+func (p *multiToolProvider) GetDefaultModel() string {
+ return "multi-tool-provider"
+}
+
+func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
+ provider := &multiToolProvider{
+ toolCalls: []providers.ToolCall{
+ {ID: "call-1", Name: "tool_one", Arguments: map[string]any{}},
+ {ID: "call-2", Name: "tool_two", Arguments: map[string]any{}},
+ {ID: "call-3", Name: "tool_three", Arguments: map[string]any{}},
+ },
+ finalContent: "done",
+ }
+ al, _, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ tool1ExecCh := make(chan struct{}, 1)
+ al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond, execCh: tool1ExecCh})
+ al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond})
+
+ hook := &respondHook{
+ respondTools: map[string]bool{"tool_one": true},
+ }
+ if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil {
+ t.Fatalf("MountHook failed: %v", err)
+ }
+
+ sub := al.SubscribeEvents(32)
+ defer al.UnsubscribeEvents(sub.ID)
+
+ sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
+
+ type result struct {
+ resp string
+ err error
+ }
+ resultCh := make(chan result, 1)
+ go func() {
+ resp, err := al.ProcessDirectWithChannel(
+ context.Background(),
+ "run tools",
+ sessionKey,
+ "cli",
+ "chat1",
+ )
+ resultCh <- result{resp: resp, err: err}
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ if err := al.InterruptGraceful("stop now"); err != nil {
+ t.Fatalf("InterruptGraceful failed: %v", err)
+ }
+
+ select {
+ case r := <-resultCh:
+ if r.err != nil {
+ t.Fatalf("unexpected error: %v", r.err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timeout waiting for result")
+ }
+
+ events := collectEventStream(sub.C)
+
+ skippedEvts := filterEvents(events, EventKindToolExecSkipped)
+ if len(skippedEvts) < 1 {
+ t.Fatal("expected at least one ToolExecSkipped event after interrupt")
+ }
+
+ for _, evt := range skippedEvts {
+ payload, ok := evt.Payload.(ToolExecSkippedPayload)
+ if !ok {
+ t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload)
+ }
+ if payload.Reason != "graceful interrupt requested" {
+ t.Fatalf("expected skip reason 'graceful interrupt requested', got %q", payload.Reason)
+ }
+ }
+}
+
+func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) {
+ provider := &multiToolProvider{
+ toolCalls: []providers.ToolCall{
+ {ID: "call-1", Name: "tool_one", Arguments: map[string]any{}},
+ {ID: "call-2", Name: "tool_two", Arguments: map[string]any{}},
+ {ID: "call-3", Name: "tool_three", Arguments: map[string]any{}},
+ },
+ finalContent: "done",
+ }
+ al, _, cleanup := newHookTestLoop(t, provider)
+ defer cleanup()
+
+ al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond})
+ al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond})
+
+ hook := &respondHook{
+ respondTools: map[string]bool{"tool_one": true},
+ }
+ if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil {
+ t.Fatalf("MountHook failed: %v", err)
+ }
+
+ sub := al.SubscribeEvents(32)
+ defer al.UnsubscribeEvents(sub.ID)
+
+ sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
+
+ type result struct {
+ resp string
+ err error
+ }
+ resultCh := make(chan result, 1)
+ go func() {
+ resp, err := al.ProcessDirectWithChannel(
+ context.Background(),
+ "run tools",
+ sessionKey,
+ "cli",
+ "chat1",
+ )
+ resultCh <- result{resp: resp, err: err}
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ al.Steer(providers.Message{Role: "user", Content: "change direction"})
+
+ select {
+ case r := <-resultCh:
+ if r.err != nil {
+ t.Fatalf("unexpected error: %v", r.err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timeout waiting for result")
+ }
+
+ events := collectEventStream(sub.C)
+
+ skippedEvts := filterEvents(events, EventKindToolExecSkipped)
+ if len(skippedEvts) < 1 {
+ t.Fatal("expected at least one ToolExecSkipped event after steering")
+ }
+
+ for _, evt := range skippedEvts {
+ payload, ok := evt.Payload.(ToolExecSkippedPayload)
+ if !ok {
+ t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload)
+ }
+ if payload.Reason != "queued user steering message" {
+ t.Fatalf("expected skip reason 'queued user steering message', got %q", payload.Reason)
+ }
+ }
+}
+
+func filterEvents(events []Event, kind EventKind) []Event {
+ var result []Event
+ for _, evt := range events {
+ if evt.Kind == kind {
+ result = append(result, evt)
+ }
+ }
+ return result
+}
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index 48e5aa625..5bcb83087 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/memory"
@@ -64,6 +65,12 @@ func NewAgentInstance(
cfg *config.Config,
provider providers.LLMProvider,
) *AgentInstance {
+ if cfg != nil {
+ // Keep the subprocess isolation runtime aligned with the latest loaded config
+ // before any tools or providers start spawning child processes.
+ isolation.Configure(cfg)
+ }
+
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 1d9e61970..4655212fb 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -91,6 +91,7 @@ type processOptions struct {
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus
+ AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false
SuppressToolFeedback bool // Whether to suppress inline tool feedback messages
NoHistory bool // If true, don't load session history (for heartbeat)
SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue)
@@ -109,6 +110,15 @@ const (
defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit."
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
handledToolResponseSummary = "Requested output delivered via tool attachment."
+ sessionKeyAgentPrefix = "agent:"
+ metadataKeyMessageKind = "message_kind"
+ messageKindThought = "thought"
+ metadataKeyAccountID = "account_id"
+ metadataKeyGuildID = "guild_id"
+ metadataKeyTeamID = "team_id"
+ metadataKeyReplyToMessage = "reply_to_message_id"
+ metadataKeyParentPeerKind = "parent_peer_kind"
+ metadataKeyParentPeerID = "parent_peer_id"
)
func NewAgentLoop(
@@ -688,21 +698,21 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return
}
- alreadySent := false
+ alreadySentToSameChat := false
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
- alreadySent = mt.HasSentInRound()
+ alreadySentToSameChat = mt.HasSentTo(channel, chatID)
}
}
}
- if alreadySent {
+ if alreadySentToSameChat {
logger.DebugCF(
"agent",
- "Skipped outbound (message tool already sent)",
- map[string]any{"channel": channel},
+ "Skipped outbound (message tool already sent to same chat)",
+ map[string]any{"channel": channel, "chat_id": chatID},
)
return
}
@@ -1593,10 +1603,12 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
UserMessage: msg.Content,
Media: append([]string(nil), msg.Media...),
},
- SenderDisplayName: msg.Sender.DisplayName,
- DefaultResponse: defaultResponse,
- EnableSummary: true,
- SendResponse: false,
+ SenderID: msg.SenderID,
+ SenderDisplayName: msg.Sender.DisplayName,
+ DefaultResponse: defaultResponse,
+ EnableSummary: true,
+ SendResponse: false,
+ AllowInterimPicoPublish: true,
}
// context-dependent commands check their own Runtime fields and report
@@ -1888,6 +1900,43 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
return ""
}
+func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
+ if reasoningContent == "" || chatID == "" {
+ return
+ }
+
+ if ctx.Err() != nil {
+ return
+ }
+
+ pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
+ defer pubCancel()
+
+ if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
+ Context: bus.InboundContext{
+ Channel: "pico",
+ ChatID: chatID,
+ Raw: map[string]string{
+ metadataKeyMessageKind: messageKindThought,
+ },
+ },
+ Content: reasoningContent,
+ }); err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
+ errors.Is(err, bus.ErrBusClosed) {
+ logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{
+ "channel": "pico",
+ "error": err.Error(),
+ })
+ } else {
+ logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{
+ "channel": "pico",
+ "error": err.Error(),
+ })
+ }
+ }
+}
+
func (al *AgentLoop) handleReasoning(
ctx context.Context,
reasoningContent, channelName, channelID string,
@@ -2483,12 +2532,16 @@ turnLoop:
if reasoningContent == "" {
reasoningContent = response.ReasoningContent
}
- go al.handleReasoning(
- ctx,
- reasoningContent,
- ts.channel,
- al.targetReasoningChannelID(ts.channel),
- )
+ if ts.channel == "pico" {
+ go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
+ } else {
+ go al.handleReasoning(
+ turnCtx,
+ reasoningContent,
+ ts.channel,
+ al.targetReasoningChannelID(ts.channel),
+ )
+ }
al.emitEvent(
EventKindLLMResponse,
ts.eventMeta("runTurn", "turn.llm.response"),
@@ -2515,9 +2568,29 @@ turnLoop:
}
logger.DebugCF("agent", "LLM response", llmResponseFields)
+ if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish {
+ if strings.TrimSpace(response.Content) != "" {
+ outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second)
+ err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ Content: response.Content,
+ })
+ outCancel()
+ if err != nil {
+ logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{
+ "error": err.Error(),
+ "channel": ts.channel,
+ "chat_id": ts.chatID,
+ "iteration": iteration,
+ })
+ }
+ }
+ }
+
if len(response.ToolCalls) == 0 || gracefulTerminal {
responseContent := response.Content
- if responseContent == "" && response.ReasoningContent != "" {
+ if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" {
responseContent = response.ReasoningContent
}
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
@@ -2613,6 +2686,238 @@ turnLoop:
toolName = toolReq.Tool
toolArgs = toolReq.Arguments
}
+ case HookActionRespond:
+ // Hook returns result directly, skip tool execution.
+ // SECURITY: This bypasses ApproveTool, allowing hooks to respond
+ // for any tool name without approval. This is intentional for
+ // plugin tools but means a before_tool hook can override even
+ // sensitive tools like bash. Hook configuration should be
+ // carefully reviewed to prevent unauthorized tool execution.
+ if toolReq != nil && toolReq.HookResult != nil {
+ hookResult := toolReq.HookResult
+
+ argsJSON, _ := json.Marshal(toolArgs)
+ argsPreview := utils.Truncate(string(argsJSON), 200)
+ logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview),
+ map[string]any{
+ "agent_id": ts.agent.ID,
+ "tool": toolName,
+ "iteration": iteration,
+ })
+
+ // Emit ToolExecStart event (same as normal tool execution)
+ al.emitEvent(
+ EventKindToolExecStart,
+ ts.eventMeta("runTurn", "turn.tool.start"),
+ ToolExecStartPayload{
+ Tool: toolName,
+ Arguments: cloneEventArguments(toolArgs),
+ },
+ )
+
+ // Send tool feedback to chat channel if enabled (same as normal tool execution)
+ if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() &&
+ ts.channel != "" &&
+ !ts.opts.SuppressToolFeedback {
+ argsJSON, _ := json.Marshal(toolArgs)
+ feedbackPreview := utils.Truncate(
+ string(argsJSON),
+ al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
+ )
+ feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview)
+ fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
+ _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ Content: feedbackMsg,
+ })
+ fbCancel()
+ }
+
+ toolDuration := time.Duration(0) // Hook execution time unknown
+
+ // Send ForUser content to user
+ // For ResponseHandled results, send regardless of SendResponse setting,
+ // same as normal tool execution path.
+ shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" &&
+ (ts.opts.SendResponse || hookResult.ResponseHandled)
+ if shouldSendForUser {
+ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
+ Context: bus.InboundContext{
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ Raw: map[string]string{
+ "is_tool_call": "true",
+ },
+ },
+ Content: hookResult.ForUser,
+ })
+ }
+
+ // Handle media from hook result (same as normal tool execution)
+ if len(hookResult.Media) > 0 && hookResult.ResponseHandled {
+ parts := make([]bus.MediaPart, 0, len(hookResult.Media))
+ for _, ref := range hookResult.Media {
+ part := bus.MediaPart{Ref: ref}
+ if al.mediaStore != nil {
+ if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
+ part.Filename = meta.Filename
+ part.ContentType = meta.ContentType
+ part.Type = inferMediaType(meta.Filename, meta.ContentType)
+ }
+ }
+ parts = append(parts, part)
+ }
+ outboundMedia := bus.OutboundMediaMessage{
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ Parts: parts,
+ }
+ if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) {
+ if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil {
+ logger.WarnCF("agent", "Failed to deliver hook media",
+ map[string]any{
+ "agent_id": ts.agent.ID,
+ "tool": toolName,
+ "channel": ts.channel,
+ "chat_id": ts.chatID,
+ "error": err.Error(),
+ })
+ // Same as normal tool execution: notify LLM about delivery failure
+ hookResult.IsError = true
+ hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err)
+ }
+ } else if al.bus != nil {
+ al.bus.PublishOutboundMedia(ctx, outboundMedia)
+ // Same as normal tool execution: bus only queues, media not yet delivered
+ hookResult.ResponseHandled = false
+ }
+ }
+
+ // Track response handling status (same as normal tool execution)
+ if !hookResult.ResponseHandled {
+ allResponsesHandled = false
+ }
+
+ // Build tool message
+ contentForLLM := hookResult.ContentForLLM()
+ if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
+ contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
+ }
+
+ toolResultMsg := providers.Message{
+ Role: "tool",
+ Content: contentForLLM,
+ ToolCallID: tc.ID,
+ }
+
+ // Handle media for LLM vision (same as normal tool execution)
+ if len(hookResult.Media) > 0 && !hookResult.ResponseHandled {
+ hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media)
+ // Recalculate contentForLLM after adding ArtifactTags
+ contentForLLM = hookResult.ContentForLLM()
+ if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
+ contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
+ }
+ toolResultMsg.Content = contentForLLM
+ toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...)
+ }
+
+ // Emit ToolExecEnd event (after filtering, same as normal tool execution)
+ al.emitEvent(
+ EventKindToolExecEnd,
+ ts.eventMeta("runTurn", "turn.tool.end"),
+ ToolExecEndPayload{
+ Tool: toolName,
+ Duration: toolDuration,
+ ForLLMLen: len(contentForLLM),
+ ForUserLen: len(hookResult.ForUser),
+ IsError: hookResult.IsError,
+ Async: hookResult.Async,
+ },
+ )
+
+ messages = append(messages, toolResultMsg)
+ if !ts.opts.NoHistory {
+ ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
+ ts.recordPersistedMessage(toolResultMsg)
+ ts.ingestMessage(turnCtx, al, toolResultMsg)
+ }
+
+ // Same as normal tool execution: check for steering/interrupt/SubTurn after each tool
+ if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
+ pendingMessages = append(pendingMessages, steerMsgs...)
+ }
+
+ skipReason := ""
+ skipMessage := ""
+ if len(pendingMessages) > 0 {
+ skipReason = "queued user steering message"
+ skipMessage = "Skipped due to queued user message."
+ } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending {
+ skipReason = "graceful interrupt requested"
+ skipMessage = "Skipped due to graceful interrupt."
+ }
+
+ if skipReason != "" {
+ remaining := len(normalizedToolCalls) - i - 1
+ if remaining > 0 {
+ logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond",
+ map[string]any{
+ "agent_id": ts.agent.ID,
+ "completed": i + 1,
+ "skipped": remaining,
+ "reason": skipReason,
+ })
+ for j := i + 1; j < len(normalizedToolCalls); j++ {
+ skippedTC := normalizedToolCalls[j]
+ al.emitEvent(
+ EventKindToolExecSkipped,
+ ts.eventMeta("runTurn", "turn.tool.skipped"),
+ ToolExecSkippedPayload{
+ Tool: skippedTC.Name,
+ Reason: skipReason,
+ },
+ )
+ skippedMsg := providers.Message{
+ Role: "tool",
+ Content: skipMessage,
+ ToolCallID: skippedTC.ID,
+ }
+ messages = append(messages, skippedMsg)
+ if !ts.opts.NoHistory {
+ ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg)
+ ts.recordPersistedMessage(skippedMsg)
+ }
+ }
+ }
+ break
+ }
+
+ // Also poll for any SubTurn results that arrived during tool execution.
+ if ts.pendingResults != nil {
+ select {
+ case result, ok := <-ts.pendingResults:
+ if ok && result != nil && result.ForLLM != "" {
+ content := al.cfg.FilterSensitiveData(result.ForLLM)
+ msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
+ messages = append(messages, msg)
+ ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
+ }
+ default:
+ // No results available
+ }
+ }
+
+ continue
+ }
+ // If no HookResult, fall back to continue with warning
+ logger.WarnCF("agent", "Hook returned respond action but no HookResult provided",
+ map[string]any{
+ "agent_id": ts.agent.ID,
+ "tool": toolName,
+ "action": "respond",
+ })
case HookActionDenyTool:
allResponsesHandled = false
denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason)
@@ -2702,7 +3007,7 @@ turnLoop:
string(argsJSON),
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
- feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview)
+ feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg))
fbCancel()
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 975956bcb..9cca84b6b 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -1398,6 +1398,40 @@ func (m *toolFeedbackProvider) GetDefaultModel() string {
return "heartbeat-tool-feedback-model"
}
+type picoInterleavedContentProvider struct {
+ calls int
+}
+
+func (m *picoInterleavedContentProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
+ m.calls++
+ if m.calls == 1 {
+ return &providers.LLMResponse{
+ Content: "intermediate model text",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_tool_limit_test",
+ Type: "function",
+ Name: "tool_limit_test_tool",
+ Arguments: map[string]any{"value": "x"},
+ }},
+ }, nil
+ }
+
+ return &providers.LLMResponse{
+ Content: "final model text",
+ ToolCalls: []providers.ToolCall{},
+ }, nil
+}
+
+func (m *picoInterleavedContentProvider) GetDefaultModel() string {
+ return "pico-interleaved-content-model"
+}
+
type toolLimitOnlyProvider struct{}
func (m *toolLimitOnlyProvider) Chat(
@@ -2229,7 +2263,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
},
{
ModelName: "gemma-fallback",
- Model: "gemini/gemma-3-27b-it",
+ Model: "openrouter/gemma-3-27b-it",
APIBase: fallbackServer.URL,
APIKeys: config.SimpleSecureStrings("fallback-key"),
Workspace: workspace,
@@ -2970,6 +3004,66 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
}
}
+func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
+ tmpDir := t.TempDir()
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &reasoningContentProvider{
+ response: "final answer",
+ reasoningContent: "thinking trace",
+ }
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ response, err := al.processMessage(context.Background(), bus.InboundMessage{
+ Channel: "pico",
+ SenderID: "user1",
+ ChatID: "pico:test-session",
+ Content: "hello",
+ })
+ if err != nil {
+ t.Fatalf("processMessage() error = %v", err)
+ }
+ if response != "final answer" {
+ t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
+ }
+
+ var thoughtMsg *bus.OutboundMessage
+ deadline := time.After(3 * time.Second)
+
+ for thoughtMsg == nil {
+ select {
+ case outbound := <-msgBus.OutboundChan():
+ msg := outbound
+ if msg.Content == "thinking trace" {
+ thoughtMsg = &msg
+ }
+ case <-deadline:
+ t.Fatal("expected thought outbound message for pico")
+ }
+ }
+
+ if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" {
+ t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID)
+ }
+ if thoughtMsg.Context.Raw[metadataKeyMessageKind] != messageKindThought {
+ t.Fatalf(
+ "thought metadata kind = %q, want %q",
+ thoughtMsg.Context.Raw[metadataKeyMessageKind],
+ messageKindThought,
+ )
+ }
+}
+
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
@@ -3135,6 +3229,133 @@ func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.
}
}
+func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &picoInterleavedContentProvider{}
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ agent := al.GetRegistry().GetDefaultAgent()
+ if agent == nil {
+ t.Fatal("expected default agent")
+ }
+ agent.Tools.Register(&toolLimitTestTool{})
+
+ runCtx, runCancel := context.WithCancel(context.Background())
+ defer runCancel()
+
+ runDone := make(chan error, 1)
+ go func() {
+ runDone <- al.Run(runCtx)
+ }()
+
+ if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
+ Channel: "pico",
+ SenderID: "user-1",
+ ChatID: "session-1",
+ Content: "run with tools",
+ }); err != nil {
+ t.Fatalf("PublishInbound() error = %v", err)
+ }
+
+ outputs := make([]string, 0, 2)
+ deadline := time.After(2 * time.Second)
+ for len(outputs) < 2 {
+ select {
+ case outbound := <-msgBus.OutboundChan():
+ outputs = append(outputs, outbound.Content)
+ case <-deadline:
+ t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
+ }
+ }
+
+ if outputs[0] != "intermediate model text" {
+ t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text")
+ }
+ if outputs[1] != "final model text" {
+ t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text")
+ }
+
+ runCancel()
+ select {
+ case err := <-runDone:
+ if err != nil {
+ t.Fatalf("Run() error = %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for Run() to exit")
+ }
+
+ select {
+ case outbound := <-msgBus.OutboundChan():
+ if outbound.Content == "final model text" {
+ t.Fatalf("unexpected duplicate final pico output: %+v", outbound)
+ }
+ case <-time.After(200 * time.Millisecond):
+ }
+}
+
+func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &picoInterleavedContentProvider{}
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ agent := al.GetRegistry().GetDefaultAgent()
+ if agent == nil {
+ t.Fatal("expected default agent")
+ }
+ agent.Tools.Register(&toolLimitTestTool{})
+
+ response, err := al.runAgentLoop(context.Background(), agent, processOptions{
+ SessionKey: "agent:main:pico:session-1",
+ Channel: "pico",
+ ChatID: "session-1",
+ UserMessage: "run with tools",
+ DefaultResponse: defaultResponse,
+ EnableSummary: false,
+ SendResponse: false,
+ AllowInterimPicoPublish: false,
+ SuppressToolFeedback: true,
+ })
+ if err != nil {
+ t.Fatalf("runAgentLoop() error = %v", err)
+ }
+ if response != "final model text" {
+ t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text")
+ }
+
+ select {
+ case outbound := <-msgBus.OutboundChan():
+ t.Fatalf("unexpected outbound message when interim publish disabled: %+v", outbound)
+ case <-time.After(200 * time.Millisecond):
+ }
+}
+
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go
index e55e9c7a4..fc1f8b611 100644
--- a/pkg/bus/bus_test.go
+++ b/pkg/bus/bus_test.go
@@ -140,6 +140,37 @@ func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) {
}
}
+func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) {
+ mb := NewMessageBus()
+ defer mb.Close()
+
+ msg := InboundMessage{
+ Channel: "pico",
+ ChatID: "session-1",
+ SenderID: "user-1",
+ MessageID: "msg-1",
+ Content: "hello",
+ }
+
+ if err := mb.PublishInbound(context.Background(), msg); err != nil {
+ t.Fatalf("PublishInbound failed: %v", err)
+ }
+
+ got := <-mb.InboundChan()
+ if got.Context.Channel != "pico" {
+ t.Fatalf("expected context channel pico, got %q", got.Context.Channel)
+ }
+ if got.Context.ChatID != "session-1" {
+ t.Fatalf("expected context chat ID session-1, got %q", got.Context.ChatID)
+ }
+ if got.Context.SenderID != "user-1" {
+ t.Fatalf("expected context sender ID user-1, got %q", got.Context.SenderID)
+ }
+ if got.Context.MessageID != "msg-1" {
+ t.Fatalf("expected context message ID msg-1, got %q", got.Context.MessageID)
+ }
+}
+
func TestPublishOutboundSubscribe(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
diff --git a/pkg/bus/inbound_context.go b/pkg/bus/inbound_context.go
index 320424178..d6be80565 100644
--- a/pkg/bus/inbound_context.go
+++ b/pkg/bus/inbound_context.go
@@ -5,6 +5,18 @@ import "strings"
// NormalizeInboundMessage ensures the inbound context is normalized and keeps
// convenience mirrors in sync for runtime consumers.
func NormalizeInboundMessage(msg InboundMessage) InboundMessage {
+ if msg.Context.Channel == "" {
+ msg.Context.Channel = msg.Channel
+ }
+ if msg.Context.ChatID == "" {
+ msg.Context.ChatID = msg.ChatID
+ }
+ if msg.Context.SenderID == "" {
+ msg.Context.SenderID = msg.SenderID
+ }
+ if msg.Context.MessageID == "" {
+ msg.Context.MessageID = msg.MessageID
+ }
msg.Context = normalizeInboundContext(msg.Context)
msg.Channel = msg.Context.Channel
msg.SenderID = msg.Context.SenderID
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index f74fab19b..98d4a4b50 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"sync/atomic"
+ "time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -42,12 +43,18 @@ type FeishuChannel struct {
wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
- botOpenID atomic.Value // stores string; populated lazily for @mention detection
+ botOpenID atomic.Value // stores string; populated lazily for @mention detection
+ messageCache sync.Map // caches fetched messages (messageID -> *larkim.Message)
mu sync.Mutex
cancel context.CancelFunc
}
+type cachedMessage struct {
+ msg *larkim.Message
+ expiry time.Time
+}
+
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
@@ -439,27 +446,14 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
if content == "" {
content = "[empty message]"
}
-
- metadata := map[string]string{}
- if messageID != "" {
- metadata["message_id"] = messageID
- }
- if messageType != "" {
- metadata["message_type"] = messageType
- }
- rawChatType := stringValue(message.ChatType)
- if rawChatType != "" {
- metadata["chat_type"] = rawChatType
- }
- if sender != nil && sender.TenantKey != nil {
- metadata["tenant_key"] = *sender.TenantKey
- }
+ chatType := stringValue(message.ChatType)
+ metadata := buildInboundMetadata(message, sender)
var (
inboundChatType string
isMentioned bool
)
- if rawChatType == "p2p" {
+ if chatType == "p2p" {
inboundChatType = "direct"
} else {
inboundChatType = "group"
@@ -480,12 +474,25 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
content = cleaned
}
+ if replyTargetID(message) != "" || stringValue(message.ThreadId) != "" {
+ content, mediaRefs = c.prependReplyContext(ctx, message, chatID, content, mediaRefs)
+ }
+ if content == "" {
+ content = "[empty message]"
+ }
+
logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_id": messageID,
"preview": utils.Truncate(content, 80),
})
+ logger.InfoCF("feishu", "Feishu reply linkage", map[string]any{
+ "message_id": messageID,
+ "parent_id": stringValue(message.ParentId),
+ "root_id": stringValue(message.RootId),
+ "thread_id": stringValue(message.ThreadId),
+ })
inboundCtx := bus.InboundContext{
Channel: "feishu",
diff --git a/pkg/channels/feishu/feishu_reply.go b/pkg/channels/feishu/feishu_reply.go
new file mode 100644
index 000000000..22dfe3e87
--- /dev/null
+++ b/pkg/channels/feishu/feishu_reply.go
@@ -0,0 +1,298 @@
+//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
+
+package feishu
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+const messageCacheTTL = 30 * time.Second
+
+const (
+ maxReplyContextLen = 600
+)
+
+func (c *FeishuChannel) prependReplyContext(
+ ctx context.Context,
+ message *larkim.EventMessage,
+ chatID string,
+ content string,
+ mediaRefs []string,
+) (string, []string) {
+ if message == nil {
+ return content, mediaRefs
+ }
+
+ lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message)
+ if targetMessageID == "" {
+ logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{
+ "message_id": stringValue(message.MessageId),
+ "parent_id": stringValue(message.ParentId),
+ "root_id": stringValue(message.RootId),
+ "thread_id": stringValue(message.ThreadId),
+ })
+ return content, mediaRefs
+ }
+
+ repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID)
+ if err != nil {
+ logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{
+ "target_message_id": targetMessageID,
+ "error": err.Error(),
+ })
+ return content, mediaRefs
+ }
+
+ messageType := stringValue(repliedMessage.MsgType)
+ rawContent := ""
+ if repliedMessage.Body != nil {
+ rawContent = stringValue(repliedMessage.Body.Content)
+ }
+
+ var repliedMediaRefs []string
+ if store := c.GetMediaStore(); store != nil {
+ repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store)
+ if messageType == larkim.MsgTypeInteractive {
+ _, externalURLs := extractCardImageKeys(rawContent)
+ if len(externalURLs) > 0 {
+ repliedMediaRefs = append(repliedMediaRefs, externalURLs...)
+ }
+ }
+ }
+
+ repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs)
+ if len(repliedMediaRefs) > 0 {
+ mediaRefs = append(repliedMediaRefs, mediaRefs...)
+ }
+
+ return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs
+}
+
+func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string {
+ if targetID := replyTargetID(message); targetID != "" {
+ logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{
+ "message_id": stringValue(message.MessageId),
+ "parent_id": stringValue(message.ParentId),
+ "root_id": stringValue(message.RootId),
+ "target_id": targetID,
+ })
+ return targetID
+ }
+
+ currentMessageID := stringValue(message.MessageId)
+ if currentMessageID == "" {
+ return ""
+ }
+
+ if stringValue(message.ThreadId) == "" {
+ logger.DebugCF("feishu", "No reply target found; message is not in a thread", map[string]any{
+ "message_id": stringValue(message.MessageId),
+ })
+ return ""
+ }
+
+ msg, err := c.fetchMessageByID(ctx, currentMessageID)
+ if err != nil {
+ logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{
+ "message_id": currentMessageID,
+ "error": err.Error(),
+ })
+ return ""
+ }
+
+ targetID := replyTargetIDFromMessage(msg)
+ if targetID != "" {
+ logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{
+ "message_id": currentMessageID,
+ "parent_id": stringValue(msg.ParentId),
+ "root_id": stringValue(msg.RootId),
+ "target_id": targetID,
+ })
+ }
+ return targetID
+}
+
+func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) {
+ if cached, ok := c.messageCache.Load(messageID); ok {
+ cm := cached.(*cachedMessage)
+ if time.Now().Before(cm.expiry) {
+ return cm.msg, nil
+ }
+ c.messageCache.Delete(messageID)
+ }
+
+ req := larkim.NewGetMessageReqBuilder().
+ MessageId(messageID).
+ Build()
+
+ resp, err := c.client.Im.V1.Message.Get(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("feishu get message: %w", err)
+ }
+ if !resp.Success() {
+ c.invalidateTokenOnAuthError(resp.Code)
+ return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg)
+ }
+ if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil {
+ return nil, fmt.Errorf("feishu get message: empty response")
+ }
+ // Items[0] contains the target message - the Feishu API returns a list
+ // but we request a single message by ID, so the list always has at most one item.
+ msg := resp.Data.Items[0]
+ c.messageCache.Store(messageID, &cachedMessage{msg: msg, expiry: time.Now().Add(messageCacheTTL)})
+ return msg, nil
+}
+
+func replyTargetID(message *larkim.EventMessage) string {
+ if message == nil {
+ return ""
+ }
+ if parentID := stringValue(message.ParentId); parentID != "" {
+ return parentID
+ }
+ return stringValue(message.RootId)
+}
+
+func replyTargetIDFromMessage(message *larkim.Message) string {
+ if message == nil {
+ return ""
+ }
+ if parentID := stringValue(message.ParentId); parentID != "" {
+ return parentID
+ }
+ return stringValue(message.RootId)
+}
+
+func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string {
+ metadata := map[string]string{}
+ if message == nil {
+ return metadata
+ }
+
+ messageID := stringValue(message.MessageId)
+ if messageID != "" {
+ metadata["message_id"] = messageID
+ }
+
+ messageType := stringValue(message.MessageType)
+ if messageType != "" {
+ metadata["message_type"] = messageType
+ }
+
+ chatType := stringValue(message.ChatType)
+ if chatType != "" {
+ metadata["chat_type"] = chatType
+ }
+
+ parentID := stringValue(message.ParentId)
+ if parentID != "" {
+ metadata["parent_id"] = parentID
+ }
+
+ rootID := stringValue(message.RootId)
+ if rootID != "" {
+ metadata["root_id"] = rootID
+ }
+
+ if replyTo := replyTargetID(message); replyTo != "" {
+ metadata["reply_to_message_id"] = replyTo
+ }
+
+ threadID := stringValue(message.ThreadId)
+ if threadID != "" {
+ metadata["thread_id"] = threadID
+ }
+
+ if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
+ metadata["tenant_key"] = *sender.TenantKey
+ }
+
+ return metadata
+}
+
+func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string {
+ content := extractContent(messageType, rawContent)
+
+ if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) {
+ content = ""
+ }
+
+ content = appendMediaTags(content, messageType, mediaRefs)
+ if strings.TrimSpace(content) != "" {
+ return content
+ }
+
+ switch messageType {
+ case larkim.MsgTypeImage:
+ return "[replied image]"
+ case larkim.MsgTypeFile:
+ return "[replied file]"
+ case larkim.MsgTypeAudio:
+ return "[replied audio]"
+ case larkim.MsgTypeMedia:
+ return "[replied video]"
+ case larkim.MsgTypeInteractive:
+ return "[replied interactive card]"
+ default:
+ return "[replied message content unavailable]"
+ }
+}
+
+func containsFeishuUpgradePlaceholder(s string) bool {
+ upgradePrompt := "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef"
+ upgradePromptEscaped := "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef"
+ return strings.Contains(s, upgradePrompt) || strings.Contains(s, upgradePromptEscaped)
+}
+
+func formatReplyContext(parentID, repliedContent, content string) string {
+ parentID = strings.TrimSpace(parentID)
+ repliedContent = strings.TrimSpace(repliedContent)
+ content = strings.TrimSpace(content)
+
+ if parentID == "" || repliedContent == "" {
+ return content
+ }
+
+ repliedContent = utils.Truncate(repliedContent, maxReplyContextLen)
+ repliedContent = sanitizeReplyContextContent(repliedContent)
+ content = sanitizeReplyContextContent(content)
+ header := fmt.Sprintf("[replied_message id=%q]", parentID)
+ footer := "[/replied_message]"
+ if content == "" {
+ return header + "\n" + repliedContent + "\n" + footer
+ }
+ if hasLeadingCommandPrefix(content) {
+ return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer
+ }
+ return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]"
+}
+
+func hasLeadingCommandPrefix(s string) bool {
+ tokens := strings.Fields(strings.TrimSpace(s))
+ if len(tokens) == 0 {
+ return false
+ }
+ first := tokens[0]
+ return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!")
+}
+
+func sanitizeReplyContextContent(s string) string {
+ tagEscaper := strings.NewReplacer(
+ "[replied_message", `\[replied_message`,
+ "[/replied_message]", `\[/replied_message]`,
+ "[current_message]", `\[current_message]`,
+ "[/current_message]", `\[/current_message]`,
+ )
+ return tagEscaper.Replace(s)
+}
diff --git a/pkg/channels/feishu/feishu_reply_test.go b/pkg/channels/feishu/feishu_reply_test.go
new file mode 100644
index 000000000..0efe7bc01
--- /dev/null
+++ b/pkg/channels/feishu/feishu_reply_test.go
@@ -0,0 +1,229 @@
+//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
+
+package feishu
+
+import (
+ "strings"
+ "testing"
+
+ larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
+)
+
+func TestBuildInboundMetadata(t *testing.T) {
+ strPtr := func(s string) *string { return &s }
+
+ t.Run("includes basic and reply fields", func(t *testing.T) {
+ message := &larkim.EventMessage{
+ MessageId: strPtr("om_msg_1"),
+ MessageType: strPtr("text"),
+ ChatType: strPtr("group"),
+ ParentId: strPtr("om_parent_1"),
+ RootId: strPtr("om_root_1"),
+ ThreadId: strPtr("omt_thread_1"),
+ }
+ sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")}
+
+ got := buildInboundMetadata(message, sender)
+
+ if got["message_id"] != "om_msg_1" {
+ t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1")
+ }
+ if got["message_type"] != "text" {
+ t.Fatalf("message_type = %q, want %q", got["message_type"], "text")
+ }
+ if got["chat_type"] != "group" {
+ t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group")
+ }
+ if got["parent_id"] != "om_parent_1" {
+ t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1")
+ }
+ if got["reply_to_message_id"] != "om_parent_1" {
+ t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1")
+ }
+ if got["root_id"] != "om_root_1" {
+ t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1")
+ }
+ if got["thread_id"] != "omt_thread_1" {
+ t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1")
+ }
+ if got["tenant_key"] != "tenant_x" {
+ t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x")
+ }
+ })
+
+ t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) {
+ message := &larkim.EventMessage{
+ MessageId: strPtr("om_msg_3"),
+ RootId: strPtr("om_root_3"),
+ }
+
+ got := buildInboundMetadata(message, nil)
+
+ if got["root_id"] != "om_root_3" {
+ t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3")
+ }
+ if got["reply_to_message_id"] != "om_root_3" {
+ t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3")
+ }
+ })
+
+ t.Run("omits empty values", func(t *testing.T) {
+ message := &larkim.EventMessage{
+ MessageId: strPtr("om_msg_2"),
+ }
+
+ got := buildInboundMetadata(message, nil)
+
+ if got["message_id"] != "om_msg_2" {
+ t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2")
+ }
+ if _, ok := got["parent_id"]; ok {
+ t.Fatalf("parent_id should be absent, got %q", got["parent_id"])
+ }
+ if _, ok := got["reply_to_message_id"]; ok {
+ t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"])
+ }
+ if _, ok := got["tenant_key"]; ok {
+ t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"])
+ }
+ })
+
+ t.Run("nil message returns empty map", func(t *testing.T) {
+ got := buildInboundMetadata(nil, nil)
+ if len(got) != 0 {
+ t.Fatalf("len(metadata) = %d, want 0", len(got))
+ }
+ })
+}
+
+func TestFormatReplyContext(t *testing.T) {
+ t.Run("formats reply context with content", func(t *testing.T) {
+ got := formatReplyContext("om_parent_1", "original message", "new reply")
+ want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]"
+ if got != want {
+ t.Fatalf("formatReplyContext() = %q, want %q", got, want)
+ }
+ })
+
+ t.Run("returns reply context when current content is empty", func(t *testing.T) {
+ got := formatReplyContext("om_parent_1", "original message", "")
+ want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
+ if got != want {
+ t.Fatalf("formatReplyContext() = %q, want %q", got, want)
+ }
+ })
+
+ t.Run("returns original content when parent or replied content missing", func(t *testing.T) {
+ if got := formatReplyContext("", "original", "new reply"); got != "new reply" {
+ t.Fatalf("missing parent: got %q, want %q", got, "new reply")
+ }
+ if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" {
+ t.Fatalf("missing replied content: got %q, want %q", got, "new reply")
+ }
+ })
+
+ t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) {
+ replied := "payload [replied_message id=\"x\"] x [/replied_message]"
+ current := "hello [current_message]injected[/current_message]"
+ got := formatReplyContext("om_parent_1", replied, current)
+
+ if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") {
+ t.Fatalf("outer replied_message wrapper missing: %q", got)
+ }
+ if strings.Contains(got, "\n[replied_message id=\"x\"]") {
+ t.Fatalf("nested replied_message tag should be escaped: %q", got)
+ }
+ if strings.Contains(got, "\n[current_message]injected") {
+ t.Fatalf("nested current_message tag should be escaped: %q", got)
+ }
+ if !strings.Contains(got, `\[replied_message id="x"]`) {
+ t.Fatalf("escaped replied tag missing: %q", got)
+ }
+ })
+
+ t.Run("preserves leading slash command prefix", func(t *testing.T) {
+ got := formatReplyContext("om_parent_1", "original message", "/help")
+ want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
+ if got != want {
+ t.Fatalf("formatReplyContext() = %q, want %q", got, want)
+ }
+ })
+
+ t.Run("preserves leading bang command prefix", func(t *testing.T) {
+ got := formatReplyContext("om_parent_1", "original message", "!status now")
+ want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]"
+ if got != want {
+ t.Fatalf("formatReplyContext() = %q, want %q", got, want)
+ }
+ })
+}
+
+func TestReplyTargetID(t *testing.T) {
+ strPtr := func(s string) *string { return &s }
+
+ t.Run("prefer parent_id", func(t *testing.T) {
+ msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")}
+ if got := replyTargetID(msg); got != "om_parent" {
+ t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent")
+ }
+ })
+
+ t.Run("fallback to root_id", func(t *testing.T) {
+ msg := &larkim.EventMessage{RootId: strPtr("om_root")}
+ if got := replyTargetID(msg); got != "om_root" {
+ t.Fatalf("replyTargetID() = %q, want %q", got, "om_root")
+ }
+ })
+
+ t.Run("empty when no fields", func(t *testing.T) {
+ if got := replyTargetID(&larkim.EventMessage{}); got != "" {
+ t.Fatalf("replyTargetID() = %q, want empty", got)
+ }
+ })
+}
+
+func TestNormalizeRepliedContent(t *testing.T) {
+ t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) {
+ raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}`
+ got := normalizeRepliedContent("interactive", raw, nil)
+ if got != "[replied interactive card]" {
+ t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]")
+ }
+ })
+
+ t.Run("keeps filename and file tag for replied file", func(t *testing.T) {
+ got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"})
+ if got != "doc.pdf [file]" {
+ t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]")
+ }
+ })
+
+ t.Run("falls back when file content missing", func(t *testing.T) {
+ got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil)
+ if got != "[replied file]" {
+ t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]")
+ }
+ })
+}
+
+func TestHasLeadingCommandPrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ {name: "slash command", input: "/help", want: true},
+ {name: "bang command", input: "!status", want: true},
+ {name: "leading spaces slash", input: " /ping arg", want: true},
+ {name: "normal text", input: "hello /help", want: false},
+ {name: "empty", input: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := hasLeadingCommandPrefix(tt.input); got != tt.want {
+ t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go
index 91af34e4c..ca36537fa 100644
--- a/pkg/channels/pico/client.go
+++ b/pkg/channels/pico/client.go
@@ -242,7 +242,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
}
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
- content, _ := msg.Payload["content"].(string)
+ if isThoughtPayload(msg.Payload) {
+ return
+ }
+
+ content, _ := msg.Payload[PayloadKeyContent].(string)
if strings.TrimSpace(content) == "" {
return
}
@@ -292,7 +296,7 @@ func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) (
}
outMsg := newMessage(TypeMessageSend, map[string]any{
- "content": msg.Content,
+ PayloadKeyContent: msg.Content,
})
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
return nil, pc.writeJSON(outMsg)
diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go
index b40606647..732589432 100644
--- a/pkg/channels/pico/client_test.go
+++ b/pkg/channels/pico/client_test.go
@@ -316,3 +316,67 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
t.Fatal("timed out waiting for inbound media message")
}
}
+
+func TestIsThoughtPayload(t *testing.T) {
+ tests := []struct {
+ name string
+ payload map[string]any
+ want bool
+ }{
+ {
+ name: "explicit thought bool",
+ payload: map[string]any{PayloadKeyThought: true},
+ want: true,
+ },
+ {
+ name: "thought false",
+ payload: map[string]any{PayloadKeyThought: false},
+ want: false,
+ },
+ {
+ name: "thought string ignored",
+ payload: map[string]any{PayloadKeyThought: "true"},
+ want: false,
+ },
+ {
+ name: "default normal",
+ payload: map[string]any{PayloadKeyContent: "hello"},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isThoughtPayload(tt.payload); got != tt.want {
+ t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) {
+ mb := bus.NewMessageBus()
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: "ws://localhost:8080/ws",
+ }, mb)
+ if err != nil {
+ t.Fatalf("NewPicoClientChannel() error = %v", err)
+ }
+
+ ch.ctx = context.Background()
+ pc := &picoConn{sessionID: "sess-thought"}
+
+ ch.handleServerMessage(pc, PicoMessage{
+ Type: TypeMessageCreate,
+ Payload: map[string]any{
+ PayloadKeyContent: "internal reasoning",
+ PayloadKeyThought: true,
+ },
+ })
+
+ select {
+ case msg := <-mb.InboundChan():
+ t.Fatalf("expected no inbound publish for thought payload, got %+v", msg)
+ case <-time.After(150 * time.Millisecond):
+ }
+}
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
index 80ab84cf1..3e6601fe0 100644
--- a/pkg/channels/pico/pico.go
+++ b/pkg/channels/pico/pico.go
@@ -39,6 +39,13 @@ var allowedInlineImageMIMETypes = map[string]struct{}{
"image/bmp": {},
}
+func outboundMessageIsThought(msg bus.OutboundMessage) bool {
+ if len(msg.Context.Raw) == 0 {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought)
+}
+
// writeJSON sends a JSON message to the connection with write locking.
func (pc *picoConn) writeJSON(v any) error {
if pc.closed.Load() {
@@ -247,9 +254,11 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
+ isThought := outboundMessageIsThought(msg)
outMsg := newMessage(TypeMessageCreate, map[string]any{
- "content": msg.Content,
+ PayloadKeyContent: msg.Content,
+ PayloadKeyThought: isThought,
})
return nil, c.broadcastToSession(msg.ChatID, outMsg)
@@ -288,8 +297,9 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
- "content": text,
- "message_id": msgID,
+ PayloadKeyContent: text,
+ PayloadKeyThought: false,
+ "message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go
index 3f8ba8643..ecdc2d140 100644
--- a/pkg/channels/pico/protocol.go
+++ b/pkg/channels/pico/protocol.go
@@ -19,6 +19,11 @@ const (
TypePong = "pong"
PicoTokenPrefix = "pico-"
+
+ PayloadKeyContent = "content"
+ PayloadKeyThought = "thought"
+
+ MessageKindThought = "thought"
)
// PicoMessage is the wire format for all Pico Protocol messages.
@@ -39,6 +44,11 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
}
}
+func isThoughtPayload(payload map[string]any) bool {
+ thought, _ := payload[PayloadKeyThought].(bool)
+ return thought
+}
+
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
payload := map[string]any{
"code": code,
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 4970047cf..e959a44e7 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -24,13 +24,14 @@ var rrCounter atomic.Uint64
// CurrentVersion is the latest config schema version
const CurrentVersion = 2
-// Config is the current config structure with version support
+// Config is the current config structure with version support.
type Config struct {
// Config schema version for migration.
- Version int `json:"version" yaml:"-"`
- Agents AgentsConfig `json:"agents" yaml:"-"`
- Session SessionConfig `json:"session,omitempty" yaml:"-"`
- Channels ChannelsConfig `json:"channels" yaml:"channels"`
+ Version int `json:"version" yaml:"-"`
+ Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
+ Agents AgentsConfig `json:"agents" yaml:"-"`
+ Session SessionConfig `json:"session,omitempty" yaml:"-"`
+ Channels ChannelsConfig `json:"channels" yaml:"channels"`
// New model-centric provider configuration.
ModelList SecureModelList `json:"model_list" yaml:"model_list"`
Gateway GatewayConfig `json:"gateway" yaml:"-"`
@@ -46,6 +47,21 @@ type Config struct {
sensitiveCache *SensitiveDataCache
}
+// IsolationConfig controls subprocess isolation for commands started by PicoClaw.
+// It is applied by the isolation package rather than by sandboxing the main process.
+type IsolationConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ ExposePaths []ExposePath `json:"expose_paths,omitempty"`
+}
+
+// ExposePath describes a host path that should remain visible inside the isolated
+// child-process environment. This is currently implemented on Linux only.
+type ExposePath struct {
+ Source string `json:"source"`
+ Target string `json:"target,omitempty"`
+ Mode string `json:"mode"`
+}
+
// FilterSensitiveData filters sensitive values from content before sending to LLM.
// This prevents the LLM from seeing its own credentials.
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 9aa91e4d9..f139ac5fd 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1058,6 +1058,37 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
}
}
+func TestDefaultConfig_IsolationEnabled(t *testing.T) {
+ cfg := DefaultConfig()
+ if cfg.Isolation.Enabled {
+ t.Fatal("DefaultConfig().Isolation.Enabled should be false")
+ }
+}
+
+func TestConfig_UnmarshalIsolation(t *testing.T) {
+ cfg := DefaultConfig()
+ raw := []byte(`{
+ "isolation": {
+ "enabled": false,
+ "expose_paths": [
+ {"source":"/src","target":"/dst","mode":"ro"}
+ ]
+ }
+ }`)
+ if err := json.Unmarshal(raw, cfg); err != nil {
+ t.Fatalf("json.Unmarshal isolation config: %v", err)
+ }
+ if cfg.Isolation.Enabled {
+ t.Fatal("Isolation.Enabled should be false after unmarshal")
+ }
+ if len(cfg.Isolation.ExposePaths) != 1 {
+ t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths))
+ }
+ if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" {
+ t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got)
+ }
+}
+
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index e3dfadc1a..33f37e6e9 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -17,6 +17,11 @@ func DefaultConfig() *Config {
return &Config{
Version: CurrentVersion,
+ // Isolation is opt-in so existing installations keep their current behavior
+ // until the user explicitly enables subprocess sandboxing.
+ Isolation: IsolationConfig{
+ Enabled: false,
+ },
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: workspacePath,
diff --git a/pkg/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go
index a46addae1..b6adbe498 100644
--- a/pkg/gateway/channel_matrix.go
+++ b/pkg/gateway/channel_matrix.go
@@ -1,4 +1,4 @@
-//go:build !mipsle && !netbsd && !(freebsd && arm)
+//go:build !mipsle && !netbsd && !(freebsd && arm) && !android
package gateway
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index 8be84bdf6..be8f9d1c8 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -111,7 +111,7 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
}
// Run starts the gateway runtime using the configuration loaded from configPath.
-func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
+func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runErr error) {
panicPath := filepath.Join(homePath, logPath, panicFile)
panicFunc, err := logger.InitPanic(panicPath)
if err != nil {
@@ -129,14 +129,25 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
} else {
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
}
+ defer func() {
+ if runErr != nil {
+ logger.ErrorCF("gateway", "Gateway startup failed", map[string]any{
+ "config_path": configPath,
+ "error": runErr.Error(),
+ "home_path": homePath,
+ "allow_empty": allowEmptyStartup,
+ "debug": debug,
+ })
+ }
+ }()
cfg, err := config.LoadConfig(configPath)
if err != nil {
- logger.Fatalf("error loading config: %v", err)
+ return fmt.Errorf("error loading config: %w", err)
}
if err = preCheckConfig(cfg); err != nil {
- logger.Fatalf("config pre-check failed: %v", err)
+ return fmt.Errorf("config pre-check failed: %w", err)
}
// Debug mode permanently overrides the config log level to DEBUG.
diff --git a/pkg/gateway/gateway_test.go b/pkg/gateway/gateway_test.go
new file mode 100644
index 000000000..60049337f
--- /dev/null
+++ b/pkg/gateway/gateway_test.go
@@ -0,0 +1,108 @@
+package gateway
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestRun_StartupFailuresReturnErrorAndEmitStructuredLog(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ prepare func(t *testing.T, dir string) string
+ wantErr string
+ wantLogSub string
+ }{
+ {
+ name: "invalid config returns load error",
+ prepare: func(t *testing.T, dir string) string {
+ t.Helper()
+ cfgPath := filepath.Join(dir, "invalid-config.json")
+ if err := os.WriteFile(cfgPath, []byte("{invalid-json"), 0o644); err != nil {
+ t.Fatalf("WriteFile(invalid config) error = %v", err)
+ }
+ return cfgPath
+ },
+ wantErr: "error loading config:",
+ wantLogSub: "error loading config:",
+ },
+ {
+ name: "invalid config returns pre-check error",
+ prepare: func(t *testing.T, dir string) string {
+ t.Helper()
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Port = 0
+ cfgPath := filepath.Join(dir, "config.json")
+ if err := config.SaveConfig(cfgPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+ return cfgPath
+ },
+ wantErr: "config pre-check failed: invalid gateway port: 0",
+ wantLogSub: "config pre-check failed: invalid gateway port: 0",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ homeDir := t.TempDir()
+ configPath := tt.prepare(t, homeDir)
+
+ cmd := exec.Command(os.Args[0], "-test.run=TestGatewayRunStartupFailureHelper")
+ cmd.Env = append(os.Environ(),
+ "GO_WANT_GATEWAY_RUN_HELPER=1",
+ "PICO_TEST_HOME="+homeDir,
+ "PICO_TEST_CONFIG="+configPath,
+ )
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("helper exited unexpectedly: %v\noutput:\n%s", err, string(output))
+ }
+
+ out := string(output)
+ if !strings.Contains(out, tt.wantErr) {
+ t.Fatalf("helper output missing expected error substring %q:\n%s", tt.wantErr, out)
+ }
+
+ logData, readErr := os.ReadFile(filepath.Join(homeDir, logPath, logFile))
+ if readErr != nil {
+ t.Fatalf("ReadFile(gateway.log) error = %v", readErr)
+ }
+ logText := string(logData)
+ if !strings.Contains(logText, "Gateway startup failed") {
+ t.Fatalf("gateway.log missing structured startup failure log:\n%s", logText)
+ }
+ if !strings.Contains(logText, tt.wantLogSub) {
+ t.Fatalf("gateway.log missing expected failure detail %q:\n%s", tt.wantLogSub, logText)
+ }
+ })
+ }
+}
+
+func TestGatewayRunStartupFailureHelper(t *testing.T) {
+ if os.Getenv("GO_WANT_GATEWAY_RUN_HELPER") != "1" {
+ return
+ }
+
+ homeDir := os.Getenv("PICO_TEST_HOME")
+ configPath := os.Getenv("PICO_TEST_CONFIG")
+
+ err := Run(false, homeDir, configPath, false)
+ if err == nil {
+ fmt.Fprintln(os.Stdout, "expected startup error, got nil")
+ os.Exit(2)
+ }
+
+ fmt.Fprintln(os.Stdout, err.Error())
+ os.Exit(0)
+}
diff --git a/pkg/health/server.go b/pkg/health/server.go
index 2602cb965..a152d8ab1 100644
--- a/pkg/health/server.go
+++ b/pkg/health/server.go
@@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"net/http"
+ "os"
"sync"
"time"
)
@@ -31,6 +32,7 @@ type Check struct {
type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
+ PID int `json:"pid,omitempty"`
Checks map[string]Check `json:"checks,omitempty"`
}
@@ -170,6 +172,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
resp := StatusResponse{
Status: "ok",
Uptime: uptime.String(),
+ PID: os.Getpid(),
}
json.NewEncoder(w).Encode(resp)
diff --git a/pkg/isolation/README.md b/pkg/isolation/README.md
new file mode 100644
index 000000000..de16ce505
--- /dev/null
+++ b/pkg/isolation/README.md
@@ -0,0 +1,238 @@
+# `pkg/isolation`
+
+`pkg/isolation` provides process-level isolation for child processes started by `picoclaw`.
+
+It does not sandbox the main `picoclaw` process itself.
+
+## Scope
+
+The current scope is the child-process startup path:
+
+- `exec` tool
+- CLI providers such as `claude-cli` and `codex-cli`
+- process hooks
+- MCP `stdio` servers
+
+## One-Sentence Model
+
+- The `picoclaw` main process still runs in the host environment.
+- Every child process should enter the shared `pkg/isolation` startup path first.
+- The startup path applies platform-specific isolation according to config.
+
+## Architecture
+
+The implementation has four layers:
+
+1. Configuration layer: reads `config.Config.Isolation` and injects it through `isolation.Configure(cfg)`.
+2. Instance layout layer: resolves `config.GetHome()`, prepares instance directories, and builds the runtime user environment.
+3. Platform backend layer: Linux uses `bwrap`; Windows uses a restricted token, low integrity, and a `Job Object`; other platforms are not implemented.
+4. Unified startup layer: `PrepareCommand(cmd)`, `Start(cmd)`, and `Run(cmd)`.
+
+All integrations that spawn subprocesses should reuse these helpers instead of calling `cmd.Start` or `cmd.Run` directly.
+
+## Configuration
+
+Isolation lives under:
+
+```json
+{
+ "isolation": {
+ "enabled": false,
+ "expose_paths": []
+ }
+}
+```
+
+Field meanings:
+
+- `enabled`: enables or disables subprocess isolation. Default: `false`.
+- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only.
+
+Example:
+
+```json
+{
+ "isolation": {
+ "enabled": true,
+ "expose_paths": [
+ {
+ "source": "/opt/toolchains/go",
+ "target": "/opt/toolchains/go",
+ "mode": "ro"
+ },
+ {
+ "source": "/data/shared-assets",
+ "target": "/opt/picoclaw-instance-a/workspace/assets",
+ "mode": "rw"
+ }
+ ]
+ }
+}
+```
+
+Rules for `expose_paths`:
+
+- `source` is a host path.
+- `target` is the path inside the isolated environment.
+- `mode` must be `ro` or `rw`.
+- When `target` is empty, it defaults to `source`.
+- Only one final rule may exist for the same `target`.
+- Later-loaded config overrides earlier rules for the same `target`.
+
+Platform note:
+
+- Linux uses a real `source -> target` mount view.
+- Windows does not currently support `expose_paths`.
+
+## Instance Root And Directories
+
+The instance root follows `config.GetHome()`:
+
+- If `PICOCLAW_HOME` is set, use it.
+- Otherwise use the default `.picoclaw` directory under the user home.
+
+If `config.GetHome()` falls back to `.` while isolation is enabled, startup should fail.
+
+Default instance directories include:
+
+- instance root
+- `skills`
+- `logs`
+- `cache`
+- `state`
+- `runtime-user-env`
+
+`workspace` is derived from `cfg.WorkspacePath()` when configured, otherwise from the default workspace rule.
+
+Windows also prepares:
+
+- `runtime-user-env/AppData/Roaming`
+- `runtime-user-env/AppData/Local`
+
+## User Environment Redirect
+
+When isolation is enabled, child processes receive a redirected per-instance user environment.
+
+Linux variables:
+
+- `HOME`
+- `TMPDIR`
+- `XDG_CONFIG_HOME`
+- `XDG_CACHE_HOME`
+- `XDG_STATE_HOME`
+
+Windows variables:
+
+- `USERPROFILE`
+- `HOME`
+- `TEMP`
+- `TMP`
+- `APPDATA`
+- `LOCALAPPDATA`
+
+These paths point into `runtime-user-env` under the instance root.
+
+## Platform Behavior
+
+### Linux
+
+The Linux backend currently depends on `bwrap` (`bubblewrap`).
+
+Capabilities:
+
+- minimal filesystem view
+- `ipc` namespace isolation
+- redirected child-process user environment
+- `source -> target` read-only or read-write mounts
+
+Default mounts include the instance root plus the minimum runtime system paths such as `/usr`, `/bin`, `/lib`, `/lib64`, and `/etc/resolv.conf`.
+
+At runtime, PicoClaw also adds the executable path, its directory, the effective working directory, and absolute path arguments when needed.
+
+There is no automatic fallback when `bwrap` is missing.
+
+Install examples:
+
+- `apt install bubblewrap`
+- `dnf install bubblewrap`
+- `yum install bubblewrap`
+- `pacman -S bubblewrap`
+- `apk add bubblewrap`
+
+If isolation must be disabled temporarily:
+
+```json
+{
+ "isolation": {
+ "enabled": false
+ }
+}
+```
+
+Disabling isolation increases the risk that child processes can access or modify more host files.
+
+### Windows
+
+Windows isolation currently supports process-level restrictions such as restricted tokens, low integrity, job objects, and redirected user-environment directories.
+
+`expose_paths` is not currently supported on Windows. If it is configured, startup should fail instead of pretending the paths were exposed.
+
+The Windows backend currently uses:
+
+- a restricted primary token
+- low integrity level
+- a `Job Object`
+- redirected child-process user environment
+
+It does not currently implement true `source -> target` filesystem remapping.
+
+### macOS And Other Platforms
+
+They are not implemented yet.
+
+When isolation is explicitly enabled on an unsupported platform, the higher-level runtime should surface that as an unsupported configuration instead of pretending isolation succeeded.
+
+## Logging And Debugging
+
+When isolation is enabled, PicoClaw logs the generated isolation plan.
+
+Linux log name:
+
+- `linux isolation mount plan`
+
+Windows log name:
+
+- `windows isolation access rules`
+
+If you suspect isolation is ineffective, check whether unexpected host paths appear in those logs.
+
+## Relationship To `restrict_to_workspace`
+
+- `restrict_to_workspace` limits the paths an agent is normally allowed to access.
+- `pkg/isolation` limits what a child process can see and where its user environment points.
+
+They complement each other and do not replace each other.
+
+## Current Limits
+
+- Linux isolation is implemented with `bwrap`, not a custom in-process isolation runtime.
+- Linux does not currently enable a dedicated `pid` namespace by default.
+- Windows does not yet implement full host ACL enforcement for every allowed or denied path.
+- macOS is not implemented.
+- The current design isolates child processes, not the main `picoclaw` process.
+
+## Suggested Reading Order
+
+If you are new to this code, read it in this order:
+
+1. `pkg/config/config.go`
+2. `pkg/isolation/runtime.go`
+3. `pkg/isolation/platform_linux.go`
+4. `pkg/isolation/platform_windows.go`
+5. Call sites:
+6. `pkg/tools/shell.go`
+7. `pkg/providers/*.go`
+8. `pkg/agent/hook_process.go`
+9. `pkg/mcp/manager.go`
+
+That path gives the fastest overview of the configuration model, runtime flow, and platform-specific limits.
diff --git a/pkg/isolation/README_CN.md b/pkg/isolation/README_CN.md
new file mode 100644
index 000000000..0529a84bd
--- /dev/null
+++ b/pkg/isolation/README_CN.md
@@ -0,0 +1,238 @@
+# `pkg/isolation`
+
+`pkg/isolation` 为 `picoclaw` 启动的子进程提供进程级隔离能力。
+
+它当前不会把 `picoclaw` 主进程自身放进沙箱中运行。
+
+## 生效范围
+
+当前生效范围是子进程启动链路:
+
+- `exec` 工具
+- `claude-cli`、`codex-cli` 等 CLI provider
+- 进程型 hooks
+- MCP `stdio` server
+
+## 一句话理解
+
+- `picoclaw` 主进程仍运行在宿主环境中。
+- 所有子进程都应先经过 `pkg/isolation` 的统一启动入口。
+- 入口会根据配置和平台,为子进程施加对应隔离。
+
+## 架构
+
+当前实现可以分为四层:
+
+1. 配置层:读取 `config.Config.Isolation`,并通过 `isolation.Configure(cfg)` 注入运行时。
+2. 实例目录层:解析 `config.GetHome()`,准备实例目录,并构建运行时用户环境目录。
+3. 平台后端层:Linux 使用 `bwrap`;Windows 使用受限 token、低完整性级别和 `Job Object`;其他平台未实现。
+4. 统一启动层:`PrepareCommand(cmd)`、`Start(cmd)`、`Run(cmd)`。
+
+所有启动子进程的接入点都应复用这组入口,而不是各自直接调用 `cmd.Start` 或 `cmd.Run`。
+
+## 配置
+
+隔离配置位于:
+
+```json
+{
+ "isolation": {
+ "enabled": false,
+ "expose_paths": []
+ }
+}
+```
+
+字段说明:
+
+- `enabled`:是否启用子进程隔离。默认值:`false`。
+- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。
+
+示例:
+
+```json
+{
+ "isolation": {
+ "enabled": true,
+ "expose_paths": [
+ {
+ "source": "/opt/toolchains/go",
+ "target": "/opt/toolchains/go",
+ "mode": "ro"
+ },
+ {
+ "source": "/data/shared-assets",
+ "target": "/opt/picoclaw-instance-a/workspace/assets",
+ "mode": "rw"
+ }
+ ]
+ }
+}
+```
+
+`expose_paths` 规则:
+
+- `source`:宿主机路径。
+- `target`:隔离环境内的目标路径。
+- `mode`:只能是 `ro` 或 `rw`。
+- `target` 为空时,默认等于 `source`。
+- 同一个 `target` 最终只能保留一条规则。
+- 后加载的配置会覆盖先加载的同目标规则。
+
+平台说明:
+
+- Linux 会真实使用 `source -> target` 挂载视图。
+- Windows 当前不支持 `expose_paths`。
+
+## 实例根与目录
+
+实例根遵循 `config.GetHome()`:
+
+- 如果设置了 `PICOCLAW_HOME`,使用该值。
+- 否则默认使用用户目录下的 `.picoclaw`。
+
+如果 `config.GetHome()` 在隔离开启时最终回退到当前目录 `.`,启动应直接失败。
+
+默认实例目录包括:
+
+- 实例根本身
+- `skills`
+- `logs`
+- `cache`
+- `state`
+- `runtime-user-env`
+
+`workspace` 优先使用 `cfg.WorkspacePath()` 的结果;未显式配置时才按默认规则派生。
+
+Windows 还会额外准备:
+
+- `runtime-user-env/AppData/Roaming`
+- `runtime-user-env/AppData/Local`
+
+## 用户环境重定向
+
+隔离开启后,子进程会收到重定向到实例目录下的独立用户环境。
+
+Linux 注入变量:
+
+- `HOME`
+- `TMPDIR`
+- `XDG_CONFIG_HOME`
+- `XDG_CACHE_HOME`
+- `XDG_STATE_HOME`
+
+Windows 注入变量:
+
+- `USERPROFILE`
+- `HOME`
+- `TEMP`
+- `TMP`
+- `APPDATA`
+- `LOCALAPPDATA`
+
+这些路径都会指向实例根下的 `runtime-user-env`。
+
+## 平台行为
+
+### Linux
+
+Linux 后端当前依赖 `bwrap`(`bubblewrap`)。
+
+能力:
+
+- 最小文件系统视图
+- `ipc namespace`
+- 子进程用户环境重定向
+- `source -> target` 只读或读写挂载
+
+默认映射包括实例根,以及 `/usr`、`/bin`、`/lib`、`/lib64`、`/etc/resolv.conf` 等最小运行时系统路径。
+
+运行时还会按需补充可执行文件本身、其所在目录、生效后的工作目录,以及命令行中的绝对路径参数。
+
+缺少 `bwrap` 时不会自动回退。
+
+安装示例:
+
+- `apt install bubblewrap`
+- `dnf install bubblewrap`
+- `yum install bubblewrap`
+- `pacman -S bubblewrap`
+- `apk add bubblewrap`
+
+如果需要临时关闭隔离:
+
+```json
+{
+ "isolation": {
+ "enabled": false
+ }
+}
+```
+
+关闭隔离后,子进程访问或修改更多宿主文件的风险会明显上升。
+
+### Windows
+
+Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object,以及用户环境目录重定向。
+
+`expose_paths` 目前不支持 Windows。如果配置了该字段,启动应直接失败,而不是假装这些路径已经被暴露进隔离环境。
+
+Windows 后端当前使用:
+
+- 受限 primary token
+- 低完整性级别
+- `Job Object`
+- 子进程用户环境重定向
+
+它当前不会实现真正的 `source -> target` 文件系统重映射。
+
+### macOS 与其他平台
+
+当前尚未实现。
+
+当在未支持的平台上显式开启隔离时,上层运行时应将其视为不支持的配置,而不是假装隔离成功。
+
+## 日志与排障
+
+隔离开启后,PicoClaw 会打印生成后的隔离计划,便于排障。
+
+Linux 日志名:
+
+- `linux isolation mount plan`
+
+Windows 日志名:
+
+- `windows isolation access rules`
+
+如果你怀疑隔离未生效,先检查这些日志里是否出现了不应暴露的宿主路径。
+
+## 与 `restrict_to_workspace` 的关系
+
+- `restrict_to_workspace` 限制的是 agent 默认可访问的路径。
+- `pkg/isolation` 限制的是子进程运行时能看到什么文件系统,以及它的用户环境指向哪里。
+
+两者互补,不互相替代。
+
+## 当前限制
+
+- Linux 基于 `bwrap` 实现,而不是纯内建 isolation runtime。
+- Linux 当前没有默认启用独立的 `pid namespace`。
+- Windows 还没有对所有允许/拒绝路径做完整 ACL 落地。
+- macOS 尚未实现。
+- 当前隔离的是子进程,不是 `picoclaw` 主进程自身。
+
+## 建议阅读顺序
+
+如果你是第一次看这部分代码,建议按这个顺序阅读:
+
+1. `pkg/config/config.go`
+2. `pkg/isolation/runtime.go`
+3. `pkg/isolation/platform_linux.go`
+4. `pkg/isolation/platform_windows.go`
+5. 调用点:
+6. `pkg/tools/shell.go`
+7. `pkg/providers/*.go`
+8. `pkg/agent/hook_process.go`
+9. `pkg/mcp/manager.go`
+
+这样能最快建立对配置模型、运行流程和平台边界的整体理解。
diff --git a/pkg/isolation/platform_linux.go b/pkg/isolation/platform_linux.go
new file mode 100644
index 000000000..9a282a4ad
--- /dev/null
+++ b/pkg/isolation/platform_linux.go
@@ -0,0 +1,264 @@
+//go:build linux
+
+package isolation
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
+ if !isolation.Enabled {
+ return nil
+ }
+ // Bubblewrap is the only supported Linux backend right now. Fail closed when
+ // it is unavailable instead of silently running the child process unisolated.
+ bwrapPath, err := exec.LookPath("bwrap")
+ if err != nil {
+ hint := bwrapInstallHint()
+ disableHint := `set "isolation.enabled": false in config.json`
+ logger.WarnCF("isolation", "bubblewrap is required for Linux isolation",
+ map[string]any{
+ "binary": "bwrap",
+ "install": hint,
+ "disable_isolation": disableHint,
+ "risk": "disabling isolation lets child processes run without Linux filesystem isolation",
+ })
+ return fmt.Errorf(
+ "linux isolation requires bwrap and does not fall back automatically: %w; install bubblewrap with one of: %s; or disable isolation by setting %s; disabling isolation means child processes can run without Linux filesystem isolation and may access or modify more host files",
+ err,
+ hint,
+ disableHint,
+ )
+ }
+ if cmd == nil || cmd.Path == "" || len(cmd.Args) == 0 {
+ return nil
+ }
+
+ originalPath := cmd.Path
+ originalArgs := append([]string{}, cmd.Args...)
+ _, execDir, err := resolveLinuxWorkingDir(cmd.Dir, originalPath)
+ if err != nil {
+ return err
+ }
+ resolvedPath, err := resolveLinuxCommandPath(originalPath, execDir)
+ if err != nil {
+ return err
+ }
+
+ // Start from the configured mount plan, then add only the executable, its
+ // resolved path, the effective working directory, and any absolute path
+ // arguments needed to preserve the original command semantics.
+ plan := BuildLinuxMountPlan(root, isolation.ExposePaths)
+ plan = ensureLinuxMountRule(plan, resolvedPath, resolvedPath, "ro")
+ plan = ensureLinuxMountRule(plan, filepath.Dir(resolvedPath), filepath.Dir(resolvedPath), "ro")
+ if resolved, resolveErr := filepath.EvalSymlinks(resolvedPath); resolveErr == nil && resolved != resolvedPath {
+ plan = ensureLinuxMountRule(plan, resolved, resolved, "ro")
+ plan = ensureLinuxMountRule(plan, filepath.Dir(resolved), filepath.Dir(resolved), "ro")
+ }
+ if execDir != "" {
+ plan = ensureLinuxMountRule(plan, execDir, execDir, "rw")
+ if resolved, resolveErr := filepath.EvalSymlinks(execDir); resolveErr == nil && resolved != execDir {
+ plan = ensureLinuxMountRule(plan, resolved, resolved, "rw")
+ }
+ }
+ plan = appendLinuxArgumentMounts(plan, originalArgs[1:])
+ logger.DebugCF("isolation", "linux isolation mount plan",
+ map[string]any{
+ "root": root,
+ "command": resolvedPath,
+ "working_dir": execDir,
+ "mounts": formatLinuxMountPlan(plan),
+ })
+ bwrapArgs, err := buildLinuxBwrapArgs(originalPath, resolvedPath, originalArgs, execDir, plan)
+ if err != nil {
+ return err
+ }
+
+ cmd.Path = bwrapPath
+ cmd.Args = bwrapArgs
+ cmd.Dir = ""
+ return nil
+}
+
+func bwrapInstallHint() string {
+ return "apt install bubblewrap; dnf install bubblewrap; yum install bubblewrap; pacman -S bubblewrap; apk add bubblewrap"
+}
+
+// formatLinuxMountPlan reshapes the internal plan for structured logging.
+func formatLinuxMountPlan(plan []MountRule) []map[string]string {
+ formatted := make([]map[string]string, 0, len(plan))
+ for _, rule := range plan {
+ formatted = append(formatted, map[string]string{
+ "source": rule.Source,
+ "target": rule.Target,
+ "mode": rule.Mode,
+ })
+ }
+ return formatted
+}
+
+func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
+ return nil
+}
+
+func cleanupPendingPlatformResources(cmd *exec.Cmd) {
+}
+
+// buildLinuxBwrapArgs translates the mount plan into the bubblewrap command
+// line that re-executes the original process inside the isolated mount view.
+func buildLinuxBwrapArgs(
+ originalPath string,
+ resolvedPath string,
+ originalArgs []string,
+ execDir string,
+ plan []MountRule,
+) ([]string, error) {
+ bwrapArgs := []string{
+ "bwrap",
+ "--die-with-parent",
+ "--unshare-ipc",
+ "--proc", "/proc",
+ "--dev", "/dev",
+ }
+ for _, rule := range plan {
+ flag, err := linuxBindFlag(rule)
+ if err != nil {
+ return nil, err
+ }
+ bwrapArgs = append(bwrapArgs, flag, rule.Source, rule.Target)
+ }
+ if execDir != "" {
+ bwrapArgs = append(bwrapArgs, "--chdir", execDir)
+ }
+ execPath := originalPath
+ if isRelativeCommandPath(originalPath) {
+ execPath = resolvedPath
+ }
+ bwrapArgs = append(bwrapArgs, "--", execPath)
+ if len(originalArgs) > 1 {
+ bwrapArgs = append(bwrapArgs, originalArgs[1:]...)
+ }
+ return bwrapArgs, nil
+}
+
+func resolveLinuxWorkingDir(originalDir, originalPath string) (string, string, error) {
+ if originalDir != "" {
+ resolved, err := filepath.Abs(originalDir)
+ if err != nil {
+ return "", "", fmt.Errorf("resolve command dir %s: %w", originalDir, err)
+ }
+ return resolved, resolved, nil
+ }
+ if !isRelativeCommandPath(originalPath) {
+ return "", "", nil
+ }
+ wd, err := os.Getwd()
+ if err != nil {
+ return "", "", fmt.Errorf("resolve current working dir: %w", err)
+ }
+ return "", wd, nil
+}
+
+func resolveLinuxCommandPath(originalPath, execDir string) (string, error) {
+ if filepath.IsAbs(originalPath) || !isRelativeCommandPath(originalPath) {
+ return filepath.Clean(originalPath), nil
+ }
+ base := execDir
+ if base == "" {
+ var err error
+ base, err = os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("resolve current working dir: %w", err)
+ }
+ }
+ return filepath.Clean(filepath.Join(base, originalPath)), nil
+}
+
+func appendLinuxArgumentMounts(plan []MountRule, args []string) []MountRule {
+ for _, arg := range args {
+ path, ok := linuxArgumentPath(arg)
+ if !ok {
+ continue
+ }
+ clean := filepath.Clean(path)
+ if info, err := os.Stat(clean); err == nil {
+ mode := "ro"
+ if info.IsDir() {
+ mode = "rw"
+ }
+ plan = ensureLinuxMountRule(plan, clean, clean, mode)
+ if resolved, resolveErr := filepath.EvalSymlinks(clean); resolveErr == nil && resolved != clean {
+ plan = ensureLinuxMountRule(plan, resolved, resolved, mode)
+ }
+ continue
+ } else if !errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ parent := filepath.Dir(clean)
+ if parent == clean {
+ continue
+ }
+ if _, err := os.Stat(parent); err == nil {
+ plan = ensureLinuxMountRule(plan, parent, parent, "rw")
+ }
+ }
+ return plan
+}
+
+func linuxArgumentPath(arg string) (string, bool) {
+ if filepath.IsAbs(arg) {
+ return arg, true
+ }
+ idx := strings.IndexRune(arg, '=')
+ if idx <= 0 || idx == len(arg)-1 {
+ return "", false
+ }
+ value := arg[idx+1:]
+ if !filepath.IsAbs(value) {
+ return "", false
+ }
+ return value, true
+}
+
+func isRelativeCommandPath(path string) bool {
+ return !filepath.IsAbs(path) && strings.ContainsRune(path, filepath.Separator)
+}
+
+// ensureLinuxMountRule appends a mount rule unless another rule already owns
+// the same target path.
+func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []MountRule {
+ cleanSource := filepath.Clean(source)
+ cleanTarget := filepath.Clean(target)
+ for _, rule := range plan {
+ if filepath.Clean(rule.Target) == cleanTarget {
+ return plan
+ }
+ }
+ return append(plan, MountRule{Source: cleanSource, Target: cleanTarget, Mode: mode})
+}
+
+// linuxBindFlag selects the correct bubblewrap bind flag based on mount mode.
+func linuxBindFlag(rule MountRule) (string, error) {
+ info, err := os.Stat(rule.Source)
+ if err != nil {
+ return "", fmt.Errorf("stat linux mount source %s: %w", rule.Source, err)
+ }
+ if !info.IsDir() {
+ if rule.Mode == "rw" {
+ return "--bind", nil
+ }
+ return "--ro-bind", nil
+ }
+ if rule.Mode == "rw" {
+ return "--bind", nil
+ }
+ return "--ro-bind", nil
+}
diff --git a/pkg/isolation/platform_linux_test.go b/pkg/isolation/platform_linux_test.go
new file mode 100644
index 000000000..2dcca96ce
--- /dev/null
+++ b/pkg/isolation/platform_linux_test.go
@@ -0,0 +1,148 @@
+//go:build linux
+
+package isolation
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestBuildLinuxBwrapArgs_IncludesNamespaceFlagsAndExec(t *testing.T) {
+ root := t.TempDir()
+ binaryDir := filepath.Join(root, "bin")
+ if err := os.MkdirAll(binaryDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ binaryPath := filepath.Join(binaryDir, "tool")
+ if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ plan := BuildLinuxMountPlan(root, []config.ExposePath{{Source: binaryDir, Target: binaryDir, Mode: "ro"}})
+ args, err := buildLinuxBwrapArgs(binaryPath, binaryPath, []string{binaryPath, "--flag"}, root, plan)
+ if err != nil {
+ t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
+ }
+ hasNet := false
+ hasIPC := false
+ hasExec := false
+ for i := range args {
+ switch args[i] {
+ case "--unshare-net":
+ hasNet = true
+ case "--unshare-ipc":
+ hasIPC = true
+ case "--":
+ if i+1 < len(args) && args[i+1] == binaryPath {
+ hasExec = true
+ }
+ }
+ }
+ if hasNet {
+ t.Fatalf("bwrap args should not unshare net by default: %v", args)
+ }
+ if !hasIPC || !hasExec {
+ t.Fatalf("bwrap args missing required items: %v", args)
+ }
+}
+
+func TestResolveLinuxWorkingDir_ResolvesRelativeDir(t *testing.T) {
+ cwd := t.TempDir()
+ previous, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if chdirErr := os.Chdir(previous); chdirErr != nil {
+ t.Fatalf("restore cwd: %v", chdirErr)
+ }
+ }()
+ if chdirErr := os.Chdir(cwd); chdirErr != nil {
+ t.Fatal(chdirErr)
+ }
+
+ resolvedDir, execDir, err := resolveLinuxWorkingDir("./hooks", "./hook.sh")
+ if err != nil {
+ t.Fatalf("resolveLinuxWorkingDir() error = %v", err)
+ }
+ want := filepath.Join(cwd, "hooks")
+ if resolvedDir != want || execDir != want {
+ t.Fatalf("resolveLinuxWorkingDir() = (%q, %q), want (%q, %q)", resolvedDir, execDir, want, want)
+ }
+}
+
+func TestResolveLinuxCommandPath_UsesExecDirForRelativeCommand(t *testing.T) {
+ execDir := filepath.Join(t.TempDir(), "hooks")
+ got, err := resolveLinuxCommandPath("./hook.sh", execDir)
+ if err != nil {
+ t.Fatalf("resolveLinuxCommandPath() error = %v", err)
+ }
+ want := filepath.Join(execDir, "hook.sh")
+ if got != want {
+ t.Fatalf("resolveLinuxCommandPath() = %q, want %q", got, want)
+ }
+}
+
+func TestBuildLinuxBwrapArgs_UsesResolvedPathForRelativeCommand(t *testing.T) {
+ root := t.TempDir()
+ execDir := filepath.Join(root, "hooks")
+ if err := os.MkdirAll(execDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ resolvedPath := filepath.Join(execDir, "hook.sh")
+ if err := os.WriteFile(resolvedPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ plan := []MountRule{
+ {Source: execDir, Target: execDir, Mode: "rw"},
+ {Source: resolvedPath, Target: resolvedPath, Mode: "ro"},
+ }
+ args, err := buildLinuxBwrapArgs("./hook.sh", resolvedPath, []string{"./hook.sh"}, execDir, plan)
+ if err != nil {
+ t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
+ }
+ hasExecDir := false
+ for _, arg := range args {
+ if arg == execDir {
+ hasExecDir = true
+ break
+ }
+ }
+ if !hasExecDir {
+ t.Fatalf("buildLinuxBwrapArgs() missing resolved chdir: %v", args)
+ }
+ for i := range args {
+ if args[i] == "--" {
+ if i+1 >= len(args) || args[i+1] != resolvedPath {
+ t.Fatalf("buildLinuxBwrapArgs() exec path = %v, want %q after --", args, resolvedPath)
+ }
+ return
+ }
+ }
+ t.Fatalf("buildLinuxBwrapArgs() missing exec delimiter: %v", args)
+}
+
+func TestAppendLinuxArgumentMounts_AddsAbsoluteArgumentPaths(t *testing.T) {
+ root := t.TempDir()
+ input := filepath.Join(root, "input.txt")
+ if err := os.WriteFile(input, []byte("data"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ output := filepath.Join(root, "out", "result.txt")
+ if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ plan := appendLinuxArgumentMounts(nil, []string{input, "--output=" + output})
+ if len(plan) != 2 {
+ t.Fatalf("appendLinuxArgumentMounts() len = %d, want 2", len(plan))
+ }
+ if plan[0].Source != input || plan[0].Mode != "ro" {
+ t.Fatalf("appendLinuxArgumentMounts()[0] = %+v, want source=%q mode=ro", plan[0], input)
+ }
+ if plan[1].Source != filepath.Dir(output) || plan[1].Mode != "rw" {
+ t.Fatalf("appendLinuxArgumentMounts()[1] = %+v, want source=%q mode=rw", plan[1], filepath.Dir(output))
+ }
+}
diff --git a/pkg/isolation/platform_other.go b/pkg/isolation/platform_other.go
new file mode 100644
index 000000000..d8d06e2ec
--- /dev/null
+++ b/pkg/isolation/platform_other.go
@@ -0,0 +1,22 @@
+//go:build !linux && !windows
+
+package isolation
+
+import (
+ "os/exec"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
+ // Unsupported platforms currently keep the command unchanged. Callers rely on
+ // Preflight and higher-level checks to surface unsupported isolation modes.
+ return nil
+}
+
+func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
+ return nil
+}
+
+func cleanupPendingPlatformResources(cmd *exec.Cmd) {
+}
diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go
new file mode 100644
index 000000000..9434976f7
--- /dev/null
+++ b/pkg/isolation/platform_windows.go
@@ -0,0 +1,217 @@
+//go:build windows
+
+package isolation
+
+import (
+ "fmt"
+ "os/exec"
+ "sync"
+ "syscall"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+const disableMaxPrivilege = 0x1
+
+// windowsProcessResources holds native handles that must live for the lifetime
+// of an isolated child process.
+type windowsProcessResources struct {
+ job windows.Handle
+ token windows.Token
+}
+
+var (
+ windowsProcessResourcesByPID sync.Map
+ windowsPendingResources sync.Map
+ advapi32 = windows.NewLazySystemDLL("advapi32.dll")
+ procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken")
+)
+
+func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
+ if !isolation.Enabled || cmd == nil {
+ return nil
+ }
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ rules := BuildWindowsAccessRules(root, isolation.ExposePaths)
+ logger.InfoCF("isolation", "windows isolation process constraints",
+ map[string]any{
+ "root": root,
+ "command": cmd.Path,
+ "rules": formatWindowsAccessRules(rules),
+ "note": "Windows currently enforces restricted token, low integrity, and job object limits; expose_paths filesystem remapping is rejected during preflight",
+ })
+ // Create the restricted token before the process starts so CreateProcess uses
+ // the reduced privilege set from the first instruction.
+ restrictedToken, err := createRestrictedPrimaryToken()
+ if err != nil {
+ return fmt.Errorf("create restricted primary token: %w", err)
+ }
+ cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB
+ cmd.SysProcAttr.Token = syscall.Token(restrictedToken)
+ windowsPendingResources.Store(cmd, windowsProcessResources{token: restrictedToken})
+ return nil
+}
+
+func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
+ if !isolation.Enabled || cmd == nil || cmd.Process == nil {
+ return nil
+ }
+ resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd)
+ resources, _ := resourcesAny.(windowsProcessResources)
+ // Job objects can only be attached after the process exists, so the Windows
+ // backend finishes isolation in this post-start hook.
+ job, err := windows.CreateJobObject(nil, nil)
+ if err != nil {
+ if resources.token != 0 {
+ _ = resources.token.Close()
+ }
+ return fmt.Errorf("create windows job object: %w", err)
+ }
+
+ info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
+ info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ if _, err := windows.SetInformationJobObject(
+ job,
+ windows.JobObjectExtendedLimitInformation,
+ uintptr(unsafe.Pointer(&info)),
+ uint32(unsafe.Sizeof(info)),
+ ); err != nil {
+ _ = windows.CloseHandle(job)
+ if resources.token != 0 {
+ _ = resources.token.Close()
+ }
+ return fmt.Errorf("set windows job object info: %w", err)
+ }
+
+ proc, err := windows.OpenProcess(
+ windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE,
+ false,
+ uint32(cmd.Process.Pid),
+ )
+ if err != nil {
+ _ = windows.CloseHandle(job)
+ if resources.token != 0 {
+ _ = resources.token.Close()
+ }
+ return fmt.Errorf("open process for job assignment: %w", err)
+ }
+
+ if err := windows.AssignProcessToJobObject(job, proc); err != nil {
+ _ = windows.CloseHandle(proc)
+ _ = windows.CloseHandle(job)
+ if resources.token != 0 {
+ _ = resources.token.Close()
+ }
+ return fmt.Errorf("assign process to job object: %w", err)
+ }
+
+ if resources.token != 0 {
+ _ = resources.token.Close()
+ }
+ resources.job = job
+ windowsProcessResourcesByPID.Store(cmd.Process.Pid, resources)
+ go reapWindowsProcessResources(cmd.Process.Pid, proc, job)
+ return nil
+}
+
+func cleanupPendingPlatformResources(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ resourcesAny, ok := windowsPendingResources.LoadAndDelete(cmd)
+ if !ok {
+ return
+ }
+ resources, _ := resourcesAny.(windowsProcessResources)
+ if resources.token != 0 {
+ _ = resources.token.Close()
+ }
+}
+
+func reapWindowsProcessResources(pid int, proc windows.Handle, job windows.Handle) {
+ _, _ = windows.WaitForSingleObject(proc, windows.INFINITE)
+ _ = windows.CloseHandle(proc)
+ _ = windows.CloseHandle(job)
+ windowsProcessResourcesByPID.Delete(pid)
+}
+
+// createRestrictedPrimaryToken duplicates the current process token, removes
+// maximum privileges, and lowers integrity before it is assigned to a child.
+func createRestrictedPrimaryToken() (windows.Token, error) {
+ var current windows.Token
+ if err := windows.OpenProcessToken(
+ windows.CurrentProcess(),
+ windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY|windows.TOKEN_QUERY|windows.TOKEN_ADJUST_DEFAULT,
+ ¤t,
+ ); err != nil {
+ return 0, err
+ }
+ defer current.Close()
+
+ var restricted windows.Token
+ r1, _, e1 := procCreateRestrictedToken.Call(
+ uintptr(current),
+ uintptr(disableMaxPrivilege),
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ uintptr(unsafe.Pointer(&restricted)),
+ )
+ if r1 == 0 {
+ if e1 != nil && e1 != syscall.Errno(0) {
+ return 0, e1
+ }
+ return 0, syscall.EINVAL
+ }
+ if err := setTokenLowIntegrity(restricted); err != nil {
+ _ = restricted.Close()
+ return 0, err
+ }
+ return restricted, nil
+}
+
+// setTokenLowIntegrity lowers the token integrity level so writes to higher
+// integrity locations are blocked by the OS.
+func setTokenLowIntegrity(token windows.Token) error {
+ lowSID, err := windows.CreateWellKnownSid(windows.WinLowLabelSid)
+ if err != nil {
+ return fmt.Errorf("create low integrity sid: %w", err)
+ }
+ tml := windows.Tokenmandatorylabel{
+ Label: windows.SIDAndAttributes{
+ Sid: lowSID,
+ Attributes: windows.SE_GROUP_INTEGRITY,
+ },
+ }
+ if err := windows.SetTokenInformation(
+ token,
+ windows.TokenIntegrityLevel,
+ (*byte)(unsafe.Pointer(&tml)),
+ tml.Size(),
+ ); err != nil {
+ return fmt.Errorf("set token low integrity: %w", err)
+ }
+ return nil
+}
+
+// formatWindowsAccessRules reshapes the internal rules for structured logging.
+func formatWindowsAccessRules(rules []AccessRule) []map[string]string {
+ formatted := make([]map[string]string, 0, len(rules))
+ for _, rule := range rules {
+ formatted = append(formatted, map[string]string{
+ "path": rule.Path,
+ "mode": rule.Mode,
+ })
+ }
+ return formatted
+}
diff --git a/pkg/isolation/runtime.go b/pkg/isolation/runtime.go
new file mode 100644
index 000000000..b2de98b88
--- /dev/null
+++ b/pkg/isolation/runtime.go
@@ -0,0 +1,443 @@
+package isolation
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// MountRule describes a source-to-target mount exposed inside the Linux
+// isolation view.
+type MountRule struct {
+ Source string
+ Target string
+ Mode string
+}
+
+// AccessRule describes the effective Windows-side access rule for a host path.
+type AccessRule struct {
+ Path string
+ Mode string
+}
+
+// UserEnv contains the redirected per-instance user directories injected into
+// isolated child processes.
+type UserEnv struct {
+ Home string
+ Tmp string
+ Config string
+ Cache string
+ State string
+ AppData string
+ LocalAppData string
+}
+
+var (
+ isolationMu sync.RWMutex
+ currentIsolation = config.DefaultConfig().Isolation
+)
+
+// Configure updates the process-wide isolation state used by subsequent child
+// process launches.
+func Configure(cfg *config.Config) {
+ isolationMu.Lock()
+ defer isolationMu.Unlock()
+ if cfg == nil {
+ defaults := config.DefaultConfig()
+ currentIsolation = defaults.Isolation
+ return
+ }
+ currentIsolation = cfg.Isolation
+}
+
+// CurrentConfig returns the currently active isolation settings.
+func CurrentConfig() config.IsolationConfig {
+ isolationMu.RLock()
+ defer isolationMu.RUnlock()
+ return currentIsolation
+}
+
+// ResolveInstanceRoot resolves the instance root used to build the isolated
+// filesystem and redirected user environment.
+func ResolveInstanceRoot() (string, error) {
+ root := filepath.Clean(config.GetHome())
+ if root == "." {
+ return "", fmt.Errorf("instance root resolved to current directory")
+ }
+ return root, nil
+}
+
+// PrepareInstanceRoot creates the directories required by the isolation runtime.
+func PrepareInstanceRoot(root string) error {
+ for _, dir := range InstanceDirs(root) {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("prepare instance dir %s: %w", dir, err)
+ }
+ }
+ return nil
+}
+
+// InstanceDirs returns the directories that must exist under the instance root
+// for isolation-aware child processes.
+func InstanceDirs(root string) []string {
+ dirs := []string{
+ root,
+ filepath.Join(root, "skills"),
+ filepath.Join(root, "logs"),
+ filepath.Join(root, "cache"),
+ filepath.Join(root, "state"),
+ filepath.Join(root, "runtime-user-env"),
+ filepath.Join(root, "runtime-user-env", "home"),
+ filepath.Join(root, "runtime-user-env", "tmp"),
+ filepath.Join(root, "runtime-user-env", "config"),
+ filepath.Join(root, "runtime-user-env", "cache"),
+ filepath.Join(root, "runtime-user-env", "state"),
+ }
+ dirs = append(dirs, filepath.Join(root, pkg.WorkspaceName))
+ if runtime.GOOS == "windows" {
+ dirs = append(dirs,
+ filepath.Join(root, "runtime-user-env", "AppData", "Roaming"),
+ filepath.Join(root, "runtime-user-env", "AppData", "Local"),
+ )
+ }
+ return dirs
+}
+
+// ResolveUserEnv derives the redirected user directories rooted under the
+// instance runtime area.
+func ResolveUserEnv(root string) UserEnv {
+ base := filepath.Join(root, "runtime-user-env")
+ return UserEnv{
+ Home: filepath.Join(base, "home"),
+ Tmp: filepath.Join(base, "tmp"),
+ Config: filepath.Join(base, "config"),
+ Cache: filepath.Join(base, "cache"),
+ State: filepath.Join(base, "state"),
+ AppData: filepath.Join(base, "AppData", "Roaming"),
+ LocalAppData: filepath.Join(base, "AppData", "Local"),
+ }
+}
+
+// ApplyUserEnv rewrites the child process environment so home, temp, and
+// platform-specific user-data directories point into the instance root.
+func ApplyUserEnv(cmd *exec.Cmd, root string) {
+ userEnv := ResolveUserEnv(root)
+ envMap := make(map[string]string)
+ for _, item := range cmd.Environ() {
+ if idx := strings.IndexRune(item, '='); idx > 0 {
+ envMap[item[:idx]] = item[idx+1:]
+ }
+ }
+
+ if runtime.GOOS == "windows" {
+ envMap["USERPROFILE"] = userEnv.Home
+ envMap["HOME"] = userEnv.Home
+ envMap["TEMP"] = userEnv.Tmp
+ envMap["TMP"] = userEnv.Tmp
+ envMap["APPDATA"] = userEnv.AppData
+ envMap["LOCALAPPDATA"] = userEnv.LocalAppData
+ } else {
+ envMap["HOME"] = userEnv.Home
+ envMap["TMPDIR"] = userEnv.Tmp
+ envMap["XDG_CONFIG_HOME"] = userEnv.Config
+ envMap["XDG_CACHE_HOME"] = userEnv.Cache
+ envMap["XDG_STATE_HOME"] = userEnv.State
+ }
+
+ env := make([]string, 0, len(envMap))
+ for k, v := range envMap {
+ env = append(env, fmt.Sprintf("%s=%s", k, v))
+ }
+ cmd.Env = env
+}
+
+// ValidateExposePaths verifies the user-supplied path exposure rules before a
+// child process is started.
+func ValidateExposePaths(items []config.ExposePath) error {
+ seen := map[string]struct{}{}
+ for _, item := range items {
+ if item.Source == "" {
+ return fmt.Errorf("source is required")
+ }
+ if item.Mode != "ro" && item.Mode != "rw" {
+ return fmt.Errorf("invalid expose_paths mode: %s", item.Mode)
+ }
+
+ source := filepath.Clean(item.Source)
+ target := item.Target
+ if target == "" {
+ target = source
+ }
+ target = filepath.Clean(target)
+
+ if !filepath.IsAbs(source) || !filepath.IsAbs(target) {
+ return fmt.Errorf("source and target must be absolute paths")
+ }
+ if _, ok := seen[target]; ok {
+ return fmt.Errorf("duplicate expose_path target: %s", target)
+ }
+ seen[target] = struct{}{}
+ }
+ return nil
+}
+
+// NormalizeExposePath fills implicit defaults and cleans path values so merge
+// and validation logic can work with canonical paths.
+func NormalizeExposePath(item config.ExposePath) config.ExposePath {
+ source := filepath.Clean(item.Source)
+ target := item.Target
+ if target == "" {
+ target = source
+ }
+ return config.ExposePath{
+ Source: source,
+ Target: filepath.Clean(target),
+ Mode: item.Mode,
+ }
+}
+
+// DefaultExposePaths returns the minimum built-in host paths required for the
+// current platform to run isolated child processes.
+func DefaultExposePaths(root string) []config.ExposePath {
+ items := []config.ExposePath{{
+ Source: root,
+ Target: root,
+ Mode: "rw",
+ }}
+ if runtime.GOOS == "linux" {
+ items = append(items, defaultLinuxSystemExposePaths()...)
+ }
+ return items
+}
+
+func defaultLinuxSystemExposePaths() []config.ExposePath {
+ return existingExposePaths([]config.ExposePath{
+ {Source: "/usr", Target: "/usr", Mode: "ro"},
+ {Source: "/bin", Target: "/bin", Mode: "ro"},
+ {Source: "/lib", Target: "/lib", Mode: "ro"},
+ {Source: "/lib64", Target: "/lib64", Mode: "ro"},
+ {Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", Mode: "ro"},
+ {Source: "/etc/hosts", Target: "/etc/hosts", Mode: "ro"},
+ {Source: "/etc/nsswitch.conf", Target: "/etc/nsswitch.conf", Mode: "ro"},
+ {Source: "/etc/passwd", Target: "/etc/passwd", Mode: "ro"},
+ {Source: "/etc/group", Target: "/etc/group", Mode: "ro"},
+ {Source: "/etc/ssl", Target: "/etc/ssl", Mode: "ro"},
+ {Source: "/etc/pki", Target: "/etc/pki", Mode: "ro"},
+ {Source: "/etc/ca-certificates", Target: "/etc/ca-certificates", Mode: "ro"},
+ {Source: "/usr/share/ca-certificates", Target: "/usr/share/ca-certificates", Mode: "ro"},
+ {Source: "/usr/local/share/ca-certificates", Target: "/usr/local/share/ca-certificates", Mode: "ro"},
+ {Source: "/etc/alternatives", Target: "/etc/alternatives", Mode: "ro"},
+ {Source: "/usr/share/zoneinfo", Target: "/usr/share/zoneinfo", Mode: "ro"},
+ {Source: "/etc/localtime", Target: "/etc/localtime", Mode: "ro"},
+ })
+}
+
+// existingExposePaths keeps only the builtin host paths that exist on the
+// current machine so Linux isolation does not fail on distro-specific paths.
+func existingExposePaths(items []config.ExposePath) []config.ExposePath {
+ filtered := make([]config.ExposePath, 0, len(items))
+ for _, item := range items {
+ if _, err := os.Stat(item.Source); err == nil {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered
+}
+
+// MergeExposePaths merges built-in rules with user overrides. Rules are keyed
+// by target path so later entries replace earlier ones for the same target.
+func MergeExposePaths(defaults []config.ExposePath, overrides []config.ExposePath) []config.ExposePath {
+ merged := make([]config.ExposePath, 0, len(defaults)+len(overrides))
+ indexByTarget := make(map[string]int, len(defaults)+len(overrides))
+ appendOrReplace := func(item config.ExposePath) {
+ normalized := NormalizeExposePath(item)
+ if idx, ok := indexByTarget[normalized.Target]; ok {
+ merged[idx] = normalized
+ return
+ }
+ indexByTarget[normalized.Target] = len(merged)
+ merged = append(merged, normalized)
+ }
+ for _, item := range defaults {
+ appendOrReplace(item)
+ }
+ for _, item := range overrides {
+ appendOrReplace(item)
+ }
+ return merged
+}
+
+// BuildLinuxMountPlan converts the merged expose-path configuration into the
+// mount rules consumed by the Linux bubblewrap backend.
+func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule {
+ merged := MergeExposePaths(DefaultExposePaths(root), overrides)
+ plan := make([]MountRule, 0, len(merged))
+ for _, item := range merged {
+ plan = append(plan, MountRule{Source: item.Source, Target: item.Target, Mode: item.Mode})
+ }
+ return plan
+}
+
+// BuildWindowsAccessRules derives the host-path access policy used by the
+// Windows restricted-token backend.
+func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule {
+ merged := MergeExposePaths(nil, overrides)
+ rules := make([]AccessRule, 0, len(merged)+1)
+ rules = append(rules, AccessRule{Path: root, Mode: "rw"})
+ for _, item := range merged {
+ rules = append(rules, AccessRule{Path: item.Source, Mode: item.Mode})
+ }
+ return rules
+}
+
+func validateWindowsExposePaths(items []config.ExposePath) error {
+ if len(items) == 0 {
+ return nil
+ }
+ return fmt.Errorf("windows isolation does not yet support expose_paths filesystem rules")
+}
+
+// IsSupported reports whether the current platform has an implemented isolation
+// backend.
+func IsSupported() bool {
+ return isSupportedOn(runtime.GOOS)
+}
+
+func isSupportedOn(goos string) bool {
+ switch goos {
+ case "linux", "windows":
+ return true
+ default:
+ return false
+ }
+}
+
+// Preflight validates the configured isolation state and prepares the instance
+// runtime directories before any child process is launched.
+func Preflight() error {
+ isolation := CurrentConfig()
+ if !isolation.Enabled {
+ return nil
+ }
+ if !IsSupported() {
+ return fmt.Errorf("subprocess isolation is not supported on %s", runtime.GOOS)
+ }
+ root, err := ResolveInstanceRoot()
+ if err != nil {
+ return err
+ }
+ if err := PrepareInstanceRoot(root); err != nil {
+ return err
+ }
+ if err := ValidateExposePaths(isolation.ExposePaths); err != nil {
+ return err
+ }
+ if runtime.GOOS == "linux" {
+ for _, rule := range BuildLinuxMountPlan(root, isolation.ExposePaths) {
+ if rule.Source == "" || rule.Target == "" {
+ return fmt.Errorf("invalid linux mount rule")
+ }
+ }
+ }
+ if runtime.GOOS == "windows" {
+ if err := validateWindowsExposePaths(isolation.ExposePaths); err != nil {
+ return err
+ }
+ for _, rule := range BuildWindowsAccessRules(root, isolation.ExposePaths) {
+ if rule.Path == "" {
+ return fmt.Errorf("invalid windows access rule")
+ }
+ }
+ }
+ return nil
+}
+
+// Start prepares isolation for the command, starts it, and applies any
+// post-start platform hooks required by the active backend.
+func Start(cmd *exec.Cmd) error {
+ if err := PrepareCommand(cmd); err != nil {
+ return err
+ }
+ if err := cmd.Start(); err != nil {
+ cleanupPendingPlatformResources(cmd)
+ return err
+ }
+ isolation := CurrentConfig()
+ root := ""
+ if isolation.Enabled {
+ var err error
+ root, err = ResolveInstanceRoot()
+ if err != nil {
+ terminateStartedCommand(cmd)
+ return err
+ }
+ }
+ if err := postStartPlatformIsolation(cmd, isolation, root); err != nil {
+ terminateStartedCommand(cmd)
+ return err
+ }
+ return nil
+}
+
+// Run is the Start-and-Wait helper that keeps the same isolation behavior as
+// Start while returning the command's final exit status.
+func Run(cmd *exec.Cmd) error {
+ if err := PrepareCommand(cmd); err != nil {
+ return err
+ }
+ if err := cmd.Start(); err != nil {
+ cleanupPendingPlatformResources(cmd)
+ return err
+ }
+ isolation := CurrentConfig()
+ root := ""
+ if isolation.Enabled {
+ var err error
+ root, err = ResolveInstanceRoot()
+ if err != nil {
+ terminateStartedCommand(cmd)
+ return err
+ }
+ }
+ if err := postStartPlatformIsolation(cmd, isolation, root); err != nil {
+ terminateStartedCommand(cmd)
+ return err
+ }
+ return cmd.Wait()
+}
+
+func terminateStartedCommand(cmd *exec.Cmd) {
+ cleanupPendingPlatformResources(cmd)
+ if cmd == nil || cmd.Process == nil {
+ return
+ }
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+}
+
+// PrepareCommand mutates the command in-place so it inherits the configured
+// isolated environment before being started by the caller.
+func PrepareCommand(cmd *exec.Cmd) error {
+ isolation := CurrentConfig()
+ if err := Preflight(); err != nil {
+ return err
+ }
+ if isolation.Enabled {
+ root, err := ResolveInstanceRoot()
+ if err != nil {
+ return err
+ }
+ ApplyUserEnv(cmd, root)
+ if err := applyPlatformIsolation(cmd, isolation, root); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/pkg/isolation/runtime_test.go b/pkg/isolation/runtime_test.go
new file mode 100644
index 000000000..aca484bba
--- /dev/null
+++ b/pkg/isolation/runtime_test.go
@@ -0,0 +1,248 @@
+package isolation
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestResolveInstanceRoot_UsesPicoclawHome(t *testing.T) {
+ t.Setenv(config.EnvHome, "/custom/picoclaw/home")
+ root, err := ResolveInstanceRoot()
+ if err != nil {
+ t.Fatalf("ResolveInstanceRoot() error = %v", err)
+ }
+ if root != "/custom/picoclaw/home" {
+ t.Fatalf("ResolveInstanceRoot() = %q, want %q", root, "/custom/picoclaw/home")
+ }
+}
+
+func TestPrepareInstanceRoot_CreatesDirectories(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "instance")
+ if err := PrepareInstanceRoot(root); err != nil {
+ t.Fatalf("PrepareInstanceRoot() error = %v", err)
+ }
+ for _, dir := range InstanceDirs(root) {
+ if info, err := os.Stat(dir); err != nil {
+ t.Fatalf("os.Stat(%q): %v", dir, err)
+ } else if !info.IsDir() {
+ t.Fatalf("%q is not a directory", dir)
+ }
+ }
+}
+
+func TestInstanceDirs_UsesInstanceWorkspaceNotGlobalState(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "instance")
+ cfg := config.DefaultConfig()
+ cfg.Isolation.Enabled = true
+ cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "external-workspace")
+ Configure(cfg)
+ t.Cleanup(func() { Configure(config.DefaultConfig()) })
+
+ dirs := InstanceDirs(root)
+ wantWorkspace := filepath.Join(root, pkg.WorkspaceName)
+ found := false
+ for _, dir := range dirs {
+ if dir == wantWorkspace {
+ found = true
+ }
+ if dir == cfg.WorkspacePath() {
+ t.Fatalf("InstanceDirs() should not depend on process-wide workspace state: %q", dir)
+ }
+ }
+ if !found {
+ t.Fatalf("InstanceDirs() missing instance workspace dir %q", wantWorkspace)
+ }
+}
+
+func TestIsSupportedOn(t *testing.T) {
+ tests := []struct {
+ goos string
+ want bool
+ }{
+ {goos: "linux", want: true},
+ {goos: "windows", want: true},
+ {goos: "darwin", want: false},
+ {goos: "freebsd", want: false},
+ }
+ for _, tt := range tests {
+ if got := isSupportedOn(tt.goos); got != tt.want {
+ t.Fatalf("isSupportedOn(%q) = %v, want %v", tt.goos, got, tt.want)
+ }
+ }
+}
+
+func TestValidateExposePaths(t *testing.T) {
+ err := ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}})
+ if err != nil {
+ t.Fatalf("ValidateExposePaths() error = %v", err)
+ }
+
+ err = ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "bad"}})
+ if err == nil {
+ t.Fatal("ValidateExposePaths() expected invalid mode error")
+ }
+
+ err = ValidateExposePaths(
+ []config.ExposePath{
+ {Source: "/src", Target: "/dst", Mode: "ro"},
+ {Source: "/other", Target: "/dst", Mode: "rw"},
+ },
+ )
+ if err == nil {
+ t.Fatal("ValidateExposePaths() expected duplicate target error")
+ }
+}
+
+func TestMergeExposePaths_OverrideByTarget(t *testing.T) {
+ merged := MergeExposePaths(
+ []config.ExposePath{{Source: "/src-a", Target: "/dst", Mode: "ro"}},
+ []config.ExposePath{{Source: "/src-b", Target: "/dst", Mode: "rw"}},
+ )
+ if len(merged) != 1 {
+ t.Fatalf("MergeExposePaths len = %d, want 1", len(merged))
+ }
+ if got := merged[0]; got.Source != "/src-b" || got.Target != "/dst" || got.Mode != "rw" {
+ t.Fatalf("merged[0] = %+v, want source=/src-b target=/dst mode=rw", got)
+ }
+}
+
+func TestBuildLinuxMountPlan(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("linux-only default mount set")
+ }
+ plan := BuildLinuxMountPlan("/rootdir", []config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}})
+ if len(plan) == 0 {
+ t.Fatal("BuildLinuxMountPlan returned empty plan")
+ }
+ foundRoot := false
+ foundOverride := false
+ for _, rule := range plan {
+ if rule.Source == "/rootdir" && rule.Target == "/rootdir" && rule.Mode == "rw" {
+ foundRoot = true
+ }
+ if rule.Source == "/src" && rule.Target == "/dst" && rule.Mode == "ro" {
+ foundOverride = true
+ }
+ }
+ if !foundRoot {
+ t.Fatal("BuildLinuxMountPlan missing root mapping")
+ }
+ if !foundOverride {
+ t.Fatal("BuildLinuxMountPlan missing override mapping")
+ }
+}
+
+func TestBuildWindowsAccessRules(t *testing.T) {
+ rules := BuildWindowsAccessRules(
+ `C:\picoclaw`,
+ []config.ExposePath{{Source: `D:\data`, Target: `C:\mapped`, Mode: "ro"}},
+ )
+ if len(rules) == 0 {
+ t.Fatal("BuildWindowsAccessRules returned empty rules")
+ }
+ foundRoot := false
+ foundOverride := false
+ for _, rule := range rules {
+ if rule.Path == `C:\picoclaw` && rule.Mode == "rw" {
+ foundRoot = true
+ }
+ if rule.Path == `D:\data` && rule.Mode == "ro" {
+ foundOverride = true
+ }
+ }
+ if !foundRoot {
+ t.Fatal("BuildWindowsAccessRules missing root rule")
+ }
+ if !foundOverride {
+ t.Fatal("BuildWindowsAccessRules missing override rule")
+ }
+}
+
+func TestValidateWindowsExposePaths(t *testing.T) {
+ if err := validateWindowsExposePaths(nil); err != nil {
+ t.Fatalf("validateWindowsExposePaths(nil) error = %v", err)
+ }
+ err := validateWindowsExposePaths([]config.ExposePath{{Source: `D:\data`, Target: `D:\data`, Mode: "ro"}})
+ if err == nil {
+ t.Fatal("validateWindowsExposePaths() expected error for expose_paths")
+ }
+}
+
+func TestDefaultLinuxSystemExposePaths(t *testing.T) {
+ paths := defaultLinuxSystemExposePaths()
+ needed := map[string]bool{}
+ for _, path := range []string{"/etc/hosts", "/etc/nsswitch.conf", "/etc/ssl", "/usr/share/zoneinfo", "/etc/localtime"} {
+ if _, err := os.Stat(path); err == nil {
+ needed[path] = false
+ }
+ }
+ for _, item := range paths {
+ if _, ok := needed[item.Source]; ok {
+ needed[item.Source] = true
+ }
+ }
+ for path, found := range needed {
+ if !found {
+ t.Fatalf("defaultLinuxSystemExposePaths missing %s", path)
+ }
+ }
+}
+
+func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) {
+ existing := filepath.Join(t.TempDir(), "existing")
+ if err := os.MkdirAll(existing, 0o755); err != nil {
+ t.Fatalf("os.MkdirAll() error = %v", err)
+ }
+ filtered := existingExposePaths([]config.ExposePath{
+ {Source: existing, Target: existing, Mode: "ro"},
+ {Source: filepath.Join(t.TempDir(), "missing"), Target: "/missing", Mode: "ro"},
+ })
+ if len(filtered) != 1 {
+ t.Fatalf("existingExposePaths() len = %d, want 1", len(filtered))
+ }
+ if got := filtered[0]; got.Source != existing {
+ t.Fatalf("existingExposePaths()[0] = %+v, want source=%q", got, existing)
+ }
+}
+
+func TestPrepareCommand_AppliesUserEnv(t *testing.T) {
+ if !isSupportedOn(runtime.GOOS) {
+ t.Skipf("isolation not supported on %s", runtime.GOOS)
+ }
+ t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home"))
+ if runtime.GOOS == "linux" {
+ binDir := filepath.Join(t.TempDir(), "bin")
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ t.Fatalf("os.MkdirAll() error = %v", err)
+ }
+ fakeBwrap := filepath.Join(binDir, "bwrap")
+ if err := os.WriteFile(fakeBwrap, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatalf("os.WriteFile() error = %v", err)
+ }
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ }
+ cfg := config.DefaultConfig()
+ cfg.Isolation.Enabled = true
+ Configure(cfg)
+ t.Cleanup(func() { Configure(config.DefaultConfig()) })
+ cmd := exec.Command("sh", "-c", "true")
+ if err := PrepareCommand(cmd); err != nil {
+ t.Fatalf("PrepareCommand() error = %v", err)
+ }
+ hasHome := false
+ for _, env := range cmd.Env {
+ if len(env) > 5 && env[:5] == "HOME=" {
+ hasHome = true
+ break
+ }
+ }
+ if runtime.GOOS != "windows" && !hasHome {
+ t.Fatal("PrepareCommand() did not inject HOME")
+ }
+}
diff --git a/pkg/mcp/isolated_command_transport.go b/pkg/mcp/isolated_command_transport.go
new file mode 100644
index 000000000..f54b4af8b
--- /dev/null
+++ b/pkg/mcp/isolated_command_transport.go
@@ -0,0 +1,226 @@
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os/exec"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/jsonrpc"
+ sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/isolation"
+)
+
+var isolatedCommandTerminateDuration = 5 * time.Second
+
+// isolatedCommandTransport mirrors the SDK command transport but routes
+// process startup through pkg/isolation so Windows post-start hooks run too.
+type isolatedCommandTransport struct {
+ Command *exec.Cmd
+ TerminateDuration time.Duration
+}
+
+func (t *isolatedCommandTransport) Connect(ctx context.Context) (sdkmcp.Connection, error) {
+ stdout, err := t.Command.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ stdout = io.NopCloser(stdout)
+ stdin, err := t.Command.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ if err := isolation.Start(t.Command); err != nil {
+ return nil, err
+ }
+ td := t.TerminateDuration
+ if td <= 0 {
+ td = isolatedCommandTerminateDuration
+ }
+ return newIsolatedIOConn(&isolatedPipeRWC{cmd: t.Command, stdout: stdout, stdin: stdin, terminateDuration: td}), nil
+}
+
+type isolatedPipeRWC struct {
+ cmd *exec.Cmd
+ stdout io.ReadCloser
+ stdin io.WriteCloser
+ terminateDuration time.Duration
+}
+
+func (s *isolatedPipeRWC) Read(p []byte) (n int, err error) {
+ return s.stdout.Read(p)
+}
+
+func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) {
+ return s.stdin.Write(p)
+}
+
+func (s *isolatedPipeRWC) Close() error {
+ if err := s.stdin.Close(); err != nil {
+ return fmt.Errorf("closing stdin: %v", err)
+ }
+ resChan := make(chan error, 1)
+ go func() {
+ resChan <- s.cmd.Wait()
+ }()
+ wait := func() (error, bool) {
+ select {
+ case err := <-resChan:
+ return err, true
+ case <-time.After(s.terminateDuration):
+ }
+ return nil, false
+ }
+ if err, ok := wait(); ok {
+ return err
+ }
+ if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil {
+ if err, ok := wait(); ok {
+ return err
+ }
+ }
+ if err := s.cmd.Process.Kill(); err != nil {
+ return err
+ }
+ if err, ok := wait(); ok {
+ return err
+ }
+ return fmt.Errorf("unresponsive subprocess")
+}
+
+type isolatedIOConn struct {
+ writeMu sync.Mutex
+ rwc io.ReadWriteCloser
+ incoming <-chan isolatedMsgOrErr
+ queue []jsonrpc.Message
+ closeOnce sync.Once
+ closed chan struct{}
+ closeErr error
+}
+
+type isolatedMsgOrErr struct {
+ msg json.RawMessage
+ err error
+}
+
+func newIsolatedIOConn(rwc io.ReadWriteCloser) *isolatedIOConn {
+ incoming := make(chan isolatedMsgOrErr)
+ closed := make(chan struct{})
+ go func() {
+ dec := json.NewDecoder(rwc)
+ for {
+ var raw json.RawMessage
+ err := dec.Decode(&raw)
+ if err == nil {
+ var tr [1]byte
+ if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
+ if tr[0] != '\n' && tr[0] != '\r' {
+ err = fmt.Errorf("invalid trailing data at the end of stream")
+ }
+ } else if readErr != nil && readErr != io.EOF {
+ err = readErr
+ }
+ }
+ select {
+ case incoming <- isolatedMsgOrErr{msg: raw, err: err}:
+ case <-closed:
+ return
+ }
+ if err != nil {
+ return
+ }
+ }
+ }()
+ return &isolatedIOConn{rwc: rwc, incoming: incoming, closed: closed}
+}
+
+func (c *isolatedIOConn) SessionID() string { return "" }
+
+func (c *isolatedIOConn) Read(ctx context.Context) (jsonrpc.Message, error) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+ if len(c.queue) > 0 {
+ next := c.queue[0]
+ c.queue = c.queue[1:]
+ return next, nil
+ }
+ var raw json.RawMessage
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case v := <-c.incoming:
+ if v.err != nil {
+ return nil, v.err
+ }
+ raw = v.msg
+ case <-c.closed:
+ return nil, io.EOF
+ }
+ msgs, err := readIsolatedBatch(raw)
+ if err != nil {
+ return nil, err
+ }
+ c.queue = msgs[1:]
+ return msgs[0], nil
+}
+
+func readIsolatedBatch(data []byte) ([]jsonrpc.Message, error) {
+ var rawBatch []json.RawMessage
+ if err := json.Unmarshal(data, &rawBatch); err == nil {
+ if len(rawBatch) == 0 {
+ return nil, fmt.Errorf("empty batch")
+ }
+ msgs := make([]jsonrpc.Message, 0, len(rawBatch))
+ for _, raw := range rawBatch {
+ msg, err := jsonrpc.DecodeMessage(raw)
+ if err != nil {
+ return nil, err
+ }
+ msgs = append(msgs, msg)
+ }
+ return msgs, nil
+ }
+ msg, err := jsonrpc.DecodeMessage(data)
+ if err != nil {
+ return nil, err
+ }
+ return []jsonrpc.Message{msg}, nil
+}
+
+func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ data, err := jsonrpc.EncodeMessage(msg)
+ if err != nil {
+ return fmt.Errorf("marshaling message: %v", err)
+ }
+ data = append(data, '\n')
+ _, err = c.rwc.Write(data)
+ return err
+}
+
+func (c *isolatedIOConn) Close() error {
+ c.closeOnce.Do(func() {
+ c.closeErr = c.rwc.Close()
+ close(c.closed)
+ })
+ return c.closeErr
+}
+
+var (
+ _ sdkmcp.Transport = (*isolatedCommandTransport)(nil)
+ _ sdkmcp.Connection = (*isolatedIOConn)(nil)
+)
diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go
index 323df0312..f589f82a9 100644
--- a/pkg/mcp/manager.go
+++ b/pkg/mcp/manager.go
@@ -365,8 +365,7 @@ func (m *Manager) ConnectServer(
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
-
- transport = &mcp.CommandTransport{Command: cmd}
+ transport = &isolatedCommandTransport{Command: cmd}
default:
return fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)",
diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go
index 0b6d461c2..f7c1f42b2 100644
--- a/pkg/pid/pidfile.go
+++ b/pkg/pid/pidfile.go
@@ -151,6 +151,30 @@ func RemovePidFile(homePath string) {
os.Remove(pidPath)
}
+// RemovePidFileIfPID deletes the PID file only when the recorded PID matches
+// expectedPID. It returns true when the file is removed successfully.
+func RemovePidFileIfPID(homePath string, expectedPID int) bool {
+ if expectedPID <= 0 {
+ return false
+ }
+
+ pidMu.Lock()
+ defer pidMu.Unlock()
+
+ pidPath := pidFilePath(homePath)
+ data, err := readPidFileUnlocked(pidPath)
+ if err != nil {
+ return false
+ }
+ if data.PID != expectedPID {
+ return false
+ }
+ if err := os.Remove(pidPath); err != nil {
+ return false
+ }
+ return true
+}
+
// readPidFileUnlocked reads the PID file without acquiring the lock.
// Caller must hold pidMu.
func readPidFileUnlocked(pidPath string) (*PidFileData, error) {
diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go
index e54b93f4f..2da44bbbc 100644
--- a/pkg/pid/pidfile_test.go
+++ b/pkg/pid/pidfile_test.go
@@ -244,6 +244,40 @@ func TestRemovePidFileNonexistent(t *testing.T) {
RemovePidFile(dir)
}
+func TestRemovePidFileIfPID(t *testing.T) {
+ dir := tmpDir(t)
+
+ other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"}
+ raw, _ := json.MarshalIndent(other, "", " ")
+ path := filepath.Join(dir, pidFileName)
+ os.WriteFile(path, raw, 0o600)
+
+ removed := RemovePidFileIfPID(dir, 99999999)
+ if !removed {
+ t.Fatal("expected RemovePidFileIfPID to remove matching pid file")
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Error("PID file should be removed for matching expected PID")
+ }
+}
+
+func TestRemovePidFileIfPIDMismatch(t *testing.T) {
+ dir := tmpDir(t)
+
+ other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"}
+ raw, _ := json.MarshalIndent(other, "", " ")
+ path := filepath.Join(dir, pidFileName)
+ os.WriteFile(path, raw, 0o600)
+
+ removed := RemovePidFileIfPID(dir, 88888888)
+ if removed {
+ t.Fatal("expected RemovePidFileIfPID to keep non-matching pid file")
+ }
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ t.Error("PID file should NOT be removed for mismatching expected PID")
+ }
+}
+
// TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
dir := tmpDir(t)
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index 8a1890212..b5ab847d5 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -389,6 +389,7 @@ type antigravityJSONResponse struct {
Content struct {
Parts []struct {
Text string `json:"text,omitempty"`
+ Thought bool `json:"thought,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
@@ -406,6 +407,7 @@ type antigravityJSONResponse struct {
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string
+ var reasoningParts []string
var toolCalls []ToolCall
var usage *UsageInfo
var finishReason string
@@ -433,7 +435,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
for _, candidate := range resp.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
- contentParts = append(contentParts, part.Text)
+ if part.Thought {
+ reasoningParts = append(reasoningParts, part.Text)
+ } else {
+ contentParts = append(contentParts, part.Text)
+ }
}
if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
@@ -475,10 +481,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
}
return &LLMResponse{
- Content: strings.Join(contentParts, ""),
- ToolCalls: toolCalls,
- FinishReason: mappedFinish,
- Usage: usage,
+ Content: strings.Join(contentParts, ""),
+ ReasoningContent: strings.Join(reasoningParts, ""),
+ ToolCalls: toolCalls,
+ FinishReason: mappedFinish,
+ Usage: usage,
}, nil
}
diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/antigravity_provider_test.go
index 238765321..9155e2d56 100644
--- a/pkg/providers/antigravity_provider_test.go
+++ b/pkg/providers/antigravity_provider_test.go
@@ -54,3 +54,27 @@ func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
t.Fatalf("expected inferred tool name search_docs, got %q", got)
}
}
+
+func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) {
+ p := &AntigravityProvider{}
+ body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" +
+ "data: [DONE]\n"
+
+ resp, err := p.parseSSEResponse(body)
+ if err != nil {
+ t.Fatalf("parseSSEResponse() error = %v", err)
+ }
+
+ if resp.Content != "visible answer" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "visible answer")
+ }
+ if resp.ReasoningContent != "hidden reasoning" {
+ t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning")
+ }
+ if resp.FinishReason != "stop" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 216 {
+ t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216)
+ }
+}
diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go
index 40b581490..c3d98c555 100644
--- a/pkg/providers/claude_cli_provider.go
+++ b/pkg/providers/claude_cli_provider.go
@@ -7,6 +7,8 @@ import (
"fmt"
"os/exec"
"strings"
+
+ "github.com/sipeed/picoclaw/pkg/isolation"
)
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
@@ -49,7 +51,9 @@ func (p *ClaudeCliProvider) Chat(
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- if err := cmd.Run(); err != nil {
+ // Execute the CLI through the shared isolation wrapper so external provider
+ // processes honor the configured isolation policy.
+ if err := isolation.Run(cmd); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
stdoutStr := strings.TrimSpace(stdout.String())
switch {
diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go
index 13f53ad9e..a9c8b692a 100644
--- a/pkg/providers/codex_cli_provider.go
+++ b/pkg/providers/codex_cli_provider.go
@@ -8,6 +8,8 @@ import (
"fmt"
"os/exec"
"strings"
+
+ "github.com/sipeed/picoclaw/pkg/isolation"
)
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
@@ -56,7 +58,9 @@ func (p *CodexCliProvider) Chat(
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- err := cmd.Run()
+ // Execute the CLI through the shared isolation wrapper so external provider
+ // processes honor the configured isolation policy.
+ err := isolation.Run(cmd)
// Parse JSONL from stdout even if exit code is non-zero,
// because codex writes diagnostic noise to stderr (e.g. rollout errors)
diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go
index 0a4d5f34a..c107bb665 100644
--- a/pkg/providers/common/common_test.go
+++ b/pkg/providers/common/common_test.go
@@ -254,6 +254,22 @@ func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) {
}
}
+func TestDecodeToolCallArguments_ObjectJSON_NewlineEscape(t *testing.T) {
+ raw := json.RawMessage(`{"content":"line1\nline2"}`)
+ args := DecodeToolCallArguments(raw, "write_file")
+ if args["content"] != "line1\nline2" {
+ t.Errorf("content = %q, want newline-expanded string", args["content"])
+ }
+}
+
+func TestDecodeToolCallArguments_ObjectJSON_LiteralBackslashN(t *testing.T) {
+ raw := json.RawMessage(`{"content":"line1\\nline2"}`)
+ args := DecodeToolCallArguments(raw, "write_file")
+ if args["content"] != `line1\nline2` {
+ t.Errorf("content = %q, want literal backslash-n", args["content"])
+ }
+}
+
func TestDecodeToolCallArguments_StringJSON(t *testing.T) {
raw := json.RawMessage(`"{\"city\":\"SF\"}"`)
args := DecodeToolCallArguments(raw, "test")
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index f13dc646c..ab68b326a 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -114,7 +114,7 @@ func ResolveAPIBase(cfg *config.ModelConfig) string {
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
-// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
+// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
// See the switch on protocol in this function for the authoritative list.
// Returns the provider, the model ID (without protocol prefix), and any error.
@@ -218,7 +218,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
- case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
+ case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
@@ -242,6 +242,24 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders,
), modelID, nil
+ case "gemini":
+ if cfg.APIKey() == "" && cfg.APIBase == "" {
+ return nil, "", fmt.Errorf("api_key or api_base is required for gemini protocol (model: %s)", cfg.Model)
+ }
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = getDefaultAPIBase(protocol)
+ }
+ return NewGeminiProvider(
+ cfg.APIKey(),
+ apiBase,
+ cfg.Proxy,
+ userAgent,
+ cfg.RequestTimeout,
+ cfg.ExtraBody,
+ cfg.CustomHeaders,
+ ), modelID, nil
+
case "minimax":
// Minimax requires reasoning_split: true in the request body
if cfg.APIKey() == "" && cfg.APIBase == "" {
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index c362463ae..20cdd8a30 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -434,6 +434,62 @@ func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
}
}
+func TestCreateProviderFromConfig_Gemini(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-gemini",
+ Model: "gemini/gemini-2.5-flash",
+ }
+ cfg.SetAPIKey("test-key")
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "gemini-2.5-flash" {
+ t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
+ }
+ if _, ok := provider.(*GeminiProvider); !ok {
+ t.Fatalf("expected *GeminiProvider, got %T", provider)
+ }
+}
+
+func TestCreateProviderFromConfig_GeminiMissingAPIKey(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-gemini-no-key",
+ Model: "gemini/gemini-2.5-flash",
+ }
+
+ _, _, err := CreateProviderFromConfig(cfg)
+ if err == nil {
+ t.Fatal("CreateProviderFromConfig() expected error for missing gemini API key")
+ }
+}
+
+func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-gemini-custom-base",
+ Model: "gemini/gemini-2.5-flash",
+ APIBase: "https://proxy.example.com/v1beta",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "gemini-2.5-flash" {
+ t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
+ }
+ if _, ok := provider.(*GeminiProvider); !ok {
+ t.Fatalf("expected *GeminiProvider, got %T", provider)
+ }
+}
+
func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-claude-cli",
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
new file mode 100644
index 000000000..561387534
--- /dev/null
+++ b/pkg/providers/gemini_provider.go
@@ -0,0 +1,796 @@
+package providers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers/common"
+)
+
+const (
+ geminiDefaultAPIBase = "https://generativelanguage.googleapis.com/v1beta"
+ geminiDefaultModel = "gemini-2.0-flash"
+)
+
+type GeminiProvider struct {
+ apiKey string
+ apiBase string
+ httpClient *http.Client
+ extraBody map[string]any
+ customHeaders map[string]string
+ userAgent string
+}
+
+func NewGeminiProvider(
+ apiKey string,
+ apiBase string,
+ proxy string,
+ userAgent string,
+ requestTimeoutSeconds int,
+ extraBody map[string]any,
+ customHeaders map[string]string,
+) *GeminiProvider {
+ if strings.TrimSpace(apiBase) == "" {
+ apiBase = geminiDefaultAPIBase
+ }
+ client := common.NewHTTPClient(proxy)
+ if requestTimeoutSeconds > 0 {
+ client.Timeout = time.Duration(requestTimeoutSeconds) * time.Second
+ }
+
+ return &GeminiProvider{
+ apiKey: strings.TrimSpace(apiKey),
+ apiBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"),
+ httpClient: client,
+ extraBody: cloneAnyMap(extraBody),
+ customHeaders: cloneStringMap(customHeaders),
+ userAgent: strings.TrimSpace(userAgent),
+ }
+}
+
+func (p *GeminiProvider) GetDefaultModel() string {
+ return geminiDefaultModel
+}
+
+func (p *GeminiProvider) SupportsThinking() bool {
+ return true
+}
+
+func (p *GeminiProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ model = normalizeGeminiModel(model)
+ requestBody := p.buildRequestBody(messages, tools, model, options)
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ p.applyHeaders(req)
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, common.HandleErrorResponse(resp, p.apiBase)
+ }
+
+ var apiResp geminiGenerateContentResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return parseGeminiResponse(&apiResp), nil
+}
+
+func (p *GeminiProvider) ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ model = normalizeGeminiModel(model)
+ requestBody := p.buildRequestBody(messages, tools, model, options)
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/models/%s:streamGenerateContent?alt=sse", p.apiBase, model)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ p.applyHeaders(req)
+ req.Header.Set("Accept", "text/event-stream")
+
+ // Streaming should not use a whole-request timeout; context cancellation is the guard.
+ streamClient := &http.Client{Transport: p.httpClient.Transport}
+ resp, err := streamClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, common.HandleErrorResponse(resp, p.apiBase)
+ }
+
+ return parseGeminiStreamResponse(ctx, resp.Body, onChunk)
+}
+
+func (p *GeminiProvider) applyHeaders(req *http.Request) {
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("X-Goog-Api-Key", p.apiKey)
+ }
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
+ for k, v := range p.customHeaders {
+ if strings.TrimSpace(k) == "" {
+ continue
+ }
+ req.Header.Set(k, v)
+ }
+}
+
+func (p *GeminiProvider) buildRequestBody(
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) map[string]any {
+ contents := make([]geminiContent, 0, len(messages))
+ toolCallNames := make(map[string]string)
+ systemPrompts := make([]string, 0, 1)
+
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ if strings.TrimSpace(msg.Content) != "" {
+ systemPrompts = append(systemPrompts, msg.Content)
+ }
+
+ case "user":
+ if msg.ToolCallID != "" {
+ toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
+ contents = append(contents, geminiContent{
+ Role: "user",
+ Parts: []geminiPart{{
+ FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
+ }},
+ })
+ continue
+ }
+
+ parts := make([]geminiPart, 0, 1+len(msg.Media))
+ if strings.TrimSpace(msg.Content) != "" {
+ parts = append(parts, geminiPart{Text: msg.Content})
+ }
+ parts = append(parts, buildInlineMediaParts(msg.Media)...)
+ if len(parts) > 0 {
+ contents = append(contents, geminiContent{Role: "user", Parts: parts})
+ }
+
+ case "assistant":
+ content := geminiContent{Role: "model"}
+ if strings.TrimSpace(msg.Content) != "" {
+ content.Parts = append(content.Parts, geminiPart{Text: msg.Content})
+ }
+ for _, tc := range msg.ToolCalls {
+ toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
+ if toolName == "" {
+ continue
+ }
+ if tc.ID != "" {
+ toolCallNames[tc.ID] = toolName
+ }
+ part := geminiPart{
+ FunctionCall: &geminiFunctionCall{
+ Name: toolName,
+ Args: toolArgs,
+ ID: tc.ID,
+ },
+ }
+ if thoughtSignature != "" {
+ part.ThoughtSignature = thoughtSignature
+ }
+ content.Parts = append(content.Parts, part)
+ }
+ if len(content.Parts) > 0 {
+ contents = append(contents, content)
+ }
+
+ case "tool":
+ toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
+ contents = append(contents, geminiContent{
+ Role: "user",
+ Parts: []geminiPart{{
+ FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
+ }},
+ })
+ }
+ }
+
+ body := map[string]any{
+ "contents": contents,
+ }
+ if len(systemPrompts) > 0 {
+ systemParts := make([]geminiPart, 0, len(systemPrompts))
+ for _, prompt := range systemPrompts {
+ systemParts = append(systemParts, geminiPart{Text: prompt})
+ }
+ body["systemInstruction"] = &geminiContent{Parts: systemParts}
+ }
+
+ if len(tools) > 0 {
+ funcDecls := make([]geminiFunctionDeclaration, 0, len(tools))
+ for _, t := range tools {
+ if t.Type != "function" {
+ continue
+ }
+ funcDecls = append(funcDecls, geminiFunctionDeclaration{
+ Name: t.Function.Name,
+ Description: t.Function.Description,
+ Parameters: sanitizeSchemaForGemini(t.Function.Parameters),
+ })
+ }
+ if len(funcDecls) > 0 {
+ body["tools"] = []geminiTool{{FunctionDeclarations: funcDecls}}
+ }
+ }
+
+ generationConfig := make(map[string]any)
+ if val, ok := options["max_tokens"]; ok {
+ if maxTokens, ok := val.(int); ok && maxTokens > 0 {
+ generationConfig["maxOutputTokens"] = maxTokens
+ } else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
+ generationConfig["maxOutputTokens"] = int(maxTokens)
+ }
+ }
+ if temp, ok := options["temperature"].(float64); ok {
+ generationConfig["temperature"] = temp
+ }
+
+ if thinkingConfig := buildGeminiThinkingConfig(model, options); len(thinkingConfig) > 0 {
+ generationConfig["thinkingConfig"] = thinkingConfig
+ }
+
+ if len(generationConfig) > 0 {
+ body["generationConfig"] = generationConfig
+ }
+
+ for k, v := range p.extraBody {
+ body[k] = v
+ }
+
+ return body
+}
+
+func normalizeGeminiModel(model string) string {
+ model = strings.TrimSpace(model)
+ model = strings.TrimPrefix(model, "models/")
+ if strings.Contains(model, "/") {
+ _, modelID := ExtractProtocol(model)
+ if modelID != "" {
+ return modelID
+ }
+ }
+ if model == "" {
+ return geminiDefaultModel
+ }
+ return model
+}
+
+func mapGeminiThinkingLevel(level string) string {
+ switch strings.ToLower(strings.TrimSpace(level)) {
+ case "minimal", "off":
+ return "minimal"
+ case "low":
+ return "low"
+ case "medium":
+ return "medium"
+ case "high", "xhigh", "adaptive":
+ return "high"
+ default:
+ return ""
+ }
+}
+
+func buildGeminiThinkingConfig(model string, options map[string]any) map[string]any {
+ if !geminiModelSupportsThinkingConfig(model) {
+ return nil
+ }
+
+ config := map[string]any{}
+ rawLevel, _ := options["thinking_level"].(string)
+ rawLevel = strings.ToLower(strings.TrimSpace(rawLevel))
+ if rawLevel == "" {
+ // Align with agent-level default: unset means ThinkingOff.
+ rawLevel = "off"
+ }
+
+ includeThoughts := rawLevel != "off" && rawLevel != "minimal"
+ config["includeThoughts"] = includeThoughts
+
+ if isGemini25Model(model) {
+ if isGemini25ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
+ // Gemini 2.5 Pro cannot disable thinking; keep model-default thinking.
+ return config
+ }
+ if budget, ok := mapGeminiThinkingBudget(rawLevel); ok {
+ config["thinkingBudget"] = budget
+ }
+ return config
+ }
+
+ if isGemini3ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
+ // Gemini 3.x Pro does not support minimal thinking level.
+ return config
+ }
+
+ if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" {
+ config["thinkingLevel"] = thinkingLevel
+ }
+ return config
+}
+
+func geminiModelSupportsThinkingConfig(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return strings.Contains(lowerModel, "gemini-3") || isGemini25Model(lowerModel)
+}
+
+func isGemini25Model(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25")
+}
+
+func isGemini25ProModel(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return isGemini25Model(lowerModel) && strings.Contains(lowerModel, "pro")
+}
+
+func isGemini3ProModel(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return strings.Contains(lowerModel, "gemini-3") && strings.Contains(lowerModel, "pro")
+}
+
+func mapGeminiThinkingBudget(level string) (int, bool) {
+ level = strings.ToLower(strings.TrimSpace(level))
+ if level == "" {
+ return 0, false
+ }
+
+ switch level {
+ case "adaptive":
+ return -1, true
+ case "minimal":
+ return 0, true
+ case "off":
+ return 0, true
+ case "low":
+ return 1024, true
+ case "medium":
+ return 4096, true
+ case "high":
+ return 8192, true
+ case "xhigh":
+ return 16384, true
+ default:
+ return 0, false
+ }
+}
+
+func parseGeminiResponse(resp *geminiGenerateContentResponse) *LLMResponse {
+ contentParts := make([]string, 0)
+ reasoningParts := make([]string, 0)
+ toolCalls := make([]ToolCall, 0)
+ finishReason := ""
+
+ for _, candidate := range resp.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ if part.Thought {
+ reasoningParts = append(reasoningParts, part.Text)
+ } else {
+ contentParts = append(contentParts, part.Text)
+ }
+ }
+ if part.FunctionCall != nil {
+ toolCalls = append(toolCalls, buildGeminiToolCall(part))
+ }
+ }
+ if candidate.FinishReason != "" {
+ finishReason = candidate.FinishReason
+ }
+ }
+
+ var usage *UsageInfo
+ if resp.UsageMetadata.TotalTokenCount > 0 {
+ usage = &UsageInfo{
+ PromptTokens: resp.UsageMetadata.PromptTokenCount,
+ CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
+ TotalTokens: resp.UsageMetadata.TotalTokenCount,
+ }
+ }
+
+ return &LLMResponse{
+ Content: strings.Join(contentParts, ""),
+ ReasoningContent: strings.Join(reasoningParts, ""),
+ ToolCalls: toolCalls,
+ FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
+ Usage: usage,
+ }
+}
+
+func parseGeminiStreamResponse(
+ ctx context.Context,
+ reader io.Reader,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ var contentBuilder strings.Builder
+ var reasoningBuilder strings.Builder
+ var finishReason string
+ var usage *UsageInfo
+
+ toolCallsByID := make(map[string]ToolCall)
+ toolCallOrder := make([]string, 0)
+ fallbackIndex := 0
+
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
+ for scanner.Scan() {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
+ if data == "" {
+ continue
+ }
+ if data == "[DONE]" {
+ break
+ }
+
+ var chunk geminiGenerateContentResponse
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ return nil, fmt.Errorf("invalid gemini stream chunk: %w", err)
+ }
+
+ for _, candidate := range chunk.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ if part.Thought {
+ reasoningBuilder.WriteString(part.Text)
+ } else {
+ contentBuilder.WriteString(part.Text)
+ if onChunk != nil {
+ onChunk(contentBuilder.String())
+ }
+ }
+ }
+ if part.FunctionCall != nil {
+ tc := buildGeminiToolCall(part)
+ if strings.TrimSpace(tc.Name) == "" {
+ continue
+ }
+
+ key := strings.TrimSpace(part.FunctionCall.ID)
+ if key == "" {
+ if len(toolCallOrder) > 0 {
+ lastKey := toolCallOrder[len(toolCallOrder)-1]
+ if lastTC, exists := toolCallsByID[lastKey]; exists && lastTC.Name == tc.Name {
+ key = lastKey
+ }
+ }
+ if key == "" {
+ fallbackIndex++
+ key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex)
+ }
+ }
+
+ tc.ID = key
+ if _, exists := toolCallsByID[key]; !exists {
+ toolCallOrder = append(toolCallOrder, key)
+ }
+ toolCallsByID[key] = tc
+ }
+ }
+ if candidate.FinishReason != "" {
+ finishReason = candidate.FinishReason
+ }
+ }
+
+ if chunk.UsageMetadata.TotalTokenCount > 0 {
+ usage = &UsageInfo{
+ PromptTokens: chunk.UsageMetadata.PromptTokenCount,
+ CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
+ TotalTokens: chunk.UsageMetadata.TotalTokenCount,
+ }
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("streaming read error: %w", err)
+ }
+
+ toolCalls := make([]ToolCall, 0, len(toolCallOrder))
+ for _, key := range toolCallOrder {
+ toolCalls = append(toolCalls, toolCallsByID[key])
+ }
+
+ return &LLMResponse{
+ Content: contentBuilder.String(),
+ ReasoningContent: reasoningBuilder.String(),
+ ToolCalls: toolCalls,
+ FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
+ Usage: usage,
+ }, nil
+}
+
+func normalizeGeminiFinishReason(reason string, toolCalls int) string {
+ if toolCalls > 0 {
+ return "tool_calls"
+ }
+
+ switch strings.ToUpper(strings.TrimSpace(reason)) {
+ case "MAX_TOKENS":
+ return "length"
+ case "", "STOP":
+ return "stop"
+ default:
+ return strings.ToLower(strings.TrimSpace(reason))
+ }
+}
+
+func buildGeminiToolCall(part geminiPart) ToolCall {
+ if part.FunctionCall == nil {
+ return ToolCall{}
+ }
+
+ args := part.FunctionCall.Args
+ if args == nil {
+ args = make(map[string]any)
+ }
+ argsJSON, _ := json.Marshal(args)
+ thoughtSignature := extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake)
+
+ toolCall := ToolCall{
+ ID: part.FunctionCall.ID,
+ Name: part.FunctionCall.Name,
+ Arguments: args,
+ ThoughtSignature: thoughtSignature,
+ Function: &FunctionCall{
+ Name: part.FunctionCall.Name,
+ Arguments: string(argsJSON),
+ ThoughtSignature: thoughtSignature,
+ },
+ }
+
+ if thoughtSignature != "" {
+ toolCall.ExtraContent = &ExtraContent{
+ Google: &GoogleExtra{ThoughtSignature: thoughtSignature},
+ }
+ }
+ if strings.TrimSpace(toolCall.ID) == "" {
+ toolCall.ID = fmt.Sprintf("call_%s_%d", toolCall.Name, time.Now().UnixNano())
+ }
+
+ return toolCall
+}
+
+func buildInlineMediaParts(media []string) []geminiPart {
+ parts := make([]geminiPart, 0, len(media))
+ for _, mediaURL := range media {
+ mimeType, data, ok := parseBase64DataURL(mediaURL)
+ if !ok {
+ continue
+ }
+ parts = append(parts, geminiPart{
+ InlineData: &geminiInlineData{
+ MIMEType: mimeType,
+ Data: data,
+ },
+ })
+ }
+ return parts
+}
+
+func buildGeminiFunctionResponse(
+ toolName string,
+ toolCallID string,
+ result string,
+ media []string,
+) *geminiFunctionResponse {
+ response := &geminiFunctionResponse{
+ ID: toolCallID,
+ Name: toolName,
+ Response: map[string]any{
+ "result": result,
+ },
+ }
+
+ if parts := buildFunctionResponseMediaParts(media); len(parts) > 0 {
+ response.Parts = parts
+ }
+
+ return response
+}
+
+func buildFunctionResponseMediaParts(media []string) []geminiFunctionResponsePart {
+ parts := make([]geminiFunctionResponsePart, 0, len(media))
+ for i, mediaURL := range media {
+ mimeType, data, ok := parseBase64DataURL(mediaURL)
+ if !ok {
+ continue
+ }
+ parts = append(parts, geminiFunctionResponsePart{
+ InlineData: &geminiInlineData{
+ MIMEType: mimeType,
+ Data: data,
+ DisplayName: defaultFunctionResponseDisplayName(mimeType, i+1),
+ },
+ })
+ }
+ return parts
+}
+
+func defaultFunctionResponseDisplayName(mimeType string, index int) string {
+ suffix := "bin"
+ switch strings.ToLower(strings.TrimSpace(mimeType)) {
+ case "image/png":
+ suffix = "png"
+ case "image/jpeg":
+ suffix = "jpg"
+ case "image/webp":
+ suffix = "webp"
+ case "application/pdf":
+ suffix = "pdf"
+ case "text/plain":
+ suffix = "txt"
+ }
+ return fmt.Sprintf("attachment-%d.%s", index, suffix)
+}
+
+func parseBase64DataURL(mediaURL string) (mimeType string, data string, ok bool) {
+ if !strings.HasPrefix(mediaURL, "data:") {
+ return "", "", false
+ }
+
+ payload := strings.TrimPrefix(mediaURL, "data:")
+ header, data, found := strings.Cut(payload, ",")
+ if !found {
+ return "", "", false
+ }
+ mimeType, params, _ := strings.Cut(header, ";")
+ mimeType = strings.TrimSpace(mimeType)
+ data = strings.TrimSpace(data)
+ if mimeType == "" || data == "" {
+ return "", "", false
+ }
+ if !strings.Contains(strings.ToLower(params), "base64") {
+ return "", "", false
+ }
+ return mimeType, data, true
+}
+
+func cloneAnyMap(in map[string]any) map[string]any {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func cloneStringMap(in map[string]string) map[string]string {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+type geminiGenerateContentResponse struct {
+ Candidates []struct {
+ Content struct {
+ Role string `json:"role"`
+ Parts []geminiPart `json:"parts"`
+ } `json:"content"`
+ FinishReason string `json:"finishReason"`
+ } `json:"candidates"`
+ UsageMetadata struct {
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ } `json:"usageMetadata"`
+}
+
+type geminiContent struct {
+ Role string `json:"role,omitempty"`
+ Parts []geminiPart `json:"parts"`
+}
+
+type geminiPart struct {
+ Text string `json:"text,omitempty"`
+ Thought bool `json:"thought,omitempty"`
+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
+ ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
+ InlineData *geminiInlineData `json:"inlineData,omitempty"`
+ FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
+ FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
+}
+
+type geminiInlineData struct {
+ MIMEType string `json:"mimeType"`
+ Data string `json:"data"`
+ DisplayName string `json:"displayName,omitempty"`
+}
+
+type geminiFunctionCall struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Args map[string]any `json:"args,omitempty"`
+}
+
+type geminiFunctionResponse struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Response map[string]any `json:"response"`
+ Parts []geminiFunctionResponsePart `json:"parts,omitempty"`
+}
+
+type geminiFunctionResponsePart struct {
+ InlineData *geminiInlineData `json:"inlineData,omitempty"`
+}
+
+type geminiTool struct {
+ FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"`
+}
+
+type geminiFunctionDeclaration struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters any `json:"parameters,omitempty"`
+}
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
new file mode 100644
index 000000000..a0ab748eb
--- /dev/null
+++ b/pkg/providers/gemini_provider_test.go
@@ -0,0 +1,763 @@
+package providers
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) {
+ var capturedBody map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Fatalf("method = %s, want POST", r.Method)
+ }
+ if !strings.Contains(r.URL.Path, ":generateContent") {
+ t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path)
+ }
+ if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" {
+ t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key")
+ }
+ if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil {
+ t.Fatalf("decode request body: %v", err)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "candidates": []any{
+ map[string]any{
+ "content": map[string]any{
+ "role": "model",
+ "parts": []any{
+ map[string]any{"text": "hidden", "thought": true},
+ map[string]any{"text": "visible"},
+ map[string]any{
+ "functionCall": map[string]any{
+ "id": "call_1",
+ "name": "search",
+ "args": map[string]any{"q": "hi"},
+ },
+ "thoughtSignature": "sig-1",
+ },
+ },
+ },
+ "finishReason": "STOP",
+ },
+ },
+ "usageMetadata": map[string]any{
+ "promptTokenCount": 2,
+ "candidatesTokenCount": 3,
+ "totalTokenCount": 5,
+ },
+ })
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil)
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-3-flash-preview",
+ map[string]any{"thinking_level": "high"},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if resp.Content != "visible" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "visible")
+ }
+ if resp.ReasoningContent != "hidden" {
+ t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden")
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 5 {
+ t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage)
+ }
+ if len(resp.ToolCalls) != 1 {
+ t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
+ }
+ if resp.ToolCalls[0].ID != "call_1" {
+ t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
+ }
+ if resp.ToolCalls[0].Name != "search" {
+ t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search")
+ }
+ if resp.ToolCalls[0].ThoughtSignature != "sig-1" {
+ t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1")
+ }
+ if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) {
+ t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function)
+ }
+
+ generationConfig, ok := capturedBody["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("request missing generationConfig: %#v", capturedBody)
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("request missing thinkingConfig: %#v", generationConfig)
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
+ t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
+ }
+ if got := thinkingConfig["thinkingLevel"]; got != "high" {
+ t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high")
+ }
+}
+
+func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.Contains(r.URL.Path, ":streamGenerateContent") {
+ t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path)
+ }
+ if got := r.URL.Query().Get("alt"); got != "sse" {
+ t.Fatalf("alt query = %q, want %q", got, "sse")
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ chunks := []map[string]any{
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{"text": "think ", "thought": true},
+ map[string]any{"text": "Hello "},
+ },
+ },
+ }},
+ },
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{"text": "World"},
+ map[string]any{
+ "functionCall": map[string]any{
+ "id": "call_stream",
+ "name": "search",
+ "args": map[string]any{"q": "stream"},
+ },
+ },
+ },
+ },
+ "finishReason": "STOP",
+ }},
+ "usageMetadata": map[string]any{
+ "promptTokenCount": 1,
+ "candidatesTokenCount": 2,
+ "totalTokenCount": 3,
+ },
+ },
+ }
+
+ for _, chunk := range chunks {
+ raw, err := json.Marshal(chunk)
+ if err != nil {
+ t.Fatalf("marshal chunk: %v", err)
+ }
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
+ t.Fatalf("write chunk: %v", err)
+ }
+ flusher.Flush()
+ }
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ updates := make([]string, 0)
+ resp, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ func(accumulated string) {
+ updates = append(updates, accumulated)
+ },
+ )
+ if err != nil {
+ t.Fatalf("ChatStream() error = %v", err)
+ }
+ if resp.Content != "Hello World" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "Hello World")
+ }
+ if resp.ReasoningContent != "think " {
+ t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ")
+ }
+ if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" {
+ t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls)
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
+ t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage)
+ }
+ if len(updates) < 2 || updates[len(updates)-1] != "Hello World" {
+ t.Fatalf("stream updates = %#v, expected final accumulated text", updates)
+ }
+}
+
+func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ _, _ = fmt.Fprint(w, "data: \n\n")
+ flusher.Flush()
+
+ chunk := map[string]any{
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{map[string]any{"text": "ok"}},
+ },
+ "finishReason": "STOP",
+ }},
+ }
+ raw, err := json.Marshal(chunk)
+ if err != nil {
+ t.Fatalf("marshal chunk: %v", err)
+ }
+ _, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
+ flusher.Flush()
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ resp, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("ChatStream() error = %v", err)
+ }
+ if resp.Content != "ok" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "ok")
+ }
+}
+
+func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ _, _ = fmt.Fprint(w, "data: {invalid-json}\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ _, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ nil,
+ )
+ if err == nil {
+ t.Fatal("ChatStream() expected error for invalid SSE data frame")
+ }
+ if !strings.Contains(err.Error(), "invalid gemini stream chunk") {
+ t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk")
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+
+ body := provider.buildRequestBody(
+ []Message{{
+ Role: "assistant",
+ ToolCalls: []ToolCall{{
+ ID: "call_1",
+ Name: "search",
+ Arguments: map[string]any{"q": "hello"},
+ Function: &FunctionCall{
+ Name: "search",
+ Arguments: `{"q":"hello"}`,
+ ThoughtSignature: "sig-1",
+ },
+ }},
+ }},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ jsonBody := string(raw)
+
+ if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
+ t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
+ }
+ if strings.Contains(jsonBody, `"thought_signature"`) {
+ t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody)
+ }
+}
+
+func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ chunks := []map[string]any{
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{
+ "functionCall": map[string]any{
+ "name": "search",
+ "args": map[string]any{"q": "first"},
+ },
+ },
+ },
+ },
+ }},
+ },
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{
+ "functionCall": map[string]any{
+ "name": "search",
+ "args": map[string]any{"q": "second"},
+ },
+ },
+ },
+ },
+ "finishReason": "STOP",
+ }},
+ },
+ }
+
+ for _, chunk := range chunks {
+ raw, err := json.Marshal(chunk)
+ if err != nil {
+ t.Fatalf("marshal chunk: %v", err)
+ }
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
+ t.Fatalf("write chunk: %v", err)
+ }
+ flusher.Flush()
+ }
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ resp, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("ChatStream() error = %v", err)
+ }
+ if len(resp.ToolCalls) != 1 {
+ t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
+ }
+ tc := resp.ToolCalls[0]
+ if tc.ID != "search#1" {
+ t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1")
+ }
+ if tc.Name != "search" {
+ t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search")
+ }
+ if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" {
+ t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments)
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+}
+
+func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+
+ body := provider.buildRequestBody(
+ []Message{{
+ Role: "user",
+ Content: "analyze attachments",
+ Media: []string{
+ "data:application/pdf;base64,UEZERGF0YQ==",
+ "data:image/png;base64,aW1hZ2VEYXRh",
+ },
+ }},
+ nil,
+ "gemini-3-flash-preview",
+ map[string]any{
+ "thinking_level": "low",
+ "max_tokens": 128,
+ "temperature": 0.2,
+ },
+ )
+
+ contents, ok := body["contents"].([]geminiContent)
+ if !ok || len(contents) != 1 {
+ t.Fatalf("contents = %#v, want one gemini content", body["contents"])
+ }
+ parts := contents[0].Parts
+ mimeSet := map[string]bool{}
+ for _, part := range parts {
+ if part.InlineData != nil {
+ mimeSet[part.InlineData.MIMEType] = true
+ }
+ }
+ if !mimeSet["application/pdf"] {
+ t.Fatalf("inline media missing application/pdf: %#v", parts)
+ }
+ if !mimeSet["image/png"] {
+ t.Fatalf("inline media missing image/png: %#v", parts)
+ }
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ if got := generationConfig["maxOutputTokens"]; got != 128 {
+ t.Fatalf("maxOutputTokens = %#v, want 128", got)
+ }
+ if got := generationConfig["temperature"]; got != 0.2 {
+ t.Fatalf("temperature = %#v, want 0.2", got)
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
+ }
+ if got := thinkingConfig["thinkingLevel"]; got != "low" {
+ t.Fatalf("thinkingLevel = %#v, want %q", got, "low")
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ map[string]any{"thinking_level": "medium"},
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if got := thinkingConfig["thinkingBudget"]; got != 4096 {
+ t.Fatalf("thinkingBudget = %#v, want 4096", got)
+ }
+ if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
+ t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.0-flash-exp",
+ map[string]any{"thinking_level": "high"},
+ )
+
+ if _, ok := body["generationConfig"]; ok {
+ t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if got := thinkingConfig["thinkingBudget"]; got != 0 {
+ t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got)
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-3-flash-preview",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if got := thinkingConfig["thinkingLevel"]; got != "minimal" {
+ t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got)
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-pro",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+ if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget {
+ t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-3.1-pro",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+ if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
+ t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{
+ {Role: "system", Content: "You are helpful."},
+ {Role: "system", Content: "Be concise."},
+ {Role: "user", Content: "hello"},
+ },
+ nil,
+ "gemini-3-flash-preview",
+ nil,
+ )
+
+ systemInstruction, ok := body["systemInstruction"].(*geminiContent)
+ if !ok || systemInstruction == nil {
+ t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"])
+ }
+ if len(systemInstruction.Parts) != 2 {
+ t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts))
+ }
+ if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." {
+ t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{
+ {
+ Role: "assistant",
+ ToolCalls: []ToolCall{{
+ ID: "call_1",
+ Name: "load_image",
+ Arguments: map[string]any{"path": "demo.png"},
+ }},
+ },
+ {
+ Role: "tool",
+ ToolCallID: "call_1",
+ Content: "tool result",
+ Media: []string{
+ "data:image/png;base64,aW1hZ2VEYXRh",
+ "data:application/pdf;base64,UEZERGF0YQ==",
+ },
+ },
+ },
+ nil,
+ "gemini-3-flash-preview",
+ nil,
+ )
+
+ contents, ok := body["contents"].([]geminiContent)
+ if !ok || len(contents) != 2 {
+ t.Fatalf("contents = %#v, want two content entries", body["contents"])
+ }
+ parts := contents[1].Parts
+ if len(parts) != 1 || parts[0].FunctionResponse == nil {
+ t.Fatalf("tool response part = %#v, want functionResponse", parts)
+ }
+ response := parts[0].FunctionResponse
+ if response.Name != "load_image" {
+ t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image")
+ }
+ if response.Response["result"] != "tool result" {
+ t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response)
+ }
+ if len(response.Parts) != 2 {
+ t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts))
+ }
+}
+
+func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
+ t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
+ }
+ if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
+ t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "candidates": []any{
+ map[string]any{
+ "content": map[string]any{
+ "parts": []any{map[string]any{"text": "ok"}},
+ },
+ "finishReason": "STOP",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider(
+ "",
+ server.URL,
+ "",
+ "",
+ 0,
+ nil,
+ map[string]string{"Authorization": "Bearer test-token"},
+ )
+
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if resp.Content != "ok" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "ok")
+ }
+}
+
+func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
+ t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "candidates": []any{
+ map[string]any{
+ "content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}},
+ "finishReason": "STOP",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil)
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if resp.Content != "ok" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "ok")
+ }
+}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index d25a0fce4..98a70cfd2 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
+ "maps"
"net/http"
"net/url"
"strings"
@@ -181,9 +182,7 @@ func (p *Provider) buildRequestBody(
// Merge extra body fields configured per-provider/model.
// These are injected last so they take precedence over defaults.
- for k, v := range p.extraBody {
- requestBody[k] = v
- }
+ maps.Copy(requestBody, p.extraBody)
return requestBody
}
diff --git a/pkg/seahorse/fts5_sanitize.go b/pkg/seahorse/fts5_sanitize.go
new file mode 100644
index 000000000..baa91e1b6
--- /dev/null
+++ b/pkg/seahorse/fts5_sanitize.go
@@ -0,0 +1,70 @@
+package seahorse
+
+import (
+ "regexp"
+ "strings"
+)
+
+// phraseRegex matches complete quoted phrases like "exact phrase".
+// Compiled once at package level to avoid per-call overhead.
+var phraseRegex = regexp.MustCompile(`"([^"]+)"`)
+
+// SanitizeFTS5Query escapes user input for safe use in an FTS5 MATCH expression.
+//
+// FTS5 treats certain characters as operators:
+// - `-` (NOT), `+` (required), `*` (prefix), `^` (initial token)
+// - `OR`, `AND`, `NOT`, `NEAR` (boolean/proximity operators)
+// - `:` (column filter — e.g. `agent:foo` means "search column agent")
+// - `"` (phrase query), `(` `)` (grouping)
+//
+// Strategy: wrap each whitespace-delimited token in double quotes so FTS5
+// treats it as a literal phrase token. User-quoted phrases ("...") are
+// preserved as-is. Internal double quotes are stripped. Empty tokens are
+// dropped. Tokens are joined with spaces (implicit AND).
+//
+// Returns empty string for blank input so callers can skip the MATCH query.
+//
+// Examples:
+//
+// "sub-agent restrict" → `"sub-agent" "restrict"`
+// "lcm_expand OR crash" → `"lcm_expand" "OR" "crash"`
+// `hello "world"` → `"hello" "world"`
+func SanitizeFTS5Query(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return ""
+ }
+
+ // Preserve user-quoted phrases: extract "..." groups first, then tokenize the rest.
+ var parts []string
+ lastIndex := 0
+
+ for _, loc := range phraseRegex.FindAllStringIndex(raw, -1) {
+ // Process unquoted text before this phrase
+ before := raw[lastIndex:loc[0]]
+ for _, t := range strings.Fields(before) {
+ t = strings.ReplaceAll(t, `"`, "")
+ if t != "" {
+ parts = append(parts, `"`+t+`"`)
+ }
+ }
+ // Preserve the phrase as-is (strip internal quotes for safety)
+ phrase := strings.TrimSpace(strings.ReplaceAll(raw[loc[0]+1:loc[1]-1], `"`, ""))
+ if phrase != "" {
+ parts = append(parts, `"`+phrase+`"`)
+ }
+ lastIndex = loc[1]
+ }
+
+ // Process unquoted text after last phrase
+ for _, t := range strings.Fields(raw[lastIndex:]) {
+ t = strings.ReplaceAll(t, `"`, "")
+ if t != "" {
+ parts = append(parts, `"`+t+`"`)
+ }
+ }
+
+ if len(parts) == 0 {
+ return ""
+ }
+ return strings.Join(parts, " ")
+}
diff --git a/pkg/seahorse/fts5_sanitize_test.go b/pkg/seahorse/fts5_sanitize_test.go
new file mode 100644
index 000000000..8b430f414
--- /dev/null
+++ b/pkg/seahorse/fts5_sanitize_test.go
@@ -0,0 +1,237 @@
+package seahorse
+
+import (
+ "context"
+ "testing"
+)
+
+func TestSanitizeFTS5Query(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ // Basic tokens
+ {"hello world", `"hello" "world"`},
+ {"database", `"database"`},
+
+ // FTS5 operators neutralized
+ {"sub-agent", `"sub-agent"`},
+ {"agent:main", `"agent:main"`},
+ {"+required", `"+required"`},
+ {"prefix*", `"prefix*"`},
+ {"^initial", `"^initial"`},
+ {"crash OR restart", `"crash" "OR" "restart"`},
+ {"NOT excluded", `"NOT" "excluded"`},
+ {"(grouped)", `"(grouped)"`},
+
+ // User-quoted phrases preserved
+ {`"exact phrase" other`, `"exact phrase" "other"`},
+ {`before "middle phrase" after`, `"before" "middle phrase" "after"`},
+
+ // Unmatched quotes stripped
+ {`"unmatched`, `"unmatched"`},
+ {`hello"world`, `"helloworld"`},
+
+ // NEAR operator neutralized
+ {"NEAR/2 agent", `"NEAR/2" "agent"`},
+
+ // Empty input
+ {"", ""},
+ {" ", ""},
+
+ // CJK unaffected
+ {"数据库连接", `"数据库连接"`},
+ {"数据库 连接", `"数据库" "连接"`},
+ {"sub-agent重启", `"sub-agent重启"`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := SanitizeFTS5Query(tt.input)
+ if got != tt.want {
+ t.Errorf("SanitizeFTS5Query(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestFTS5SpecialCharsShouldNotError verifies that user input containing
+// FTS5 special characters does not cause errors when searching.
+func TestFTS5SpecialCharsShouldNotError(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-sanitize")
+ re := &RetrievalEngine{store: s}
+
+ // Seed data with content containing special characters
+ s.AddMessage(ctx, conv.ConversationID, "user", "the sub-agent restarted after crash", 10)
+ s.AddMessage(ctx, conv.ConversationID, "assistant", "agent:main session restored successfully", 10)
+ s.AddMessage(ctx, conv.ConversationID, "user", "use NOT operator in the query filter", 10)
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "sub-agent crashed and was restarted by the orchestrator",
+ TokenCount: 50,
+ })
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "agent:main handled the restart procedure",
+ TokenCount: 50,
+ })
+
+ tests := []struct {
+ name string
+ pattern string
+ wantSummaryMin int
+ wantMessageMin int
+ }{
+ {
+ name: "hyphen in search term",
+ pattern: "sub-agent",
+ wantSummaryMin: 1,
+ wantMessageMin: 1,
+ },
+ {
+ name: "colon in search term",
+ pattern: "agent:main",
+ wantSummaryMin: 1,
+ wantMessageMin: 1,
+ },
+ {
+ name: "unmatched double quote",
+ pattern: `"sub-agent`,
+ wantSummaryMin: 1,
+ wantMessageMin: 1,
+ },
+ {
+ name: "plus sign",
+ pattern: "+agent",
+ wantSummaryMin: 0,
+ wantMessageMin: 0,
+ },
+ {
+ name: "parentheses",
+ pattern: "(agent)",
+ wantSummaryMin: 0,
+ wantMessageMin: 0,
+ },
+ {
+ name: "NOT keyword",
+ pattern: "NOT operator",
+ wantSummaryMin: 0,
+ wantMessageMin: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := re.Grep(ctx, GrepInput{
+ Pattern: tt.pattern,
+ Scope: "both",
+ })
+ if err != nil {
+ t.Fatalf("Grep(%q) returned error: %v", tt.pattern, err)
+ }
+ if len(result.Summaries) < tt.wantSummaryMin {
+ t.Errorf("Grep(%q) summaries = %d, want >= %d",
+ tt.pattern, len(result.Summaries), tt.wantSummaryMin)
+ }
+ if len(result.Messages) < tt.wantMessageMin {
+ t.Errorf("Grep(%q) messages = %d, want >= %d",
+ tt.pattern, len(result.Messages), tt.wantMessageMin)
+ }
+ })
+ }
+}
+
+// TestFTS5OperatorsNotInterpreted verifies that FTS5 operators are treated
+// as literal text, not as query syntax. Each case constructs data where
+// boolean interpretation would produce different results than literal matching.
+func TestFTS5OperatorsNotInterpreted(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-operators")
+ re := &RetrievalEngine{store: s}
+
+ // "restart only" — contains "restart" but NOT "crash".
+ // If OR is treated as boolean, "crash OR restart" would match this.
+ // With sanitization (literal AND), it should NOT match.
+ s.AddMessage(ctx, conv.ConversationID, "user", "restart the service now please", 10)
+
+ // "subcommand" — starts with "sub" but is not "sub-agent".
+ // If * is treated as prefix wildcard, "sub*" would match this.
+ // With sanitization (literal "sub*"), it should NOT match.
+ s.AddMessage(ctx, conv.ConversationID, "user", "run the subcommand to deploy", 10)
+
+ // "agent grouped" — contains "agent" but not "(agent)".
+ // If () is treated as grouping, "(agent)" would match this.
+ // With sanitization (literal "(agent)"), it should NOT match.
+ s.AddMessage(ctx, conv.ConversationID, "user", "the agent processed the request", 10)
+
+ // Same patterns in summaries
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "restart procedure completed without any crash involvement",
+ TokenCount: 50,
+ })
+ s.CreateSummary(ctx, CreateSummaryInput{
+ ConversationID: conv.ConversationID,
+ Kind: SummaryKindLeaf,
+ Depth: 0,
+ Content: "subprocess and subcommand management overview",
+ TokenCount: 50,
+ })
+
+ t.Run("OR must not be boolean", func(t *testing.T) {
+ // "crash OR restart" as literal means all three tokens must appear.
+ // The message "restart the service now please" has "restart" but not "crash" or "OR".
+ // Boolean OR would match it; literal AND should not.
+ result, err := re.Grep(ctx, GrepInput{Pattern: "crash OR restart", Scope: "message"})
+ if err != nil {
+ t.Fatalf("Grep returned error: %v", err)
+ }
+ if len(result.Messages) != 0 {
+ t.Errorf(
+ "OR treated as boolean: got %d messages, want 0 (only-restart message should not match literal AND of 'crash','OR','restart')",
+ len(result.Messages),
+ )
+ }
+ })
+
+ t.Run("asterisk must not be prefix wildcard", func(t *testing.T) {
+ // "sub*" as literal means exact trigram match on "sub*".
+ // The message "run the subcommand to deploy" contains "sub" as prefix.
+ // Prefix wildcard would match it; literal should not.
+ result, err := re.Grep(ctx, GrepInput{Pattern: "sub*", Scope: "message"})
+ if err != nil {
+ t.Fatalf("Grep returned error: %v", err)
+ }
+ if len(result.Messages) != 0 {
+ t.Errorf(
+ "asterisk treated as prefix wildcard: got %d messages, want 0 (literal 'sub*' does not appear in any message)",
+ len(result.Messages),
+ )
+ }
+ })
+
+ t.Run("parentheses must not be grouping", func(t *testing.T) {
+ // "(agent)" as literal means exact trigram match on "(agent)".
+ // The message "the agent processed the request" contains "agent" without parens.
+ // Grouping would match it; literal should not.
+ result, err := re.Grep(ctx, GrepInput{Pattern: "(agent)", Scope: "message"})
+ if err != nil {
+ t.Fatalf("Grep returned error: %v", err)
+ }
+ if len(result.Messages) != 0 {
+ t.Errorf(
+ "parentheses treated as grouping: got %d messages, want 0 (literal '(agent)' does not appear in any message)",
+ len(result.Messages),
+ )
+ }
+ })
+}
diff --git a/pkg/seahorse/schema_test.go b/pkg/seahorse/schema_test.go
index 17879f66c..e11e6e96e 100644
--- a/pkg/seahorse/schema_test.go
+++ b/pkg/seahorse/schema_test.go
@@ -2,14 +2,26 @@ package seahorse
import (
"database/sql"
+ "fmt"
+ "strings"
+ "sync/atomic"
"testing"
_ "modernc.org/sqlite"
)
+var testDBCounter uint64
+
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
- db, err := sql.Open("sqlite", ":memory:")
+
+ n := atomic.AddUint64(&testDBCounter, 1)
+ testName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
+ // Use a shared in-memory database so concurrent goroutines/connections in tests
+ // observe the same schema/data.
+ dsn := fmt.Sprintf("file:seahorse_test_%s_%d?mode=memory&cache=shared", testName, n)
+
+ db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open test db: %v", err)
}
diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go
index 3d85c7b9c..3026533b2 100644
--- a/pkg/seahorse/store.go
+++ b/pkg/seahorse/store.go
@@ -1178,9 +1178,14 @@ func (s *Store) SearchSummaries(ctx context.Context, input SearchInput) ([]Searc
}
func (s *Store) searchSummariesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ sanitized := SanitizeFTS5Query(input.Pattern)
+ if sanitized == "" {
+ return nil, nil
+ }
+
// Build WHERE clause for filters (used in both count and data queries)
whereClauses := []string{"summaries_fts MATCH ?"}
- args := []any{input.Pattern}
+ args := []any{sanitized}
if input.ConversationID > 0 && !input.AllConversations {
whereClauses = append(whereClauses, "s.conversation_id = ?")
@@ -1326,9 +1331,14 @@ func (s *Store) SearchMessages(ctx context.Context, input SearchInput) ([]Search
}
func (s *Store) searchMessagesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) {
+ sanitized := SanitizeFTS5Query(input.Pattern)
+ if sanitized == "" {
+ return nil, nil
+ }
+
// Build WHERE clause for filters (used in both count and data queries)
whereClauses := []string{"messages_fts MATCH ?"}
- args := []any{input.Pattern}
+ args := []any{sanitized}
if input.ConversationID > 0 && !input.AllConversations {
whereClauses = append(whereClauses, "m.conversation_id = ?")
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index 09d1f545b..c527dab54 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -29,7 +29,7 @@ func (t *EditFileTool) Name() string {
}
func (t *EditFileTool) Description() string {
- return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n."
+ return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *EditFileTool) Parameters() map[string]any {
@@ -42,11 +42,11 @@ func (t *EditFileTool) Parameters() map[string]any {
},
"old_text": map[string]any{
"type": "string",
- "description": "The exact text to find and replace. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
+ "description": "The exact text to find and replace. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"new_text": map[string]any{
"type": "string",
- "description": "The text to replace with. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
+ "description": "The text to replace with. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "old_text", "new_text"},
@@ -92,7 +92,7 @@ func (t *AppendFileTool) Name() string {
}
func (t *AppendFileTool) Description() string {
- return "Append content to the end of a file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n."
+ return "Append content to the end of a file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n."
}
func (t *AppendFileTool) Parameters() map[string]any {
@@ -105,7 +105,7 @@ func (t *AppendFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
- "description": "The content to append. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
+ "description": "The content to append. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
},
"required": []string{"path", "content"},
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 52d77f665..0f6811f33 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -870,7 +870,7 @@ func (t *WriteFileTool) Name() string {
}
func (t *WriteFileTool) Description() string {
- return "Write content to a file. In `function.arguments`, use \\n for a newline and \\\\n for a literal backslash-n sequence. Content is written byte-for-byte after argument decoding. If the file already exists, you must set overwrite=true to replace it."
+ return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it."
}
func (t *WriteFileTool) Parameters() map[string]any {
@@ -883,7 +883,7 @@ func (t *WriteFileTool) Parameters() map[string]any {
},
"content": map[string]any{
"type": "string",
- "description": "Content to write to the file. In `function.arguments`, use \\n for newline and \\\\n for literal backslash-n.",
+ "description": "Content to write to the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.",
},
"overwrite": map[string]any{
"type": "boolean",
diff --git a/pkg/tools/message.go b/pkg/tools/message.go
index ec04f042e..39440e5a3 100644
--- a/pkg/tools/message.go
+++ b/pkg/tools/message.go
@@ -3,14 +3,21 @@ package tools
import (
"context"
"fmt"
- "sync/atomic"
+ "sync"
)
type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
+// sentTarget records the channel+chatID that the message tool sent to.
+type sentTarget struct {
+ Channel string
+ ChatID string
+}
+
type MessageTool struct {
sendCallback SendCallbackWithContext
- sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
+ mu sync.Mutex
+ sentTargets []sentTarget // Tracks all targets sent to in the current round
}
func NewMessageTool() *MessageTool {
@@ -53,12 +60,30 @@ func (t *MessageTool) Parameters() map[string]any {
// ResetSentInRound resets the per-round send tracker.
// Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound() {
- t.sentInRound.Store(false)
+ t.mu.Lock()
+ t.sentTargets = t.sentTargets[:0]
+ t.mu.Unlock()
}
// HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound() bool {
- return t.sentInRound.Load()
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return len(t.sentTargets) > 0
+}
+
+// HasSentTo returns true if the message tool sent to the specific channel+chatID
+// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
+// the final response when the message tool only sent to a different conversation.
+func (t *MessageTool) HasSentTo(channel, chatID string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for _, st := range t.sentTargets {
+ if st.Channel == channel && st.ChatID == chatID {
+ return true
+ }
+ }
+ return false
}
func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
@@ -98,7 +123,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
}
}
- t.sentInRound.Store(true)
+ t.mu.Lock()
+ t.sentTargets = append(t.sentTargets, sentTarget{Channel: channel, ChatID: chatID})
+ t.mu.Unlock()
+
// Silent: user already received the message directly
return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index d2971f3f8..a570ac9ec 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -20,6 +20,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/isolation"
)
var (
@@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex
func NewExecToolWithConfig(
workingDir string,
restrict bool,
- config *config.Config,
+ cfg *config.Config,
allowPaths ...[]*regexp.Regexp,
) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
@@ -131,8 +132,8 @@ func NewExecToolWithConfig(
allowedPathPatterns = allowPaths[0]
}
- if config != nil {
- execConfig := config.Tools.Exec
+ if cfg != nil {
+ execConfig := cfg.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
@@ -163,8 +164,8 @@ func NewExecToolWithConfig(
}
var timeout time.Duration
- if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
- timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
+ if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 {
+ timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
}
return &ExecTool{
@@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- if err := cmd.Start(); err != nil {
+ // Route shell execution through the shared isolation entry point so exec tool
+ // subprocesses receive the same isolation policy as other integrations.
+ if err := isolation.Start(cmd); err != nil {
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
}
@@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
session.stdinWriter = stdinWriter
}
- if err := cmd.Start(); err != nil {
+ // Background sessions use the same startup path so isolation stays consistent
+ // with synchronous exec runs.
+ if err := isolation.Start(cmd); err != nil {
if session.ptyMaster != nil {
session.ptyMaster.Close()
}
diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go
new file mode 100644
index 000000000..a6c8895b8
--- /dev/null
+++ b/pkg/utils/tool_feedback.go
@@ -0,0 +1,9 @@
+package utils
+
+import "fmt"
+
+// FormatToolFeedbackMessage renders the tool name and arguments preview in the
+// same markdown shape used by live tool feedback and session reconstruction.
+func FormatToolFeedbackMessage(toolName, argsPreview string) string {
+ return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview)
+}
diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go
new file mode 100644
index 000000000..d7a55ce6b
--- /dev/null
+++ b/pkg/utils/tool_feedback_test.go
@@ -0,0 +1,11 @@
+package utils
+
+import "testing"
+
+func TestFormatToolFeedbackMessage(t *testing.T) {
+ got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}")
+ want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```"
+ if got != want {
+ t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
+ }
+}
diff --git a/web/Makefile b/web/Makefile
index 891c170c2..cf5ea774a 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -1,4 +1,5 @@
-.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean
+.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
+ build-android-arm64 build-android-bundle
# Go variables
GO?=CGO_ENABLED=0 go
@@ -9,6 +10,7 @@ GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
# Build variables
BUILD_DIR=build
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
+OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64
FRONTEND_DIR=frontend
BACKEND_DIR=backend
BACKEND_DIST=$(BACKEND_DIR)/dist
@@ -91,6 +93,17 @@ build: build-frontend
@mkdir -p "$$(dirname "$(OUTPUT)")"
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
+# Build launcher for Android ARM64 (frontend must already be built)
+build-android-arm64: build-frontend
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
+
+# Build launcher for all Android architectures
+build-android-bundle: build-frontend
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
+ @echo "All Android launcher builds complete"
+
build-frontend:
@if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
[ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \
diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go
index 22f7ec2c2..3cfc3e20d 100644
--- a/web/backend/api/auth.go
+++ b/web/backend/api/auth.go
@@ -1,8 +1,10 @@
package api
import (
+ "context"
"crypto/subtle"
"encoding/json"
+ "fmt"
"io"
"net/http"
"strings"
@@ -10,34 +12,47 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
-// LauncherAuthRouteOpts configures dashboard token login handlers.
+// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
+// Implemented by dashboardauth.Store; a nil value falls back to the legacy
+// static-token comparison.
+type PasswordStore interface {
+ IsInitialized(ctx context.Context) (bool, error)
+ SetPassword(ctx context.Context, plain string) error
+ VerifyPassword(ctx context.Context, plain string) (bool, error)
+}
+
+// LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct {
+ // DashboardToken is the fallback plaintext token used when PasswordStore is
+ // nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
- // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
- TokenHelp LauncherAuthTokenHelp
-}
-
-// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
-type LauncherAuthTokenHelp struct {
- EnvVarName string `json:"env_var_name"`
- LogFileAbs string `json:"log_file,omitempty"`
- ConfigFileAbs string `json:"config_file,omitempty"`
- TrayCopyMenu bool `json:"tray_copy_menu"`
- ConsoleStdout bool `json:"console_stdout"`
+ // PasswordStore enables bcrypt-backed password persistence. When non-nil and
+ // initialized, web-form login verifies against the stored hash instead of
+ // the plaintext DashboardToken.
+ PasswordStore PasswordStore
+ // StoreError holds the error returned when opening the password store. When
+ // non-nil and PasswordStore is nil, the auth endpoints surface a recovery
+ // message instead of an opaque 501/503.
+ StoreError error
}
type launcherAuthLoginBody struct {
- Token string `json:"token"`
+ Password string `json:"password"`
+}
+
+type launcherAuthSetupBody struct {
+ Password string `json:"password"`
+ Confirm string `json:"confirm"`
}
type launcherAuthStatusResponse struct {
- Authenticated bool `json:"authenticated"`
- TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
}
-// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
+// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
@@ -47,22 +62,52 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
- tokenHelp: opts.TokenHelp,
+ store: opts.PasswordStore,
+ storeErr: opts.StoreError,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
+ mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
- tokenHelp LauncherAuthTokenHelp
+ store PasswordStore
+ storeErr error // set when the store failed to open; drives recovery messages
loginLimit *loginRateLimiter
}
+func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
+ return h.store == nil && h.storeErr == nil && h.token != ""
+}
+
+// isStoreInitialized safely queries the store.
+// Returns (true, nil) when legacy token auth is active without a password store.
+// Returns (false, nil) when no store/token fallback is configured.
+// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
+// "uninitialized", to keep auth fail-closed.
+// Exception: handleLogin swallows storeErr and falls back to token auth so
+// that a corrupt DB does not lock out all access.
+func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
+ if h.store == nil {
+ if h.storeErr != nil {
+ return false, fmt.Errorf(
+ "password store unavailable (%w); "+
+ "to recover, stop the application, delete the database file and restart ",
+ h.storeErr)
+ }
+ if h.usesLegacyTokenAuth() {
+ return true, nil
+ }
+ return false, nil
+ }
+ return h.store.IsInitialized(ctx)
+}
+
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
@@ -77,10 +122,39 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
- in := strings.TrimSpace(body.Token)
- if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
+ in := strings.TrimSpace(body.Password)
+ var ok bool
+
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ if h.storeErr != nil {
+ // Store failed to open at startup — token login remains available.
+ initialized = false
+ } else {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "%v", initErr)
+ return
+ }
+ }
+
+ if initialized && h.store != nil {
+ // Bcrypt path: verify against the stored hash.
+ var err error
+ ok, err = h.store.VerifyPassword(r.Context(), in)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "password verification failed: %v", err)
+ return
+ }
+ } else {
+ // Fallback: constant-time compare against the plaintext token.
+ ok = len(in) == len(h.token) &&
+ subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
+ }
+
+ if !ok {
w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":"invalid token"}`))
+ _, _ = w.Write([]byte(`{"error":"invalid password"}`))
return
}
@@ -121,23 +195,108 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- ok := false
+ authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
- ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
+ authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
- if ok {
- _, _ = w.Write([]byte(`{"authenticated":true}`))
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
return
}
resp := launcherAuthStatusResponse{
- Authenticated: false,
- TokenHelp: &h.tokenHelp,
+ Authenticated: authed,
+ Initialized: initialized,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte(`{"error":"internal error"}`))
+ writeErrorf(w, "marshal response failed: %v", err)
return
}
_, _ = w.Write(enc)
}
+
+// handleSetup sets or changes the dashboard password.
+//
+// Rules:
+// - If the store has no password yet, the endpoint is open (no session required).
+// - If a password is already set, the caller must hold a valid session cookie.
+func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ if h.usesLegacyTokenAuth() {
+ w.WriteHeader(http.StatusNotImplemented)
+ _, _ = w.Write(
+ []byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`),
+ )
+ return
+ }
+
+ if h.store == nil {
+ w.WriteHeader(http.StatusNotImplemented)
+ _, _ = w.Write([]byte(`{"error":"password store not configured"}`))
+ return
+ }
+
+ initialized, initErr := h.isStoreInitialized(r.Context())
+ if initErr != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ writeErrorf(w, "%v", initErr)
+ return
+ }
+
+ // If already initialized, require an active session (change-password flow).
+ if initialized {
+ authed := false
+ if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
+ authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
+ }
+ if !authed {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`))
+ return
+ }
+ }
+
+ var body launcherAuthSetupBody
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
+ return
+ }
+
+ pw := strings.TrimSpace(body.Password)
+ if pw == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"password must not be empty"}`))
+ return
+ }
+ if pw != strings.TrimSpace(body.Confirm) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"passwords do not match"}`))
+ return
+ }
+ if len([]rune(pw)) < 8 {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`))
+ return
+ }
+
+ if err := h.store.SetPassword(r.Context(), pw); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ writeErrorf(w, "failed to save password: %v", err)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"status":"ok"}`))
+}
+
+// writeErrorf writes a JSON error response with a formatted message.
+// json.Marshal is used to safely escape the message string.
+func writeErrorf(w http.ResponseWriter, format string, args ...any) {
+ msg, _ := json.Marshal(fmt.Sprintf(format, args...))
+ _, _ = w.Write([]byte(`{"error":` + string(msg) + `}`))
+}
diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go
index d2624a440..58f819ec6 100644
--- a/web/backend/api/auth_test.go
+++ b/web/backend/api/auth_test.go
@@ -23,12 +23,6 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{
- EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
- LogFileAbs: "/tmp/launcher.log",
- TrayCopyMenu: true,
- ConsoleStdout: false,
- },
})
t.Run("status_unauthenticated", func(t *testing.T) {
@@ -38,23 +32,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
- Authenticated bool `json:"authenticated"`
- TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
- if body.Authenticated || body.TokenHelp == nil {
- t.Fatalf("unexpected body: %+v", body)
- }
- if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
- t.Fatalf("token_help = %+v", body.TokenHelp)
+ if body.Authenticated {
+ t.Fatalf("unexpected authenticated=true: %+v", body)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@@ -84,6 +75,67 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
})
}
+func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
+ key := make([]byte, 32)
+ const tok = "legacy-fallback-token"
+ sess := middleware.SessionCookieValue(key, tok)
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ DashboardToken: tok,
+ SessionCookie: sess,
+ })
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var body struct {
+ Authenticated bool `json:"authenticated"`
+ Initialized bool `json:"initialized"`
+ }
+ if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if !body.Initialized {
+ t.Fatalf("initialized = false, want true in legacy token fallback mode")
+ }
+ if body.Authenticated {
+ t.Fatalf("unexpected authenticated=true: %+v", body)
+ }
+
+ rec = httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
+ key := make([]byte, 32)
+ sess := middleware.SessionCookieValue(key, "legacy-token")
+ mux := http.NewServeMux()
+ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
+ DashboardToken: "legacy-token",
+ SessionCookie: sess,
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/auth/setup",
+ strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotImplemented {
+ t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
@@ -91,7 +143,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
@@ -125,11 +176,10 @@ func TestLauncherAuthLoginRateLimit(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
- wrongBody := `{"token":"wrong"}`
+ wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@@ -187,7 +237,6 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@@ -206,7 +255,6 @@ func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index 139f2c8c8..8994e9c60 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -108,6 +108,8 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
return client.Get(url)
}
+var gatewayProcessMatcher = isLikelyGatewayProcess
+
// getGatewayHealth checks the gateway health endpoint and returns the status response.
// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.
func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) {
@@ -117,7 +119,7 @@ func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*
gateway.mu.Lock()
if d := gateway.pidData; d != nil && d.Port > 0 {
port = d.Port
- host = d.Host
+ host = gatewayProbeHost(d.Host)
}
gateway.mu.Unlock()
if port == 0 {
@@ -150,6 +152,150 @@ func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusRes
return &healthResponse, resp.StatusCode, nil
}
+// isLikelyGatewayProcess returns whether PID appears to be a picoclaw gateway
+// process plus whether inspection was conclusive on this platform/environment.
+func isLikelyGatewayProcess(pid int) (bool, bool) {
+ if pid <= 0 {
+ return false, true
+ }
+
+ if runtime.GOOS == "windows" {
+ psCmd := fmt.Sprintf(
+ `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`,
+ pid,
+ )
+ out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
+ if err == nil {
+ cmdline := strings.TrimSpace(string(out))
+ if cmdline != "" {
+ return looksLikeGatewayCommandLine(cmdline), true
+ }
+ }
+
+ // Fallback: determine only whether the process still exists.
+ out, err = exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
+ if err != nil {
+ return false, false
+ }
+ line := strings.ToLower(strings.TrimSpace(string(out)))
+ if line == "" {
+ return false, true
+ }
+ // A CSV row means the process exists, but may have a custom executable
+ // name we cannot classify here.
+ if strings.HasPrefix(line, "\"") {
+ if strings.Contains(line, "\"picoclaw.exe\"") {
+ return true, true
+ }
+ return false, false
+ }
+ if strings.Contains(line, "no tasks are running") {
+ return false, true
+ }
+ return false, true
+ }
+
+ out, err := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output()
+ if err != nil {
+ return false, false
+ }
+ cmdline := strings.ToLower(strings.TrimSpace(string(out)))
+ if cmdline == "" {
+ return false, true
+ }
+ return looksLikeGatewayCommandLine(cmdline), true
+}
+
+// looksLikeGatewayCommandLine checks whether a process command line likely
+// represents "picoclaw gateway ..." regardless of executable filename.
+func looksLikeGatewayCommandLine(cmdline string) bool {
+ fields := strings.Fields(strings.ToLower(strings.TrimSpace(cmdline)))
+ if len(fields) == 0 {
+ return false
+ }
+ for _, f := range fields {
+ token := strings.Trim(f, `"'`)
+ if token == "gateway" || strings.HasSuffix(token, "/gateway") || strings.HasSuffix(token, `\gateway`) {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *Handler) getGatewayHealthForPidData(
+ pidData *ppid.PidFileData,
+ cfg *config.Config,
+ timeout time.Duration,
+) (*health.StatusResponse, int, error) {
+ if pidData == nil {
+ return nil, 0, errors.New("nil pid data")
+ }
+
+ port := pidData.Port
+ if port == 0 {
+ port = 18790
+ if cfg != nil && cfg.Gateway.Port != 0 {
+ port = cfg.Gateway.Port
+ }
+ }
+
+ host := gatewayProbeHost(strings.TrimSpace(pidData.Host))
+ if host == "" {
+ host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
+ }
+ if host == "" {
+ host = "127.0.0.1"
+ }
+
+ url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
+ return getGatewayHealthByURL(url, timeout)
+}
+
+func (h *Handler) validateGatewayPidData(
+ pidData *ppid.PidFileData,
+ cfg *config.Config,
+) (ok bool, decisive bool, reason string) {
+ if pidData == nil || pidData.PID <= 0 {
+ return false, true, "invalid pid data"
+ }
+
+ if gatewayProcess, inspected := gatewayProcessMatcher(pidData.PID); inspected {
+ if !gatewayProcess {
+ return false, true, "pid process command is not picoclaw gateway"
+ }
+ return true, true, ""
+ }
+
+ healthResp, statusCode, err := h.getGatewayHealthForPidData(pidData, cfg, 800*time.Millisecond)
+ if err != nil {
+ return false, false, fmt.Sprintf("health probe failed: %v", err)
+ }
+ if statusCode != http.StatusOK {
+ return false, false, fmt.Sprintf("health endpoint returned status %d", statusCode)
+ }
+ if healthResp.PID > 0 && healthResp.PID != pidData.PID {
+ return false, true, fmt.Sprintf("health pid mismatch: pidFile=%d, health=%d", pidData.PID, healthResp.PID)
+ }
+ return true, true, ""
+}
+
+func (h *Handler) sanitizeGatewayPidData(pidData *ppid.PidFileData, cfg *config.Config) *ppid.PidFileData {
+ if pidData == nil {
+ return nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, cfg)
+ if ok {
+ return pidData
+ }
+
+ logger.Warnf("ignore pid file for PID %d: %s", pidData.PID, reason)
+ if decisive && ppid.RemovePidFileIfPID(globalConfigDir(), pidData.PID) {
+ logger.Warnf("removed stale pid file for PID %d", pidData.PID)
+ }
+ return nil
+}
+
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
@@ -164,7 +310,7 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
// Check PID file first to detect an already-running gateway.
- pidData := ppid.ReadPidFileWithCheck(globalConfigDir())
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
if pidData != nil {
gateway.mu.Lock()
ready, reason, err := h.gatewayStartReady()
@@ -472,6 +618,11 @@ func stopGatewayLocked() (int, error) {
}
pid := gateway.cmd.Process.Pid
+ if !gateway.owned {
+ if isGateway, inspected := gatewayProcessMatcher(pid); inspected && !isGateway {
+ return pid, fmt.Errorf("refuse to stop non-gateway process (PID %d)", pid)
+ }
+ }
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
@@ -681,7 +832,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
// Check PID file first to detect an already-running gateway.
- pidData := ppid.ReadPidFileWithCheck(globalConfigDir())
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil)
if pidData != nil {
pid := pidData.PID
gateway.mu.Lock()
@@ -807,9 +958,22 @@ func (h *Handler) RestartGateway() (int, error) {
gateway.mu.Lock()
previousCmd := gateway.cmd
+ previousOwned := gateway.owned
setGatewayRuntimeStatusLocked("restarting")
gateway.mu.Unlock()
+ if previousCmd != nil && previousCmd.Process != nil && !previousOwned {
+ if isGateway, inspected := gatewayProcessMatcher(previousCmd.Process.Pid); inspected && !isGateway {
+ logger.Warnf("refuse restarting non-gateway process (PID: %d)", previousCmd.Process.Pid)
+ gateway.mu.Lock()
+ if gateway.cmd == previousCmd {
+ setGatewayRuntimeStatusLocked("running")
+ }
+ gateway.mu.Unlock()
+ return 0, fmt.Errorf("refuse to restart non-gateway process (PID %d)", previousCmd.Process.Pid)
+ }
+ }
+
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
gateway.mu.Lock()
if gateway.cmd == previousCmd {
@@ -921,7 +1085,7 @@ func (h *Handler) gatewayStatusData() map[string]any {
}
// Primary detection: read PID file and check if process is alive.
- pidData := ppid.ReadPidFileWithCheck(globalConfigDir())
+ pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), cfg)
if pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index 1f5f13e27..d300b657c 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -15,8 +15,6 @@ import (
"testing"
"time"
- "github.com/stretchr/testify/require"
-
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
@@ -40,6 +38,36 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd {
return cmd
}
+func startGatewayLikeProcess(t *testing.T) *exec.Cmd {
+ t.Helper()
+
+ var cmd *exec.Cmd
+ if runtime.GOOS == "windows" {
+ t.Skip("gateway-like process commandline check is not deterministic on Windows tests")
+ }
+ cmd = exec.Command("sh", "-c", "sleep 30 # picoclaw gateway")
+
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+
+ return cmd
+}
+
+func writeTestPidFile(t *testing.T, data ppid.PidFileData) string {
+ t.Helper()
+
+ path := filepath.Join(globalConfigDir(), ".picoclaw.pid")
+ raw, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ t.Fatalf("marshal pid file: %v", err)
+ }
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatalf("write pid file: %v", err)
+ }
+ return path
+}
+
func mockGatewayHealthResponse(statusCode, pid int) *http.Response {
return &http.Response{
StatusCode: statusCode,
@@ -68,12 +96,14 @@ func resetGatewayTestState(t *testing.T) {
t.Helper()
originalHealthGet := gatewayHealthGet
+ originalProcessMatcher := gatewayProcessMatcher
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
+ gatewayProcessMatcher = originalProcessMatcher
gatewayRestartGracePeriod = originalRestartGracePeriod
gatewayRestartForceKillWindow = originalRestartForceKillWindow
gatewayRestartPollInterval = originalRestartPollInterval
@@ -105,6 +135,105 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
}
}
+func TestLooksLikeGatewayCommandLine(t *testing.T) {
+ cases := []struct {
+ name string
+ cmdline string
+ want bool
+ }{
+ {
+ name: "default picoclaw gateway",
+ cmdline: "/usr/local/bin/picoclaw gateway -E",
+ want: true,
+ },
+ {
+ name: "renamed binary with gateway subcommand",
+ cmdline: "/opt/bin/custom-claw gateway -E -d",
+ want: true,
+ },
+ {
+ name: "standalone gateway binary path",
+ cmdline: "/opt/bin/gateway -E",
+ want: true,
+ },
+ {
+ name: "non gateway process",
+ cmdline: "/bin/sleep 30",
+ want: false,
+ },
+ {
+ name: "gateway substring only",
+ cmdline: "/opt/bin/gatewayd --serve",
+ want: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := looksLikeGatewayCommandLine(tc.cmdline)
+ if got != tc.want {
+ t.Fatalf("looksLikeGatewayCommandLine(%q) = %v, want %v", tc.cmdline, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestValidateGatewayPidDataAcceptsHealthWhenMatcherInconclusive(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ const testPID = 34567
+ pidData := &ppid.PidFileData{
+ PID: testPID,
+ Host: "127.0.0.1",
+ Port: 18790,
+ }
+
+ gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, testPID), nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
+ if !ok {
+ t.Fatalf("validateGatewayPidData() ok = false, want true (reason=%q)", reason)
+ }
+ if !decisive {
+ t.Fatalf("validateGatewayPidData() decisive = false, want true")
+ }
+}
+
+func TestValidateGatewayPidDataRejectsHealthPidMismatchWhenMatcherInconclusive(t *testing.T) {
+ resetGatewayTestState(t)
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ pidData := &ppid.PidFileData{
+ PID: 34567,
+ Host: "127.0.0.1",
+ Port: 18790,
+ }
+
+ gatewayProcessMatcher = func(int) (bool, bool) { return false, false }
+ gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
+ return mockGatewayHealthResponse(http.StatusOK, 99999), nil
+ }
+
+ ok, decisive, reason := h.validateGatewayPidData(pidData, nil)
+ if ok {
+ t.Fatalf("validateGatewayPidData() ok = true, want false")
+ }
+ if !decisive {
+ t.Fatalf("validateGatewayPidData() decisive = false, want true")
+ }
+ if !strings.Contains(reason, "health pid mismatch") {
+ t.Fatalf("validateGatewayPidData() reason = %q, want contains %q", reason, "health pid mismatch")
+ }
+}
+
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@@ -533,7 +662,7 @@ func TestGatewayStatusDowngradesRunningWhenTrackedProcessExitedAndPidFileMissing
}
}
-func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
+func TestGatewayStatusIgnoresAndRemovesPidFileForNonGatewayProcess(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@@ -549,6 +678,87 @@ func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
_ = cmd.Wait()
})
+ pidPath := writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "stale-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ if got := body["gateway_status"]; got != "stopped" {
+ t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
+ }
+ if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
+ t.Fatal("stale pid file should be removed for non-gateway process")
+ }
+}
+
+func TestGatewayStopRefusesNonGatewayAttachedProcess(t *testing.T) {
+ resetGatewayTestState(t)
+ if runtime.GOOS == "windows" {
+ t.Skip("commandline-based process type check is best-effort on Windows")
+ }
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startLongRunningProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ gateway.mu.Lock()
+ gateway.cmd = cmd
+ gateway.owned = false
+ setGatewayRuntimeStatusLocked("running")
+ gateway.mu.Unlock()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/gateway/stop", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
+ }
+ if !isCmdProcessAliveLocked(cmd) {
+ t.Fatal("non-gateway process should not be terminated by /api/gateway/stop")
+ }
+}
+
+func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
+ resetGatewayTestState(t)
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
@@ -557,8 +767,12 @@ func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
}
- _, err := ppid.WritePidFile(globalConfigDir(), "localhost", 0)
- require.NoError(t, err)
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
@@ -583,6 +797,7 @@ func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
resetGatewayTestState(t)
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
@@ -601,16 +816,23 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
- process, err := os.FindProcess(os.Getpid())
- if err != nil {
- t.Fatalf("FindProcess() error = %v", err)
- }
- _, err = ppid.WritePidFile(globalConfigDir(), "localhost", 0)
- require.NoError(t, err)
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: "127.0.0.1",
+ Port: 18790,
+ })
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
- gateway.cmd = &exec.Cmd{Process: process}
+ gateway.cmd = cmd
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index 95bbfd2c1..1d6b46d32 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -64,7 +64,7 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
gatewayAvailable := false
// Prefer fresh PID file data when available.
- if pidData := ppid.ReadPidFileWithCheck(globalConfigDir()); pidData != nil {
+ if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
setGatewayRuntimeStatusLocked("running")
diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go
index 04888fde7..af5ba205f 100644
--- a/web/backend/api/pico_test.go
+++ b/web/backend/api/pico_test.go
@@ -308,6 +308,10 @@ func TestHandlePicoSetup_Response(t *testing.T) {
}
func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
@@ -339,9 +343,19 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
- if _, err := ppid.WritePidFile(globalConfigDir(), cfg.Gateway.Host, cfg.Gateway.Port); err != nil {
- t.Fatalf("WritePidFile() error = %v", err)
- }
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
origPidData := gateway.pidData
origPicoToken := gateway.picoToken
t.Cleanup(func() {
@@ -392,6 +406,10 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
}
func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
@@ -416,9 +434,19 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
- if _, err := ppid.WritePidFile(globalConfigDir(), cfg.Gateway.Host, cfg.Gateway.Port); err != nil {
- t.Fatalf("WritePidFile() error = %v", err)
- }
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ writeTestPidFile(t, ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
+ })
t.Cleanup(func() {
ppid.RemovePidFile(globalConfigDir())
})
@@ -450,6 +478,10 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) {
}
func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
+ origMatcher := gatewayProcessMatcher
+ gatewayProcessMatcher = func(int) (bool, bool) { return true, true }
+ t.Cleanup(func() { gatewayProcessMatcher = origMatcher })
+
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
@@ -475,10 +507,20 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
t.Fatalf("SaveConfig() error = %v", err)
}
- pidData, err := ppid.WritePidFile(globalConfigDir(), cfg.Gateway.Host, cfg.Gateway.Port)
- if err != nil {
- t.Fatalf("WritePidFile() error = %v", err)
+ cmd := startGatewayLikeProcess(t)
+ t.Cleanup(func() {
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+ pidData := ppid.PidFileData{
+ PID: cmd.Process.Pid,
+ Token: "test-token",
+ Host: cfg.Gateway.Host,
+ Port: cfg.Gateway.Port,
}
+ writeTestPidFile(t, pidData)
t.Cleanup(func() {
ppid.RemovePidFile(globalConfigDir())
})
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index f3dd03dc0..054b78b73 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -16,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
@@ -64,6 +65,11 @@ const (
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
+func defaultToolFeedbackMaxArgsLength() int {
+ defaults := config.AgentDefaults{}
+ return defaults.GetToolFeedbackMaxArgsLength()
+}
+
// extractLegacyPicoSessionID extracts the session UUID from an old Pico key.
// Returns the UUID and true if the key matches the Pico session pattern.
func extractLegacyPicoSessionID(key string) (string, bool) {
@@ -391,7 +397,7 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio
return picoLegacySessionRef{}, os.ErrNotExist
}
-func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
+func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" {
@@ -408,7 +414,7 @@ func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
}
title := preview
- validMessageCount := len(visibleSessionMessages(sess.Messages))
+ validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
return sessionListItem{
ID: sessionID,
@@ -449,7 +455,7 @@ func sessionMessagePreview(msg providers.Message) string {
return ""
}
-func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
+func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
transcript := make([]sessionChatMessage, 0, len(messages))
for _, msg := range messages {
@@ -464,6 +470,17 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
}
case "assistant":
+ // Reasoning-only assistant messages are transient display artifacts and
+ // should not be restored from session history.
+ if assistantMessageTransientThought(msg) {
+ continue
+ }
+
+ toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
+ if len(toolSummaryMessages) > 0 {
+ transcript = append(transcript, toolSummaryMessages...)
+ }
+
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
if len(visibleToolMessages) > 0 {
transcript = append(transcript, visibleToolMessages...)
@@ -472,7 +489,7 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
// Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed
// internal summary that marks handled tool delivery.
- if len(visibleToolMessages) > 0 || !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
+ if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
continue
}
@@ -487,10 +504,63 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
return transcript
}
+func assistantMessageTransientThought(msg providers.Message) bool {
+ return strings.TrimSpace(msg.Content) == "" &&
+ strings.TrimSpace(msg.ReasoningContent) != "" &&
+ len(msg.ToolCalls) == 0 &&
+ len(msg.Media) == 0
+}
+
func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
}
+func visibleAssistantToolSummaryMessages(
+ toolCalls []providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) []sessionChatMessage {
+ if len(toolCalls) == 0 {
+ return nil
+ }
+ if toolFeedbackMaxArgsLength <= 0 {
+ toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
+ }
+
+ messages := make([]sessionChatMessage, 0, len(toolCalls))
+ for _, tc := range toolCalls {
+ name := tc.Name
+ argsJSON := ""
+ if tc.Function != nil {
+ if name == "" {
+ name = tc.Function.Name
+ }
+ argsJSON = tc.Function.Arguments
+ }
+
+ if strings.TrimSpace(name) == "" {
+ continue
+ }
+
+ if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
+ if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
+ argsJSON = string(encodedArgs)
+ }
+ }
+
+ argsPreview := strings.TrimSpace(argsJSON)
+ if argsPreview == "" {
+ argsPreview = "{}"
+ }
+
+ messages = append(messages, sessionChatMessage{
+ Role: "assistant",
+ Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)),
+ })
+ }
+
+ return messages
+}
+
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
@@ -536,7 +606,19 @@ func (h *Handler) sessionsDir() (string, error) {
return "", err
}
- workspace := cfg.Agents.Defaults.Workspace
+ return resolveSessionsDir(cfg.Agents.Defaults.Workspace), nil
+}
+
+func (h *Handler) sessionRuntimeSettings() (string, int, error) {
+ cfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ return "", 0, err
+ }
+
+ return resolveSessionsDir(cfg.Agents.Defaults.Workspace), cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), nil
+}
+
+func resolveSessionsDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
@@ -552,14 +634,14 @@ func (h *Handler) sessionsDir() (string, error) {
}
}
- return filepath.Join(workspace, "sessions"), nil
+ return filepath.Join(workspace, "sessions")
}
// handleListSessions returns a list of Pico session summaries.
//
// GET /api/sessions
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
- dir, err := h.sessionsDir()
+ dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
@@ -582,7 +664,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
continue
}
seen[ref.ID] = struct{}{}
- items = append(items, buildSessionListItem(ref.ID, sess))
+ items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength))
}
}
@@ -596,7 +678,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
continue
}
seen[ref.ID] = struct{}{}
- items = append(items, buildSessionListItem(ref.ID, sess))
+ items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength))
}
}
@@ -645,7 +727,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
- dir, err := h.sessionsDir()
+ dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings()
if err != nil {
http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError)
return
@@ -679,7 +761,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
}
}
- messages := visibleSessionMessages(sess.Messages)
+ messages := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 6b7205057..e40a8c77c 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -13,6 +13,7 @@ import (
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func sessionsTestDir(t *testing.T, configPath string) string {
@@ -292,6 +293,59 @@ func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
}
}
+func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-transient-thought"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", ReasoningContent: "internal chain of thought"},
+ {Role: "assistant", Content: "final visible answer"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
+ t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
+ }
+ if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
+ t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
+ }
+}
+
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -348,11 +402,14 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
- if len(resp.Messages) != 2 {
- t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
}
- if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
- t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
+ if !strings.Contains(resp.Messages[1].Content, "`message`") {
+ t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
+ }
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
+ t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
}
}
@@ -411,14 +468,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
- if len(resp.Messages) != 3 {
- t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ if len(resp.Messages) != 4 {
+ t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
}
- if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
- t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
+ if !strings.Contains(resp.Messages[1].Content, "`message`") {
+ t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
}
- if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
- t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
+ t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
+ }
+ if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
+ t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
}
}
@@ -475,8 +535,152 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
- if items[0].MessageCount != 2 {
- t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
+ if items[0].MessageCount != 3 {
+ t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
+ }
+}
+
+func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-tool-summary-and-content"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "check file"},
+ {
+ Role: "assistant",
+ Content: "model final reply",
+ ToolCalls: []providers.ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md","start_line":1,"end_line":10}`,
+ },
+ },
+ },
+ },
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-and-content", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 3 {
+ t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
+ t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
+ }
+ if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
+ t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
+ }
+ if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" {
+ t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2])
+ }
+}
+
+func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
+ err = config.SaveConfig(configPath, cfg)
+ if err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
+ sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
+ err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
+ if err != nil {
+ t.Fatalf("AddFullMessage(user) error = %v", err)
+ }
+ err = store.AddFullMessage(nil, sessionKey, providers.Message{
+ Role: "assistant",
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_1",
+ Type: "function",
+ Function: &providers.FunctionCall{
+ Name: "read_file",
+ Arguments: argsJSON,
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatalf("AddFullMessage(assistant) error = %v", err)
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-max-args", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &resp)
+ if err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) < 2 {
+ t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
+ }
+
+ wantPreview := utils.Truncate(argsJSON, 20)
+ if !strings.Contains(resp.Messages[1].Content, wantPreview) {
+ t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
+ }
+ if strings.Contains(resp.Messages[1].Content, argsJSON) {
+ t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
}
}
diff --git a/web/backend/dashboardauth/platform.go b/web/backend/dashboardauth/platform.go
new file mode 100644
index 000000000..25ba5da08
--- /dev/null
+++ b/web/backend/dashboardauth/platform.go
@@ -0,0 +1,7 @@
+package dashboardauth
+
+import "errors"
+
+// ErrUnsupportedPlatform reports that the SQLite-backed password store is not
+// available for the current target platform.
+var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform")
diff --git a/web/backend/dashboardauth/sql.go b/web/backend/dashboardauth/sql.go
new file mode 100644
index 000000000..94886072b
--- /dev/null
+++ b/web/backend/dashboardauth/sql.go
@@ -0,0 +1,24 @@
+package dashboardauth
+
+const (
+ // DBFilename is the SQLite database file stored under the PicoClaw home directory.
+ DBFilename = "launcher-auth.db"
+
+ sqliteDriver = "sqlite"
+ // bcryptCost is deliberately high enough to slow brute-force attempts.
+ bcryptCost = 12
+
+ sqlCreateTable = `
+ CREATE TABLE IF NOT EXISTS dashboard_credentials (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ bcrypt_hash TEXT NOT NULL
+ )`
+
+ sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1`
+
+ sqlUpsertHash = `
+ INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?)
+ ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash`
+
+ sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1`
+)
diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go
new file mode 100644
index 000000000..870796bba
--- /dev/null
+++ b/web/backend/dashboardauth/store.go
@@ -0,0 +1,96 @@
+//go:build !mipsle && !netbsd && !(freebsd && arm)
+
+// Package dashboardauth provides a bcrypt-backed SQLite store for the
+// launcher dashboard password. The database contains a single row (id=1)
+// with the bcrypt hash; no plaintext is ever persisted.
+package dashboardauth
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+
+ "golang.org/x/crypto/bcrypt"
+ _ "modernc.org/sqlite" // register "sqlite" driver
+)
+
+// Store holds a handle to the SQLite database that stores the bcrypt hash.
+type Store struct {
+ db *sql.DB
+ path string // absolute path to the SQLite file
+}
+
+// New opens (or creates) the database inside dir, using the package's
+// canonical filename. This is the preferred constructor for most callers.
+// Any error is wrapped with the resolved path so callers get actionable output.
+func New(dir string) (*Store, error) {
+ path := filepath.Join(dir, DBFilename)
+ s, err := Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ return s, nil
+}
+
+// Open opens (or creates) the SQLite database at path and migrates the schema.
+func Open(path string) (*Store, error) {
+ db, err := sql.Open(sqliteDriver, path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err = db.Exec(sqlCreateTable); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return &Store{db: db, path: path}, nil
+}
+
+// Close releases the database handle.
+func (s *Store) Close() error { return s.db.Close() }
+
+// DBPath returns the absolute path to the SQLite database file.
+func (s *Store) DBPath() string { return s.path }
+
+// IsInitialized reports whether a password hash has been stored.
+func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
+ var n int
+ err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n)
+ if err != nil {
+ return false, err
+ }
+ return n > 0, nil
+}
+
+// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it.
+// The plaintext is never written to disk.
+func (s *Store) SetPassword(ctx context.Context, plain string) error {
+ if len([]rune(plain)) == 0 {
+ return errors.New("password must not be empty")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash))
+ return err
+}
+
+// VerifyPassword returns true iff plain matches the stored bcrypt hash.
+// Returns (false, nil) when no password has been set yet.
+func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) {
+ var hash string
+ err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
+ if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
+ return false, nil
+ }
+ return err == nil, err
+}
diff --git a/web/backend/dashboardauth/store_unsupported.go b/web/backend/dashboardauth/store_unsupported.go
new file mode 100644
index 000000000..204682020
--- /dev/null
+++ b/web/backend/dashboardauth/store_unsupported.go
@@ -0,0 +1,60 @@
+//go:build mipsle || netbsd || (freebsd && arm)
+
+package dashboardauth
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "runtime"
+)
+
+// Store is unavailable on platforms where modernc sqlite/libc does not build.
+type Store struct {
+ path string
+}
+
+// New reports that the password store is unavailable on this platform.
+func New(dir string) (*Store, error) {
+ path := filepath.Join(dir, DBFilename)
+ s, err := Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ return s, nil
+}
+
+// Open reports that the password store is unavailable on this platform.
+func Open(path string) (*Store, error) {
+ return nil, unsupportedPlatformError()
+}
+
+// Close is a no-op for unsupported platforms.
+func (s *Store) Close() error { return nil }
+
+// DBPath returns the configured path, if any.
+func (s *Store) DBPath() string {
+ if s == nil {
+ return ""
+ }
+ return s.path
+}
+
+// IsInitialized reports that the store is unavailable on this platform.
+func (s *Store) IsInitialized(context.Context) (bool, error) {
+ return false, unsupportedPlatformError()
+}
+
+// SetPassword reports that the store is unavailable on this platform.
+func (s *Store) SetPassword(context.Context, string) error {
+ return unsupportedPlatformError()
+}
+
+// VerifyPassword reports that the store is unavailable on this platform.
+func (s *Store) VerifyPassword(context.Context, string) (bool, error) {
+ return false, unsupportedPlatformError()
+}
+
+func unsupportedPlatformError() error {
+ return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH)
+}
diff --git a/web/backend/i18n.go b/web/backend/i18n.go
index 106df8506..9cda9e5d5 100644
--- a/web/backend/i18n.go
+++ b/web/backend/i18n.go
@@ -24,8 +24,6 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
- MenuCopyToken TranslationKey = "MenuCopyToken"
- MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
- MenuCopyToken: "Copy dashboard token",
- MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
- MenuCopyToken: "复制控制台口令",
- MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",
diff --git a/web/backend/main.go b/web/backend/main.go
index 5e9f3315f..c5d25f6ef 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -27,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/api"
+ "github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -49,8 +50,6 @@ var (
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
- // launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
- launcherDashboardTokenForClipboard string
noBrowser *bool
)
@@ -66,6 +65,24 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
return launcherPath
}
+// maskSecret masks a secret for display. It always shows up to the first 3
+// runes. The last 4 runes are only appended when at least 5 runes remain
+// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
+// password never exposes its tail. Strings of 3 chars or fewer are fully
+// masked.
+func maskSecret(s string) string {
+ runes := []rune(s)
+ n := len(runes)
+ const prefixLen, suffixLen, minHidden = 3, 4, 5
+ if n < prefixLen+suffixLen+minHidden {
+ if n <= prefixLen {
+ return "**********"
+ }
+ return string(runes[:prefixLen]) + "**********"
+ }
+ return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
+}
+
func main() {
port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
@@ -209,7 +226,25 @@ func main() {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
- launcherDashboardTokenForClipboard = dashboardToken
+
+ // Open the bcrypt password store (creates the DB file on first run).
+ authStore, authStoreErr := dashboardauth.New(picoHome)
+ var passwordStore api.PasswordStore
+ if authStoreErr == nil {
+ passwordStore = authStore
+ defer authStore.Close()
+ } else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
+ logger.InfoC(
+ "web",
+ fmt.Sprintf(
+ "Dashboard password store unavailable on this platform; falling back to token login: %v",
+ authStoreErr,
+ ),
+ )
+ authStoreErr = nil
+ } else {
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
+ }
// Determine listen address
var addr string
@@ -222,20 +257,11 @@ func main() {
// Initialize Server components
mux := http.NewServeMux()
- tokenLogFileAbs := ""
- if fileLoggingEnabled {
- tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
- }
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
- TokenHelp: api.LauncherAuthTokenHelp{
- EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
- LogFileAbs: tokenLogFileAbs,
- ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
- TrayCopyMenu: trayOffersDashboardTokenCopy(),
- ConsoleStdout: enableConsole,
- },
+ PasswordStore: passwordStore,
+ StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@@ -284,23 +310,23 @@ func main() {
fmt.Println()
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom:
- fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
+ fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken))
case launcherconfig.DashboardTokenSourceEnv:
- fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
+ fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n")
case launcherconfig.DashboardTokenSourceConfig:
- fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath)
+ fmt.Printf(" Dashboard password: configured in %s\n", launcherPath)
}
fmt.Println()
}
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv:
- logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
+ logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN")
case launcherconfig.DashboardTokenSourceConfig:
- logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath))
+ logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath))
case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole {
- logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
+ logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken))
}
}
diff --git a/web/backend/main_test.go b/web/backend/main_test.go
index f69705179..82bf12b40 100644
--- a/web/backend/main_test.go
+++ b/web/backend/main_test.go
@@ -67,3 +67,31 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) {
})
}
}
+
+func TestMaskSecret(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ // Long token (>=12 chars): first 3 + 10 stars + last 4
+ {"sdhjflsjdflksdf", "sdh**********ksdf"},
+ {"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
+ // Exactly 12 chars (3+4+5 hidden): suffix shown
+ {"abcdefghijkl", "abc**********ijkl"},
+ // 8 chars (minimum password length): suffix NOT shown — only prefix+stars
+ {"abcdefgh", "abc**********"},
+ // 11 chars (one below threshold): suffix NOT shown
+ {"abcdefghijk", "abc**********"},
+ // 4..3 chars: prefix shown, no suffix
+ {"abcdefg", "abc**********"},
+ {"abcd", "abc**********"},
+ // <=3 chars: fully masked
+ {"abc", "**********"},
+ {"", "**********"},
+ }
+ for _, tt := range tests {
+ if got := maskSecret(tt.input); got != tt.want {
+ t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go
index 7e92fca22..c1c4c19c6 100644
--- a/web/backend/middleware/launcher_dashboard_auth.go
+++ b/web/backend/middleware/launcher_dashboard_auth.go
@@ -173,6 +173,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
+ case "/api/auth/setup":
+ return method == http.MethodPost
}
return false
}
@@ -183,7 +185,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
- if p == "/launcher-login" {
+ if p == "/launcher-login" || p == "/launcher-setup" {
return true
}
if strings.HasPrefix(p, "/assets/") {
diff --git a/web/backend/systray.go b/web/backend/systray.go
index 744ea4611..41fea1fbe 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -1,4 +1,4 @@
-//go:build (!darwin && !freebsd) || cgo
+//go:build !android && ((!darwin && !freebsd) || cgo)
package main
@@ -6,7 +6,6 @@ import (
"fmt"
"fyne.io/systray"
- "github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -24,7 +23,6 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
- mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@@ -52,17 +50,6 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
- case <-mCopyTok.ClickedCh:
- if launcherDashboardTokenForClipboard == "" {
- logger.WarnC("web", "Dashboard token is empty; cannot copy")
- continue
- }
- if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
- logger.Errorf("Failed to copy dashboard token: %v", err)
- } else {
- logger.InfoC("web", "Dashboard token copied to clipboard")
- }
-
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version
diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go
index 9e75e112a..41514feef 100644
--- a/web/backend/systray_stub_nocgo.go
+++ b/web/backend/systray_stub_nocgo.go
@@ -1,4 +1,4 @@
-//go:build (darwin || freebsd) && !cgo
+//go:build (darwin || freebsd || android) && !cgo
package main
diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go
deleted file mode 100644
index 6b7d17412..000000000
--- a/web/backend/tray_offers_copy.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (!darwin && !freebsd) || cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return true }
diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go
deleted file mode 100644
index 9312700f3..000000000
--- a/web/backend/tray_offers_copy_stub.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build (darwin || freebsd) && !cgo
-
-package main
-
-func trayOffersDashboardTokenCopy() bool { return false }
diff --git a/web/frontend/package.json b/web/frontend/package.json
index c802c71ff..51e6f1dd9 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -19,7 +19,7 @@
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
"@tailwindcss/vite": "^4.2.2",
- "@tanstack/react-query": "^5.96.1",
+ "@tanstack/react-query": "^5.97.0",
"@tanstack/react-router": "^1.167.0",
"@tanstack/react-router-devtools": "^1.163.3",
"class-variance-authority": "^0.7.1",
@@ -27,17 +27,17 @@
"dayjs": "^1.11.20",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
- "jotai": "^2.18.1",
+ "jotai": "^2.19.1",
"radix-ui": "^1.4.3",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
+ "react": "19.2.5",
+ "react-dom": "19.2.5",
"react-i18next": "^17.0.2",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
- "shadcn": "^4.1.2",
+ "shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
@@ -63,6 +63,6 @@
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.1",
- "vite": "^8.0.3"
+ "vite": "^8.0.8"
}
}
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index eb464f62d..e104eaee6 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -13,19 +13,19 @@ importers:
version: 5.2.8
'@tabler/icons-react':
specifier: ^3.40.0
- version: 3.41.1(react@19.2.4)
+ version: 3.41.1(react@19.2.5)
'@tailwindcss/vite':
specifier: ^4.2.2
- version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
+ version: 4.2.2(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
'@tanstack/react-query':
- specifier: ^5.96.1
- version: 5.96.1(react@19.2.4)
+ specifier: ^5.97.0
+ version: 5.97.0(react@19.2.5)
'@tanstack/react-router':
specifier: ^1.167.0
- version: 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-router-devtools':
specifier: ^1.163.3
- version: 1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -42,26 +42,26 @@ importers:
specifier: ^8.2.1
version: 8.2.1
jotai:
- specifier: ^2.18.1
- version: 2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
+ specifier: ^2.19.1
+ version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)
radix-ui:
specifier: ^1.4.3
- version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react:
- specifier: ^19.2.0
- version: 19.2.4
+ specifier: 19.2.5
+ version: 19.2.5
react-dom:
- specifier: ^19.2.0
- version: 19.2.4(react@19.2.4)
+ specifier: 19.2.5
+ version: 19.2.5(react@19.2.5)
react-i18next:
specifier: ^17.0.2
- version: 17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ version: 17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
- version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
react-textarea-autosize:
specifier: ^8.5.9
- version: 8.5.9(@types/react@19.2.14)(react@19.2.4)
+ version: 8.5.9(@types/react@19.2.14)(react@19.2.5)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -72,11 +72,11 @@ importers:
specifier: ^4.0.1
version: 4.0.1
shadcn:
- specifier: ^4.1.2
- version: 4.1.2(@types/node@25.5.0)(typescript@5.9.3)
+ specifier: ^4.2.0
+ version: 4.2.0(@types/node@25.5.0)(typescript@5.9.3)
sonner:
specifier: ^2.0.7
- version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -98,7 +98,7 @@ importers:
version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
+ version: 1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.8.1)
@@ -116,7 +116,7 @@ importers:
version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
+ version: 6.0.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
eslint:
specifier: ^10.1.0
version: 10.1.0(jiti@2.6.1)
@@ -145,8 +145,8 @@ importers:
specifier: ^8.57.1
version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
vite:
- specifier: ^8.0.3
- version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ specifier: ^8.0.8
+ version: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
packages:
@@ -283,8 +283,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
- '@dotenvx/dotenvx@1.59.1':
- resolution: {integrity: sha512-Qg+meC+XFxliuVSDlEPkKnaUjdaJKK6FNx/Wwl2UxhQR8pyPIuLhMavsF7ePdB9qFZUWV1jEK3ckbJir/WmF4w==}
+ '@dotenvx/dotenvx@1.61.0':
+ resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==}
hasBin: true
'@ecies/ciphers@0.2.6':
@@ -293,14 +293,14 @@ packages:
peerDependencies:
'@noble/ciphers': ^1.0.0
- '@emnapi/core@1.9.1':
- resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
+ '@emnapi/core@1.9.2':
+ resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
- '@emnapi/runtime@1.9.1':
- resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
+ '@emnapi/runtime@1.9.2':
+ resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
- '@emnapi/wasi-threads@1.2.0':
- resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
@@ -515,8 +515,8 @@ packages:
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
- '@hono/node-server@1.19.12':
- resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==}
+ '@hono/node-server@1.19.13':
+ resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -602,8 +602,8 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
- '@napi-rs/wasm-runtime@1.1.2':
- resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==}
+ '@napi-rs/wasm-runtime@1.1.3':
+ resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
@@ -641,8 +641,8 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
- '@oxc-project/types@0.122.0':
- resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
+ '@oxc-project/types@0.124.0':
+ resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1334,97 +1334,97 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@rolldown/binding-android-arm64@1.0.0-rc.12':
- resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
+ '@rolldown/binding-android-arm64@1.0.0-rc.15':
+ resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rolldown/binding-darwin-arm64@1.0.0-rc.12':
- resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==}
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.15':
+ resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.0.0-rc.12':
- resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==}
+ '@rolldown/binding-darwin-x64@1.0.0-rc.15':
+ resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.0.0-rc.12':
- resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==}
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.15':
+ resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
- resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==}
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
+ resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
- resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==}
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
+ resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
- resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
+ resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
- resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
+ resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
- resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
+ resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
- resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
+ resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
- resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
+ resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
- resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
+ resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
- resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==}
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
+ resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
- '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
- resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==}
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
+ resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
- resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==}
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
+ resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rolldown/pluginutils@1.0.0-rc.12':
- resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
+ '@rolldown/pluginutils@1.0.0-rc.15':
+ resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==}
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
@@ -1543,11 +1543,11 @@ packages:
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
- '@tanstack/query-core@5.96.1':
- resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==}
+ '@tanstack/query-core@5.97.0':
+ resolution: {integrity: sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==}
- '@tanstack/react-query@5.96.1':
- resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==}
+ '@tanstack/react-query@5.97.0':
+ resolution: {integrity: sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==}
peerDependencies:
react: ^18 || ^19
@@ -1856,8 +1856,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- baseline-browser-mapping@2.10.13:
- resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==}
+ baseline-browser-mapping@2.10.17:
+ resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1905,8 +1905,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001784:
- resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==}
+ caniuse-lite@1.0.30001787:
+ resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -1975,8 +1975,8 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
- content-disposition@1.0.1:
- resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
content-type@1.0.5:
@@ -2094,8 +2094,8 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
- dotenv@17.4.0:
- resolution: {integrity: sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==}
+ dotenv@17.4.1:
+ resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
@@ -2109,8 +2109,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
- electron-to-chromium@1.5.331:
- resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==}
+ electron-to-chromium@1.5.334:
+ resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -2463,8 +2463,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.10:
- resolution: {integrity: sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==}
+ hono@4.12.12:
+ resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==}
engines: {node: '>=16.9.0'}
html-parse-stringify@3.0.1:
@@ -2649,8 +2649,8 @@ packages:
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
- jotai@2.19.0:
- resolution: {integrity: sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ==}
+ jotai@2.19.1:
+ resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
@@ -3002,8 +3002,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- msw@2.12.14:
- resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==}
+ msw@2.13.2:
+ resolution: {integrity: sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -3177,8 +3177,8 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
- postcss@8.5.8:
- resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ postcss@8.5.9:
+ resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
@@ -3268,8 +3268,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- qs@6.15.0:
- resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
+ qs@6.15.1:
+ resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -3296,10 +3296,10 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
- react-dom@19.2.4:
- resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ react-dom@19.2.5:
+ resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
- react: ^19.2.4
+ react: ^19.2.5
react-i18next@17.0.2:
resolution: {integrity: sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==}
@@ -3359,8 +3359,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- react@19.2.4:
- resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ react@19.2.5:
+ resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
readdirp@3.6.0:
@@ -3415,8 +3415,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- rolldown@1.0.0-rc.12:
- resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
+ rolldown@1.0.0-rc.15:
+ resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -3467,8 +3467,8 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- shadcn@4.1.2:
- resolution: {integrity: sha512-qNQcCavkbYsgBj+X09tF2bTcwRd8abR880bsFkDU2kMqceMCLAm5c+cLg7kWDhfh1H9g08knpQ5ZEf6y/co16g==}
+ shadcn@4.2.0:
+ resolution: {integrity: sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ==}
hasBin: true
shebang-command@2.0.0:
@@ -3479,8 +3479,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- side-channel-list@1.0.0:
- resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
@@ -3599,15 +3599,15 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
- tinyglobby@0.2.15:
- resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
- tldts-core@7.0.27:
- resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
+ tldts-core@7.0.28:
+ resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
- tldts@7.0.27:
- resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
+ tldts@7.0.28:
+ resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
hasBin: true
to-regex-range@5.0.1:
@@ -3797,14 +3797,14 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@8.0.3:
- resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
+ vite@8.0.8:
+ resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.0
- esbuild: ^0.27.0
+ esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
@@ -3906,6 +3906,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yocto-spinner@1.1.0:
+ resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==}
+ engines: {node: '>=18.19'}
+
yoctocolors-cjs@2.1.3:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
@@ -4124,10 +4128,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@dotenvx/dotenvx@1.59.1':
+ '@dotenvx/dotenvx@1.61.0':
dependencies:
commander: 11.1.0
- dotenv: 17.4.0
+ dotenv: 17.4.1
eciesjs: 0.4.18
execa: 5.1.1
fdir: 6.5.0(picomatch@4.0.4)
@@ -4135,23 +4139,24 @@ snapshots:
object-treeify: 1.1.33
picomatch: 4.0.4
which: 4.0.0
+ yocto-spinner: 1.1.0
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies:
'@noble/ciphers': 1.3.0
- '@emnapi/core@1.9.1':
+ '@emnapi/core@1.9.2':
dependencies:
- '@emnapi/wasi-threads': 1.2.0
+ '@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.9.1':
+ '@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
optional: true
- '@emnapi/wasi-threads@1.2.0':
+ '@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -4277,19 +4282,19 @@ snapshots:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
- '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/dom': 1.7.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
'@floating-ui/utils@0.2.11': {}
'@fontsource-variable/inter@5.2.8': {}
- '@hono/node-server@1.19.12(hono@4.12.10)':
+ '@hono/node-server@1.19.13(hono@4.12.12)':
dependencies:
- hono: 4.12.10
+ hono: 4.12.12
'@humanfs/core@0.19.1': {}
@@ -4351,7 +4356,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.12(hono@4.12.10)
+ '@hono/node-server': 1.19.13(hono@4.12.12)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4361,7 +4366,7 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.2(express@5.2.1)
- hono: 4.12.10
+ hono: 4.12.12
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -4380,10 +4385,10 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
- '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)':
+ '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
dependencies:
- '@emnapi/core': 1.9.1
- '@emnapi/runtime': 1.9.1
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
'@tybys/wasm-util': 0.10.1
optional: true
@@ -4416,806 +4421,805 @@ snapshots:
'@open-draft/until@2.1.0': {}
- '@oxc-project/types@0.122.0': {}
+ '@oxc-project/types@0.124.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/rect': 1.1.1
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
aria-hidden: 1.2.6
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/rect': 1.1.1
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/rect@1.1.1': {}
- '@rolldown/binding-android-arm64@1.0.0-rc.12':
+ '@rolldown/binding-android-arm64@1.0.0-rc.15':
optional: true
- '@rolldown/binding-darwin-arm64@1.0.0-rc.12':
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.15':
optional: true
- '@rolldown/binding-darwin-x64@1.0.0-rc.12':
+ '@rolldown/binding-darwin-x64@1.0.0-rc.15':
optional: true
- '@rolldown/binding-freebsd-x64@1.0.0-rc.12':
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
optional: true
- '@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
optional: true
- '@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
optional: true
- '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)':
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
dependencies:
- '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
- transitivePeerDependencies:
- - '@emnapi/core'
- - '@emnapi/runtime'
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
optional: true
- '@rolldown/pluginutils@1.0.0-rc.12': {}
+ '@rolldown/pluginutils@1.0.0-rc.15': {}
'@rolldown/pluginutils@1.0.0-rc.7': {}
@@ -5223,10 +5227,10 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
- '@tabler/icons-react@3.41.1(react@19.2.4)':
+ '@tabler/icons-react@3.41.1(react@19.2.5)':
dependencies:
'@tabler/icons': 3.41.1
- react: 19.2.4
+ react: 19.2.5
'@tabler/icons@3.41.1': {}
@@ -5296,48 +5300,48 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.2
- '@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
+ '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
- vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
'@tanstack/history@1.161.6': {}
- '@tanstack/query-core@5.96.1': {}
+ '@tanstack/query-core@5.97.0': {}
- '@tanstack/react-query@5.96.1(react@19.2.4)':
+ '@tanstack/react-query@5.97.0(react@19.2.5)':
dependencies:
- '@tanstack/query-core': 5.96.1
- react: 19.2.4
+ '@tanstack/query-core': 5.97.0
+ react: 19.2.5
- '@tanstack/react-router-devtools@1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router-devtools@1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/react-router': 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@tanstack/react-router': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/router-devtools-core': 1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@tanstack/router-core': 1.168.7
transitivePeerDependencies:
- csstype
- '@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/history': 1.161.6
- '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/router-core': 1.168.7
isbot: 5.1.36
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
- '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/store': 0.9.3
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
- use-sync-external-store: 1.6.0(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ use-sync-external-store: 1.6.0(react@19.2.5)
'@tanstack/router-core@1.168.7':
dependencies:
@@ -5367,7 +5371,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5383,8 +5387,8 @@ snapshots:
unplugin: 2.3.11
zod: 3.25.76
optionalDependencies:
- '@tanstack/react-router': 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ '@tanstack/react-router': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -5398,7 +5402,7 @@ snapshots:
babel-dead-code-elimination: 1.0.12
diff: 8.0.4
pathe: 2.0.3
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
transitivePeerDependencies:
- supports-color
@@ -5544,7 +5548,7 @@ snapshots:
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
- tinyglobby: 0.2.15
+ tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -5568,10 +5572,10 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
+ vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
accepts@2.0.0:
dependencies:
@@ -5646,7 +5650,7 @@ snapshots:
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.10.13: {}
+ baseline-browser-mapping@2.10.17: {}
binary-extensions@2.3.0: {}
@@ -5658,7 +5662,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
- qs: 6.15.0
+ qs: 6.15.1
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -5678,9 +5682,9 @@ snapshots:
browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.10.13
- caniuse-lite: 1.0.30001784
- electron-to-chromium: 1.5.331
+ baseline-browser-mapping: 2.10.17
+ caniuse-lite: 1.0.30001787
+ electron-to-chromium: 1.5.334
node-releases: 2.0.37
update-browserslist-db: 1.2.3(browserslist@4.28.2)
@@ -5702,7 +5706,7 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001784: {}
+ caniuse-lite@1.0.30001787: {}
ccount@2.0.1: {}
@@ -5762,7 +5766,7 @@ snapshots:
commander@14.0.3: {}
- content-disposition@1.0.1: {}
+ content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -5841,7 +5845,7 @@ snapshots:
diff@8.0.4: {}
- dotenv@17.4.0: {}
+ dotenv@17.4.1: {}
dunder-proto@1.0.1:
dependencies:
@@ -5858,7 +5862,7 @@ snapshots:
ee-first@1.1.1: {}
- electron-to-chromium@1.5.331: {}
+ electron-to-chromium@1.5.334: {}
emoji-regex@10.6.0: {}
@@ -6057,7 +6061,7 @@ snapshots:
dependencies:
accepts: 2.0.0
body-parser: 2.2.2
- content-disposition: 1.0.1
+ content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
@@ -6075,7 +6079,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
- qs: 6.15.0
+ qs: 6.15.1
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -6325,7 +6329,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.10: {}
+ hono@4.12.12: {}
html-parse-stringify@3.0.1:
dependencies:
@@ -6458,12 +6462,12 @@ snapshots:
jose@6.2.2: {}
- jotai@2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4):
+ jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5):
optionalDependencies:
'@babel/core': 7.29.0
'@babel/template': 7.28.6
'@types/react': 19.2.14
- react: 19.2.4
+ react: 19.2.5
js-tokens@4.0.0: {}
@@ -6965,7 +6969,7 @@ snapshots:
ms@2.1.3: {}
- msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3):
+ msw@2.13.2(@types/node@25.5.0)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 5.1.21(@types/node@25.5.0)
'@mswjs/interceptors': 0.41.3
@@ -7148,7 +7152,7 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss@8.5.8:
+ postcss@8.5.9:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -7184,71 +7188,71 @@ snapshots:
punycode@2.3.1: {}
- qs@6.15.0:
+ qs@6.15.1:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
- radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -7262,23 +7266,23 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- react-dom@19.2.4(react@19.2.4):
+ react-dom@19.2.5(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
scheduler: 0.27.0
- react-i18next@17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ react-i18next@17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
i18next: 26.0.3(typescript@5.9.3)
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
- react-dom: 19.2.4(react@19.2.4)
+ react-dom: 19.2.5(react@19.2.5)
typescript: 5.9.3
- react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
@@ -7287,7 +7291,7 @@ snapshots:
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
- react: 19.2.4
+ react: 19.2.5
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
@@ -7296,43 +7300,43 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4)
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5)
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
- use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
+ use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5)
+ use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
+ react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
get-nonce: 1.0.1
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4):
+ react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@babel/runtime': 7.29.2
- react: 19.2.4
- use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4)
- use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5)
+ use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- react@19.2.4: {}
+ react@19.2.5: {}
readdirp@3.6.0:
dependencies:
@@ -7408,29 +7412,26 @@ snapshots:
reusify@1.1.0: {}
- rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1):
+ rolldown@1.0.0-rc.15:
dependencies:
- '@oxc-project/types': 0.122.0
- '@rolldown/pluginutils': 1.0.0-rc.12
+ '@oxc-project/types': 0.124.0
+ '@rolldown/pluginutils': 1.0.0-rc.15
optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-rc.12
- '@rolldown/binding-darwin-arm64': 1.0.0-rc.12
- '@rolldown/binding-darwin-x64': 1.0.0-rc.12
- '@rolldown/binding-freebsd-x64': 1.0.0-rc.12
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
- '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
- '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
- '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
- '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
- '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
- '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
- '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
- '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
- transitivePeerDependencies:
- - '@emnapi/core'
- - '@emnapi/runtime'
+ '@rolldown/binding-android-arm64': 1.0.0-rc.15
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.15
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.15
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.15
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15
router@2.2.0:
dependencies:
@@ -7489,13 +7490,13 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.1.2(@types/node@25.5.0)(typescript@5.9.3):
+ shadcn@4.2.0(@types/node@25.5.0)(typescript@5.9.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
- '@dotenvx/dotenvx': 1.59.1
+ '@dotenvx/dotenvx': 1.61.0
'@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.2
@@ -7510,11 +7511,11 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
- msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3)
+ msw: 2.13.2(@types/node@25.5.0)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
- postcss: 8.5.8
+ postcss: 8.5.9
postcss-selector-parser: 7.1.1
prompts: 2.4.2
recast: 0.23.11
@@ -7538,7 +7539,7 @@ snapshots:
shebang-regex@3.0.0: {}
- side-channel-list@1.0.0:
+ side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -7562,7 +7563,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
- side-channel-list: 1.0.0
+ side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
@@ -7572,10 +7573,10 @@ snapshots:
sisteransi@1.0.5: {}
- sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
- react: 19.2.4
- react-dom: 19.2.4(react@19.2.4)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
source-map-js@1.2.1: {}
@@ -7651,16 +7652,16 @@ snapshots:
tiny-invariant@1.3.3: {}
- tinyglobby@0.2.15:
+ tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
- tldts-core@7.0.27: {}
+ tldts-core@7.0.28: {}
- tldts@7.0.27:
+ tldts@7.0.28:
dependencies:
- tldts-core: 7.0.27
+ tldts-core: 7.0.28
to-regex-range@5.0.1:
dependencies:
@@ -7670,7 +7671,7 @@ snapshots:
tough-cookie@6.0.1:
dependencies:
- tldts: 7.0.27
+ tldts: 7.0.28
trim-lines@3.0.1: {}
@@ -7789,43 +7790,43 @@ snapshots:
dependencies:
punycode: 2.3.1
- use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
+ use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4):
+ use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4):
+ use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
- use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4):
+ use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
- react: 19.2.4
- use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.5
+ use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
- use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
+ use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
detect-node-es: 1.1.0
- react: 19.2.4
+ react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- use-sync-external-store@1.6.0(react@19.2.4):
+ use-sync-external-store@1.6.0(react@19.2.5):
dependencies:
- react: 19.2.4
+ react: 19.2.5
util-deprecate@1.0.2: {}
@@ -7848,22 +7849,19 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0):
+ vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
- postcss: 8.5.8
- rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
- tinyglobby: 0.2.15
+ postcss: 8.5.9
+ rolldown: 1.0.0-rc.15
+ tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.5.0
esbuild: 0.27.4
fsevents: 2.3.3
jiti: 2.6.1
tsx: 4.21.0
- transitivePeerDependencies:
- - '@emnapi/core'
- - '@emnapi/runtime'
void-elements@3.1.0: {}
@@ -7926,6 +7924,10 @@ snapshots:
yocto-queue@0.1.0: {}
+ yocto-spinner@1.1.0:
+ dependencies:
+ yoctocolors: 2.1.2
+
yoctocolors-cjs@2.1.3: {}
yoctocolors@2.1.2: {}
diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts
index 0eb872f3f..347dd9373 100644
--- a/web/frontend/src/api/http.ts
+++ b/web/frontend/src/api/http.ts
@@ -1,14 +1,14 @@
-import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
+import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
-function isLauncherLoginPath(): boolean {
+function isLauncherAuthPath(): boolean {
if (typeof globalThis.location === "undefined") {
return false
}
- if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
+ if (isLauncherAuthPathname(globalThis.location.pathname || "/")) {
return true
}
try {
- return isLauncherLoginPathname(
+ return isLauncherAuthPathname(
new URL(globalThis.location.href).pathname || "/",
)
} catch {
@@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean {
/**
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
- * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
+ * Skips redirect while already on an auth page (login or setup) to avoid reload loops.
*/
export async function launcherFetch(
input: RequestInfo | URL,
@@ -33,7 +33,7 @@ export async function launcherFetch(
if (
ct.includes("application/json") &&
typeof globalThis.location !== "undefined" &&
- !isLauncherLoginPath()
+ !isLauncherAuthPath()
) {
globalThis.location.assign("/launcher-login")
}
diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts
index 4ca51993b..ed2e30687 100644
--- a/web/frontend/src/api/launcher-auth.ts
+++ b/web/frontend/src/api/launcher-auth.ts
@@ -1,30 +1,23 @@
/**
- * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
- * redirect loops on 401 while on the login page.
+ * Dashboard launcher auth API.
+ * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
export async function postLauncherDashboardLogin(
- token: string,
+ password: string,
): Promise {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
- body: JSON.stringify({ token: token.trim() }),
+ body: JSON.stringify({ password: password.trim() }),
})
return res.ok
}
-export type LauncherAuthTokenHelp = {
- env_var_name: string
- log_file?: string
- config_file?: string
- tray_copy_menu: boolean
- console_stdout: boolean
-}
-
export type LauncherAuthStatus = {
authenticated: boolean
- token_help?: LauncherAuthTokenHelp
+ /** true when a bcrypt password has been stored in the DB */
+ initialized: boolean
}
export async function getLauncherAuthStatus(): Promise {
@@ -47,3 +40,28 @@ export async function postLauncherDashboardLogout(): Promise {
})
return res.ok
}
+
+export type SetupResult =
+ | { ok: true }
+ | { ok: false; error: string }
+
+export async function postLauncherDashboardSetup(
+ password: string,
+ confirm: string,
+): Promise {
+ const res = await fetch("/api/auth/setup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }),
+ })
+ if (res.ok) return { ok: true }
+ let msg = "Unknown error"
+ try {
+ const j = (await res.json()) as { error?: string }
+ if (j.error) msg = j.error
+ } catch {
+ /* ignore */
+ }
+ return { ok: false, error: msg }
+}
diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx
index fa1b5a488..798ac8ad5 100644
--- a/web/frontend/src/components/app-header.tsx
+++ b/web/frontend/src/components/app-header.tsx
@@ -2,6 +2,7 @@ import {
IconBook,
IconLanguage,
IconLoader2,
+ IconLogout,
IconMenu2,
IconMoon,
IconPlayerPlay,
@@ -39,6 +40,7 @@ import {
} from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
+import { postLauncherDashboardLogout } from "@/api/launcher-auth"
export function AppHeader() {
const { i18n, t } = useTranslation()
@@ -47,10 +49,12 @@ export function AppHeader() {
state: gwState,
loading: gwLoading,
canStart,
+ startReason,
restartRequired,
start,
restart,
stop,
+ error: gwError,
} = useGateway()
const isRunning = gwState === "running"
@@ -65,6 +69,12 @@ export function AppHeader() {
(gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
+ const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
+
+ const handleLogout = async () => {
+ await postLauncherDashboardLogout()
+ globalThis.location.assign("/launcher-login")
+ }
const handleGatewayToggle = () => {
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
@@ -134,6 +144,23 @@ export function AppHeader() {
+
+
+
+ {t("header.logout.tooltip")}
+
+ {t("header.logout.description")}
+
+
+
+ {t("common.cancel")}
+ void handleLogout()}>
+ {t("header.logout.confirm")}
+
+
+
+
+
{restartRequired && (
@@ -171,38 +198,50 @@ export function AppHeader() {
- {t("header.gateway.action.stop")}
+ {gwError ?? t("header.gateway.action.stop")}
) : (
-
- {gwLoading || isStarting || isRestarting || isStopping ? (
-
- ) : (
-
- )}
-
- {isStopping
- ? t("header.gateway.status.stopping")
- : isRestarting
- ? t("header.gateway.status.restarting")
- : isStarting
- ? t("header.gateway.status.starting")
- : t("header.gateway.action.start")}
-
-
+
+
+ {/* Wrap in span so the tooltip still fires when the button is disabled */}
+
+
+ {gwLoading || isStarting || isRestarting || isStopping ? (
+
+ ) : (
+
+ )}
+
+ {isStopping
+ ? t("header.gateway.status.stopping")
+ : isRestarting
+ ? t("header.gateway.status.restarting")
+ : isStarting
+ ? t("header.gateway.status.starting")
+ : t("header.gateway.action.start")}
+
+
+
+
+ {(gwError || (!canStart && startReason)) ? (
+ {gwError ?? startReason}
+ ) : null}
+
)}
{/* Theme Toggle */}
+
+
+ setShowLogoutDialog(true)}
+ aria-label={t("header.logout.tooltip")}
+ >
+
+
+
+ {t("header.logout.tooltip")}
+
+
PicoClaw
+ {isThought && (
+
+
+ {t("chat.reasoningLabel")}
+
+ )}
{formattedTimestamp && (
<>
•
@@ -42,8 +53,22 @@ export function AssistantMessage({
-
-
+
+
{isCopied ? (
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx
index 38a0fc6b1..e8e07a801 100644
--- a/web/frontend/src/components/chat/chat-page.tsx
+++ b/web/frontend/src/components/chat/chat-page.tsx
@@ -247,6 +247,7 @@ export function ChatPage() {
{msg.role === "assistant" ? (
) : (
diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts
index 850b3319e..92beb06b7 100644
--- a/web/frontend/src/features/chat/history.ts
+++ b/web/frontend/src/features/chat/history.ts
@@ -24,6 +24,7 @@ export async function loadSessionMessages(
id: `hist-${index}-${Date.now()}`,
role: message.role,
content: message.content,
+ kind: message.role === "assistant" ? "normal" : undefined,
attachments: toChatAttachments(message.media),
timestamp: fallbackTime,
}))
@@ -50,7 +51,7 @@ function messageSignature(message: ChatMessage): string {
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
message.timestamp,
- )}\u0000${attachmentSignature}`
+ )}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
}
function comparableTimestamp(timestamp: number | string): number {
diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts
index 7429aef01..a7edfc21b 100644
--- a/web/frontend/src/features/chat/protocol.ts
+++ b/web/frontend/src/features/chat/protocol.ts
@@ -1,7 +1,10 @@
import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state"
-import { updateChatStore } from "@/store/chat"
+import {
+ type AssistantMessageKind,
+ updateChatStore,
+} from "@/store/chat"
export interface PicoMessage {
type: string
@@ -11,6 +14,16 @@ export interface PicoMessage {
payload?: Record
}
+function parseAssistantMessageKind(
+ payload: Record,
+): AssistantMessageKind {
+ return payload.thought === true ? "thought" : "normal"
+}
+
+function hasAssistantKindPayload(payload: Record): boolean {
+ return typeof payload.thought === "boolean"
+}
+
export function handlePicoMessage(
message: PicoMessage,
expectedSessionId: string,
@@ -25,6 +38,7 @@ export function handlePicoMessage(
case "message.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
+ const kind = parseAssistantMessageKind(payload)
const timestamp =
message.timestamp !== undefined &&
Number.isFinite(Number(message.timestamp))
@@ -38,6 +52,7 @@ export function handlePicoMessage(
id: messageId,
role: "assistant",
content,
+ kind,
timestamp,
},
],
@@ -49,13 +64,21 @@ export function handlePicoMessage(
case "message.update": {
const content = (payload.content as string) || ""
const messageId = payload.message_id as string
+ const hasKind = hasAssistantKindPayload(payload)
+ const kind = parseAssistantMessageKind(payload)
if (!messageId) {
break
}
updateChatStore((prev) => ({
messages: prev.messages.map((msg) =>
- msg.id === messageId ? { ...msg, content } : msg,
+ msg.id === messageId
+ ? {
+ ...msg,
+ content,
+ ...(hasKind ? { kind } : {}),
+ }
+ : msg,
),
}))
break
diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts
index b118b43da..31bee0e91 100644
--- a/web/frontend/src/hooks/use-gateway.ts
+++ b/web/frontend/src/hooks/use-gateway.ts
@@ -13,8 +13,9 @@ import {
export function useGateway() {
const gateway = useAtomValue(gatewayAtom)
- const { status: state, canStart, restartRequired } = gateway
+ const { status: state, canStart, startReason, restartRequired } = gateway
const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
useEffect(() => {
return subscribeGatewayPolling()
@@ -23,6 +24,7 @@ export function useGateway() {
const start = useCallback(async () => {
if (!canStart) return
+ setError(null)
setLoading(true)
try {
await startGateway()
@@ -32,6 +34,7 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to start gateway:", err)
+ setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
@@ -39,12 +42,14 @@ export function useGateway() {
}, [canStart])
const stop = useCallback(async () => {
+ setError(null)
setLoading(true)
beginGatewayStoppingTransition()
try {
await stopGateway()
} catch (err) {
console.error("Failed to stop gateway:", err)
+ setError(err instanceof Error ? err.message : String(err))
cancelGatewayStoppingTransition()
} finally {
await refreshGatewayState({ force: true })
@@ -55,6 +60,7 @@ export function useGateway() {
const restart = useCallback(async () => {
if (state !== "running") return
+ setError(null)
setLoading(true)
try {
await restartGateway()
@@ -64,11 +70,12 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to restart gateway:", err)
+ setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [state])
- return { state, loading, canStart, restartRequired, start, stop, restart }
+ return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index d5bc44fe0..2434d4576 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -16,19 +16,24 @@
"logs": "Logs"
},
"launcherLogin": {
- "title": "Launcher access",
- "description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable or launcher config).",
- "tokenLabel": "Token",
- "tokenPlaceholder": "Enter access token",
- "submit": "Continue to Dashboard",
- "errorInvalid": "Invalid token. Please try again.",
- "errorNetwork": "Network error. Please try again.",
- "helpTitle": "Where to find the token",
- "helpConsole": "Console mode: printed in the terminal when the launcher starts.",
- "helpTray": "Tray mode: menu «Copy dashboard token».",
- "helpConfig": "Launcher config file: {{path}}",
- "helpLogFile": "Log file (startup line includes the token): {{path}}",
- "helpEnv": "Stable token: set {{env}}."
+ "title": "Sign in",
+ "description": "Enter the dashboard password to continue.",
+ "passwordLabel": "Password",
+ "passwordPlaceholder": "Enter password",
+ "submit": "Sign in",
+ "errorInvalid": "Incorrect password. Please try again.",
+ "errorNetwork": "Network error. Please try again."
+ },
+ "launcherSetup": {
+ "title": "Set dashboard password",
+ "description": "Choose a password to protect access to this dashboard. You will use it every time you sign in.",
+ "passwordLabel": "Password",
+ "passwordPlaceholder": "At least 8 characters",
+ "confirmLabel": "Confirm password",
+ "confirmPlaceholder": "Repeat password",
+ "submit": "Set password",
+ "errorMismatch": "Passwords do not match.",
+ "errorNetwork": "Network error. Please try again."
},
"chat": {
"welcome": "How can I help you today?",
@@ -42,6 +47,7 @@
"step3": "Preparing response...",
"step4": "Almost there..."
},
+ "reasoningLabel": "Reasoning",
"history": "History",
"noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history",
@@ -72,6 +78,11 @@
}
},
"header": {
+ "logout": {
+ "tooltip": "Sign out",
+ "confirm": "Sign out",
+ "description": "Are you sure you want to sign out of the dashboard?"
+ },
"gateway": {
"stopDialog": {
"title": "Stop Gateway Service?",
@@ -645,4 +656,4 @@
"description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
}
}
-}
+}
\ No newline at end of file
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index 3d1b35c8c..c03d4181d 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -16,19 +16,24 @@
"logs": "日志"
},
"launcherLogin": {
- "title": "Launcher 访问验证",
- "description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量或 launcher 配置固定)",
- "tokenLabel": "令牌",
- "tokenPlaceholder": "输入访问令牌",
- "submit": "进入 Dashboard",
- "errorInvalid": "令牌错误,请重试",
- "errorNetwork": "网络错误,请重试",
- "helpTitle": "口令在哪里",
- "helpConsole": "控制台模式:启动时在终端输出",
- "helpTray": "托盘模式:菜单「复制控制台口令」",
- "helpConfig": "Launcher 配置文件:{{path}}",
- "helpLogFile": "日志文件(启动时会写入口令):{{path}}",
- "helpEnv": "固定口令:设置环境变量 {{env}}"
+ "title": "登录",
+ "description": "请输入控制台密码以继续。",
+ "passwordLabel": "密码",
+ "passwordPlaceholder": "输入密码",
+ "submit": "登录",
+ "errorInvalid": "密码错误,请重试。",
+ "errorNetwork": "网络错误,请重试。"
+ },
+ "launcherSetup": {
+ "title": "设置控制台密码",
+ "description": "设置一个密码来保护控制台访问权限,登录时需要输入此密码。",
+ "passwordLabel": "密码",
+ "passwordPlaceholder": "至少 8 个字符",
+ "confirmLabel": "确认密码",
+ "confirmPlaceholder": "再次输入密码",
+ "submit": "设置密码",
+ "errorMismatch": "两次输入的密码不一致。",
+ "errorNetwork": "网络错误,请重试。"
},
"chat": {
"welcome": "今天我能为您做些什么?",
@@ -42,6 +47,7 @@
"step3": "准备回复...",
"step4": "马上就好..."
},
+ "reasoningLabel": "思考",
"history": "历史记录",
"noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败",
@@ -72,6 +78,11 @@
}
},
"header": {
+ "logout": {
+ "tooltip": "退出登录",
+ "confirm": "退出登录",
+ "description": "确定要退出仪表盘登录吗?"
+ },
"gateway": {
"stopDialog": {
"title": "停止服务?",
@@ -645,4 +656,4 @@
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
}
}
-}
+}
\ No newline at end of file
diff --git a/web/frontend/src/lib/launcher-login-path.ts b/web/frontend/src/lib/launcher-login-path.ts
index 52c35d240..45ece4d90 100644
--- a/web/frontend/src/lib/launcher-login-path.ts
+++ b/web/frontend/src/lib/launcher-login-path.ts
@@ -7,3 +7,12 @@ export function normalizePathname(p: string): string {
export function isLauncherLoginPathname(pathname: string): boolean {
return normalizePathname(pathname) === "/launcher-login"
}
+
+export function isLauncherSetupPathname(pathname: string): boolean {
+ return normalizePathname(pathname) === "/launcher-setup"
+}
+
+/** True for any page that is part of the auth flow (login or setup). */
+export function isLauncherAuthPathname(pathname: string): boolean {
+ return isLauncherLoginPathname(pathname) || isLauncherSetupPathname(pathname)
+}
diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts
index a32a6150d..b2f85e826 100644
--- a/web/frontend/src/routeTree.gen.ts
+++ b/web/frontend/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
+import { Route as LauncherSetupRouteImport } from './routes/launcher-setup'
import { Route as LauncherLoginRouteImport } from './routes/launcher-login'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
@@ -33,6 +34,11 @@ const LogsRoute = LogsRouteImport.update({
path: '/logs',
getParentRoute: () => rootRouteImport,
} as any)
+const LauncherSetupRoute = LauncherSetupRouteImport.update({
+ id: '/launcher-setup',
+ path: '/launcher-setup',
+ getParentRoute: () => rootRouteImport,
+} as any)
const LauncherLoginRoute = LauncherLoginRouteImport.update({
id: '/launcher-login',
path: '/launcher-login',
@@ -96,6 +102,7 @@ export interface FileRoutesByFullPath {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
+ '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@@ -111,6 +118,7 @@ export interface FileRoutesByTo {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
+ '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@@ -127,6 +135,7 @@ export interface FileRoutesById {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
+ '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@@ -144,6 +153,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
+ | '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@@ -159,6 +169,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
+ | '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@@ -174,6 +185,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
+ | '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@@ -190,6 +202,7 @@ export interface RootRouteChildren {
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LauncherLoginRoute: typeof LauncherLoginRoute
+ LauncherSetupRoute: typeof LauncherSetupRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
}
@@ -210,6 +223,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/launcher-setup': {
+ id: '/launcher-setup'
+ path: '/launcher-setup'
+ fullPath: '/launcher-setup'
+ preLoaderRoute: typeof LauncherSetupRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/launcher-login': {
id: '/launcher-login'
path: '/launcher-login'
@@ -334,6 +354,7 @@ const rootRouteChildren: RootRouteChildren = {
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LauncherLoginRoute: LauncherLoginRoute,
+ LauncherSetupRoute: LauncherSetupRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
}
diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx
index c34558554..b5af5de45 100644
--- a/web/frontend/src/routes/__root.tsx
+++ b/web/frontend/src/routes/__root.tsx
@@ -1,15 +1,16 @@
import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
-import { useEffect } from "react"
+import { useEffect, useState } from "react"
+import { getLauncherAuthStatus } from "@/api/launcher-auth"
import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/features/chat/controller"
-import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
+import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
const RootLayout = () => {
// Prefer the real address bar path: stale embedded bundles may not register
- // /launcher-login in the route tree, which would otherwise keep AppLayout +
- // gateway polling → 401 → launcherFetch redirect loop.
+ // /launcher-login or /launcher-setup in the route tree, which would otherwise
+ // keep AppLayout + gateway polling → 401 → launcherFetch redirect loop.
const routerState = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
@@ -22,19 +23,50 @@ const RootLayout = () => {
? globalThis.location.pathname || "/"
: routerState.pathname
- const isLauncherLogin =
- isLauncherLoginPathname(windowPath) ||
- isLauncherLoginPathname(routerState.pathname) ||
- routerState.matches.some((m) => m.routeId === "/launcher-login")
+ const isAuthPage =
+ isLauncherAuthPathname(windowPath) ||
+ isLauncherAuthPathname(routerState.pathname) ||
+ routerState.matches.some(
+ (m) => m.routeId === "/launcher-login" || m.routeId === "/launcher-setup",
+ )
+
+ const [authError, setAuthError] = useState(null)
+
+ // Session guard: proactively check auth status on every page load.
+ // This catches the case where ?token= auto-login bypassed the login/setup UI.
+ useEffect(() => {
+ if (isAuthPage) return
+ void getLauncherAuthStatus()
+ .then((s) => {
+ if (!s.initialized) {
+ globalThis.location.assign("/launcher-setup")
+ } else if (!s.authenticated) {
+ globalThis.location.assign("/launcher-login")
+ }
+ })
+ .catch((err: unknown) => {
+ // On 401/403, redirect to login — the session is invalid.
+ // On 5xx (e.g. 503 when the auth store is unavailable) or network errors,
+ // do NOT redirect: a subsequent successful login would loop straight back here.
+ // launcherFetch handles 401 on real API calls regardless.
+ if (err instanceof Error && /^status 40[13]$/.test(err.message)) {
+ globalThis.location.assign("/launcher-login")
+ } else {
+ setAuthError(
+ err instanceof Error ? err.message : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
+ )
+ }
+ })
+ }, [isAuthPage])
useEffect(() => {
- if (isLauncherLogin) {
+ if (isAuthPage) {
return
}
initializeChatStore()
- }, [isLauncherLogin])
+ }, [isAuthPage])
- if (isLauncherLogin) {
+ if (isAuthPage) {
return (
<>
@@ -44,10 +76,24 @@ const RootLayout = () => {
}
return (
-
-
- {import.meta.env.DEV ? : null}
-
+ <>
+ {authError && (
+
+ Auth service error: {authError}
+ setAuthError(null)}
+ aria-label="Dismiss"
+ >
+ ✕
+
+
+ )}
+
+
+ {import.meta.env.DEV ? : null}
+
+ >
)
}
diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx
index f5cdd105f..c5626fbb0 100644
--- a/web/frontend/src/routes/launcher-login.tsx
+++ b/web/frontend/src/routes/launcher-login.tsx
@@ -3,11 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
-import {
- type LauncherAuthTokenHelp,
- getLauncherAuthStatus,
- postLauncherDashboardLogin,
-} from "@/api/launcher-auth"
+import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
@@ -32,24 +28,16 @@ function LauncherLoginPage() {
const [token, setToken] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
- const [tokenHelp, setTokenHelp] =
- React.useState(null)
+ // If the password store has never been initialized, go to setup instead.
React.useEffect(() => {
- let cancelled = false
void getLauncherAuthStatus()
.then((s) => {
- if (cancelled || s.authenticated || !s.token_help) {
- return
+ if (!s.initialized) {
+ globalThis.location.assign("/launcher-setup")
}
- setTokenHelp(s.token_help)
})
- .catch(() => {
- /* ignore; login form still usable */
- })
- return () => {
- cancelled = true
- }
+ .catch(() => { /* network error — stay on login page */ })
}, [])
const loginWithToken = React.useCallback(
@@ -120,17 +108,17 @@ function LauncherLoginPage() {
- {tokenHelp ? (
-
-
- {t("launcherLogin.helpTitle")}
-
-
- {tokenHelp.console_stdout ? (
- {t("launcherLogin.helpConsole")}
- ) : null}
- {tokenHelp.tray_copy_menu ? (
- {t("launcherLogin.helpTray")}
- ) : null}
- {tokenHelp.config_file ? (
-
- {t("launcherLogin.helpConfig", {
- path: tokenHelp.config_file,
- })}
-
- ) : null}
- {tokenHelp.log_file ? (
-
- {t("launcherLogin.helpLogFile", {
- path: tokenHelp.log_file,
- })}
-
- ) : null}
- {tokenHelp.env_var_name ? (
-
- {t("launcherLogin.helpEnv", {
- env: tokenHelp.env_var_name,
- })}
-
- ) : null}
-
-
- ) : null}
diff --git a/web/frontend/src/routes/launcher-setup.tsx b/web/frontend/src/routes/launcher-setup.tsx
new file mode 100644
index 000000000..876af94fb
--- /dev/null
+++ b/web/frontend/src/routes/launcher-setup.tsx
@@ -0,0 +1,146 @@
+import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
+import { createFileRoute } from "@tanstack/react-router"
+import * as React from "react"
+import { useTranslation } from "react-i18next"
+
+import { postLauncherDashboardSetup } from "@/api/launcher-auth"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { useTheme } from "@/hooks/use-theme"
+
+function LauncherSetupPage() {
+ const { t, i18n } = useTranslation()
+ const { theme, toggleTheme } = useTheme()
+ const [password, setPassword] = React.useState("")
+ const [confirm, setConfirm] = React.useState("")
+ const [submitting, setSubmitting] = React.useState(false)
+ const [error, setError] = React.useState("")
+
+ const onSubmit = async (e: React.FormEvent
) => {
+ e.preventDefault()
+ setError("")
+ if (password !== confirm) {
+ setError(t("launcherSetup.errorMismatch"))
+ return
+ }
+ setSubmitting(true)
+ try {
+ const result = await postLauncherDashboardSetup(password, confirm)
+ if (result.ok) {
+ globalThis.location.assign("/launcher-login")
+ return
+ }
+ setError(result.error)
+ } catch {
+ setError(t("launcherSetup.errorNetwork"))
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ i18n.changeLanguage("en")}>
+ English
+
+ i18n.changeLanguage("zh")}>
+ 简体中文
+
+
+
+ toggleTheme()}
+ aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
+ >
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {t("launcherSetup.title")}
+ {t("launcherSetup.description")}
+
+
+
+
+
+
+
+ )
+}
+
+export const Route = createFileRoute("/launcher-setup")({
+ component: LauncherSetupPage,
+})
diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts
index 21eb5edff..2c6f70610 100644
--- a/web/frontend/src/store/chat.ts
+++ b/web/frontend/src/store/chat.ts
@@ -11,11 +11,14 @@ export interface ChatAttachment {
filename?: string
}
+export type AssistantMessageKind = "normal" | "thought"
+
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
timestamp: number | string
+ kind?: AssistantMessageKind
attachments?: ChatAttachment[]
}
diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts
index 1bdec6220..5bf6f3897 100644
--- a/web/frontend/src/store/gateway.ts
+++ b/web/frontend/src/store/gateway.ts
@@ -14,6 +14,7 @@ export type GatewayState =
export interface GatewayStoreState {
status: GatewayState
canStart: boolean
+ startReason?: string
restartRequired: boolean
}
@@ -57,6 +58,7 @@ function normalizeGatewayStoreState(
if (
next.status === prev.status &&
next.canStart === prev.canStart &&
+ next.startReason === prev.startReason &&
next.restartRequired === prev.restartRequired
) {
return prev
@@ -108,7 +110,10 @@ export function applyGatewayStatusToStore(
data: Partial<
Pick<
GatewayStatusResponse,
- "gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
+ | "gateway_status"
+ | "gateway_start_allowed"
+ | "gateway_start_reason"
+ | "gateway_restart_required"
>
>,
) {
@@ -121,6 +126,10 @@ export function applyGatewayStatusToStore(
prev.status === "stopping" && data.gateway_status === "running"
? false
: (data.gateway_start_allowed ?? prev.canStart),
+ startReason:
+ prev.status === "stopping" && data.gateway_status === "running"
+ ? prev.startReason
+ : (data.gateway_start_reason ?? prev.startReason),
restartRequired:
prev.status === "stopping" && data.gateway_status === "running"
? false