From 89af3b251137698cfc0be6d284a323c21301dd31 Mon Sep 17 00:00:00 2001
From: smallwhite
Date: Mon, 30 Mar 2026 15:01:01 +0800
Subject: [PATCH 01/47] fix(tools): message tool no longer suppresses reply to
originating chat
When the message tool sent to a different chat (e.g., a group), the
agent's final response to the originating chat was incorrectly skipped
because HasSentInRound() was a simple bool that didn't distinguish
targets. Replace with HasSentTo(channel, chatID) that tracks all
send targets per round and only suppresses when the target matches.
Fixes cross-conversation message causing "Processing..." to hang.
---
pkg/agent/loop.go | 10 +++++-----
pkg/tools/message.go | 38 +++++++++++++++++++++++++++++++++-----
2 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index ef2951365..a32d8d5bf 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -608,21 +608,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
}
diff --git a/pkg/tools/message.go b/pkg/tools/message.go
index 438ceeddd..e20edbd20 100644
--- a/pkg/tools/message.go
+++ b/pkg/tools/message.go
@@ -3,14 +3,21 @@ package tools
import (
"context"
"fmt"
- "sync/atomic"
+ "sync"
)
type SendCallback func(channel, chatID, content string) error
+// sentTarget records the channel+chatID that the message tool sent to.
+type sentTarget struct {
+ Channel string
+ ChatID string
+}
+
type MessageTool struct {
sendCallback SendCallback
- 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 {
@@ -49,12 +56,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 SendCallback) {
@@ -93,7 +118,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),
From 330de0c3825187b94bf10b598d5565801a516e65 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Wed, 8 Apr 2026 10:57:22 +0800
Subject: [PATCH 02/47] fix(agent): disable seahorse context manager on
freebsd/arm (#2417)
* fix(agent): disable seahorse context manager on freebsd/arm
Exclude freebsd/arm from the seahorse-enabled build and route it to the
unsupported stub implementation.
This avoids freebsd/arm build failures caused by modernc sqlite/libc while
keeping picoclaw buildable on that target.
* build: bump Go version from 1.25.8 to 1.25.9
* ci: install and run govulncheck directly in PR workflow
---
.github/workflows/pr.yml | 7 ++++---
go.mod | 2 +-
pkg/agent/context_seahorse.go | 2 +-
pkg/agent/context_seahorse_unsupported.go | 2 +-
4 files changed, 7 insertions(+), 6 deletions(-)
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/go.mod b/go.mod
index a9f4bb7cb..1ff7cb306 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
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
From ee29aaa871be7336a6418cd3c5e09a80bc0af512 Mon Sep 17 00:00:00 2001
From: Harmoon
Date: Wed, 8 Apr 2026 11:47:02 +0800
Subject: [PATCH 03/47] Enhance hooks with respond action and comprehensive
documentation (#2215)
* feat(hooks): add respond action for tool execution bypass
Add a new HookActionRespond that allows hooks to return tool results directly, skipping actual tool execution. This enables plugin tool injection, caching, and mocking capabilities.
- Add HookActionRespond constant and support in HookManager
- Extend ToolCallHookRequest with HookResult field
- Implement respond action handling in process hooks and agent loop
- Add comprehensive tests for respond and deny_tool actions
- Update documentation with hook actions table and examples
* docs(hooks): add JSON-RPC protocol and plugin tool injection documentation
Add comprehensive documentation for hook JSON-RPC protocol and plugin tool injection capabilities:
- Add "Hook Actions" section to README.zh.md explaining respond action for tool execution bypass
- Create hook-json-protocol.md/.zh.md detailing JSON-RPC 2.0 protocol for all hook methods
- Create plugin-tool-injection.md/.zh.md with complete examples for external tool implementation
- Document how hooks can inject tool definitions and return results via respond action
- Include Python and Go examples for weather query plugin implementation
* feat(agent): emit tool events and feedback for hook results
Add ToolExecStart event emission and tool feedback for hook results to ensure consistent behavior between normal tool execution and hook bypass scenarios. This maintains parity in event tracking and user feedback when tools are executed via hooks.
* style(agent): format whitespace in hook structs and constants
Remove trailing whitespace and standardize spacing in JSON struct tags, constants, and test data for improved code consistency.
* feat(hooks): add media support for plugin tool injection
Extend the hook respond action to support media file handling:
- Add `media` field for returning images and files from hooks
- Add `response_handled` field to control turn completion behavior
- When response_handled=true, media is automatically delivered to user
- When response_handled=false, media is passed to LLM for vision requests
This enables plugins to directly return generated images, downloaded
files, and other media content either to users or for LLM analysis.
* docs(hooks): document security implications of respond action
Add security boundary documentation explaining that the respond action
bypasses ApproveTool checks, allowing hooks to return results for any
tool without approval. Include recommendations for secure hook
implementation and code comments marking the security considerations.
Changes:
- Add "Security Boundaries" section to plugin-tool-injection docs
- Document bypass of approval checks and associated risks
- Provide security recommendations and example code
- Add inline security comments in hooks.go and loop.go
* refactor(agent): improve completeness of tool result cloning and hook processing
Extend cloneToolResult to properly copy ArtifactTags and Messages fields,
ensuring deep copies of all ToolResult data. Consolidate event emission
and user message handling to match the normal tool execution flow.
* fix(agent): align hook respond path with normal tool execution flow
The hook respond code path was missing several critical behaviors that
existed in normal tool execution:
- Add logging for tool calls with arguments preview
- Add is_tool_call metadata to user-facing messages
- Handle attachment delivery failures by setting error state and
notifying LLM
- Set ResponseHandled=false when using bus for media delivery
- Check for steering messages and graceful interrupts after tool
execution, skipping remaining tools when appropriate
- Poll for SubTurn results that arrived during tool execution
This ensures consistent behavior between hook-responded tool calls and
normally executed tool calls.
* test(agent): add tests for hook respond media error handling
Add comprehensive tests for the hook respond code path when media
delivery fails. Tests cover error media channel scenarios and verify
proper error state handling.
Also document that AfterTool is not called when using respond action,
as it provides the final answer directly (design decision).
---
docs/hooks/README.md | 63 +++
docs/hooks/README.zh.md | 63 +++
docs/hooks/hook-json-protocol.md | 568 ++++++++++++++++++++++++
docs/hooks/hook-json-protocol.zh.md | 568 ++++++++++++++++++++++++
docs/hooks/plugin-tool-injection.md | 587 +++++++++++++++++++++++++
docs/hooks/plugin-tool-injection.zh.md | 587 +++++++++++++++++++++++++
pkg/agent/hook_process.go | 8 +-
pkg/agent/hooks.go | 24 +-
pkg/agent/hooks_test.go | 517 ++++++++++++++++++++++
pkg/agent/loop.go | 230 ++++++++++
10 files changed, 3209 insertions(+), 6 deletions(-)
create mode 100644 docs/hooks/hook-json-protocol.md
create mode 100644 docs/hooks/hook-json-protocol.zh.md
create mode 100644 docs/hooks/plugin-tool-injection.md
create mode 100644 docs/hooks/plugin-tool-injection.zh.md
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/pkg/agent/hook_process.go b/pkg/agent/hook_process.go
index e5632913d..59dc8ad62 100644
--- a/pkg/agent/hook_process.go
+++ b/pkg/agent/hook_process.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/tools"
)
const (
@@ -90,7 +91,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 {
@@ -241,6 +243,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/hooks.go b/pkg/agent/hooks.go
index c1ef58ffd..c23961dc6 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"
@@ -127,11 +128,12 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse {
}
type ToolCallHookRequest struct {
- Meta EventMeta `json:"meta"`
- Tool string `json:"tool"`
- Arguments map[string]any `json:"arguments,omitempty"`
- Channel string `json:"channel,omitempty"`
- ChatID string `json:"chat_id,omitempty"`
+ Meta EventMeta `json:"meta"`
+ 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 {
@@ -140,6 +142,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
}
cloned := *r
cloned.Arguments = cloneStringAnyMap(r.Arguments)
+ cloned.HookResult = cloneToolResult(r.HookResult)
return &cloned
}
@@ -382,6 +385,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:
@@ -793,6 +800,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 49e1b1784..92e9caae9 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"
@@ -10,6 +11,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/tools"
)
@@ -343,3 +345,518 @@ 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
+ sendMediaErr error
+}
+
+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 := routing.BuildAgentMainSessionKey(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 := routing.BuildAgentMainSessionKey(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/loop.go b/pkg/agent/loop.go
index 369928d78..11446d222 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -2352,6 +2352,236 @@ 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 := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", 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{
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ Content: hookResult.ForUser,
+ Metadata: map[string]string{
+ "is_tool_call": "true",
+ },
+ })
+ }
+
+ // 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)
From 862421b146e08a974ae4854547da4b49aa774224 Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 13:42:57 +0900
Subject: [PATCH 04/47] docs: add Korean README translation
---
README.fr.md | 3 +-
README.id.md | 3 +-
README.it.md | 2 +-
README.ja.md | 2 +-
README.ko.md | 626 ++++++++++++++++++++++++++++++++++++++++++++++++
README.md | 2 +-
README.my.md | 2 +-
README.pt-br.md | 2 +-
README.vi.md | 2 +-
README.zh.md | 3 +-
10 files changed, 635 insertions(+), 12 deletions(-)
create mode 100644 README.ko.md
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:
-
From 8f7eae8b373b38851232d98f6db1b4bfe9633b1e Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 14:19:11 +0900
Subject: [PATCH 05/47] docs(tool): use provider-agnostic JSON escaping
guidance
---
pkg/providers/common/common_test.go | 16 ++++++++++++++++
pkg/tools/edit.go | 10 +++++-----
pkg/tools/filesystem.go | 4 ++--
3 files changed, 23 insertions(+), 7 deletions(-)
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/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",
From 7d167646749b11b54a3d27c42fa2d5bf05e88381 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Wed, 8 Apr 2026 14:23:21 +0800
Subject: [PATCH 06/47] fix(gateway): validate PID ownership and clean stale
pid files (#2422)
* fix(gateway): validate PID ownership and clean stale pid files
- include `pid` in health responses for runtime PID verification
- add `RemovePidFileIfPID` to safely delete PID files only on PID match
- sanitize gateway PID data via process-command checks with health fallback
- ignore and remove stale/non-gateway PID files before gateway operations
- refuse stop/restart actions when the attached process is not a gateway
- update gateway and websocket tests to cover PID validation and safety paths
* test(seahorse): use shared in-memory SQLite DB in tests to fix async compaction failures
* test: remove unused sendMediaErr field from hook test mock
---
pkg/agent/hooks_test.go | 1 -
pkg/health/server.go | 3 +
pkg/pid/pidfile.go | 24 ++++
pkg/pid/pidfile_test.go | 34 +++++
pkg/seahorse/schema_test.go | 14 +-
web/backend/api/gateway.go | 172 +++++++++++++++++++++-
web/backend/api/gateway_test.go | 246 ++++++++++++++++++++++++++++++--
web/backend/api/pico.go | 2 +-
web/backend/api/pico_test.go | 60 ++++++--
9 files changed, 528 insertions(+), 28 deletions(-)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 92e9caae9..9049a5c72 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
- sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
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/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/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/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())
})
From 8b3e5026903d4a6c02b3a7f8860d6625c4117fbe Mon Sep 17 00:00:00 2001
From: ywj <138745068+yangwenjie1231@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:26:17 +0800
Subject: [PATCH 07/47] fix(feishu): enrich reply context for card and file
replies (#2144)
* fix(feishu): enrich reply context for card and file replies
* refactor(feishu): extract reply functions to feishu_reply.go
- Move reply-related functions to new feishu_reply.go
- Move corresponding tests to feishu_reply_test.go
- Extract magic number 600 to maxReplyContextLen constant
- Unify replyTargetID/replyTargetFromMessage (prefer parent_id, fallback root_id)
- Add source comment for containsFeishuUpgradePlaceholder
* fix(feishu): skip API fallback for non-thread messages, prepend replied media refs
- resolveReplyTargetMessageID: only call fetchMessageByID fallback when
ThreadId is set, avoiding unnecessary API calls for non-reply messages
- prependReplyContext: prepend replied media refs before current media refs
to maintain correct ordering
* fix(feishu): add message cache for fetchMessageByID to avoid repeated downloads
- Add messageCache (sync.Map) to FeishuChannel struct
- Cache fetched messages with 30s TTL to avoid re-downloading attachments
when multiple users reply to the same parent message in a thread
- Cleanup expired entries on read access (no background goroutine needed)
* fix(feishu): early-return for non-reply messages, add cache and fetchMessageByID comment
* fix: remove duplicate test and fix gci import order
* fix(feishu): remove duplicate prependReplyContext call
---
pkg/channels/feishu/feishu_64.go | 40 +--
pkg/channels/feishu/feishu_reply.go | 298 +++++++++++++++++++++++
pkg/channels/feishu/feishu_reply_test.go | 229 +++++++++++++++++
3 files changed, 549 insertions(+), 18 deletions(-)
create mode 100644 pkg/channels/feishu/feishu_reply.go
create mode 100644 pkg/channels/feishu/feishu_reply_test.go
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index b0b231d09..c12827729 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),
@@ -436,24 +443,8 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
- if content == "" {
- content = "[empty message]"
- }
-
- metadata := map[string]string{}
- if messageID != "" {
- metadata["message_id"] = messageID
- }
- if messageType != "" {
- metadata["message_type"] = messageType
- }
chatType := stringValue(message.ChatType)
- if chatType != "" {
- metadata["chat_type"] = chatType
- }
- if sender != nil && sender.TenantKey != nil {
- metadata["tenant_key"] = *sender.TenantKey
- }
+ metadata := buildInboundMetadata(message, sender)
var peer bus.Peer
if chatType == "p2p" {
@@ -477,12 +468,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),
+ })
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
return nil
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)
+ }
+ })
+ }
+}
From 51eecde01ed2db893949b88939e3e1965204847c Mon Sep 17 00:00:00 2001
From: lxowalle <83055338+lxowalle@users.noreply.github.com>
Date: Wed, 8 Apr 2026 18:15:42 +0800
Subject: [PATCH 08/47] Feat/support isolation (#2423)
* * completed
* * optimzie
* * fix format
* * fix pr check
* try to fix ci
* * Indicates that Windows does not support expos_paths, adding more mount paths for the Linux platform.
* fix isolation startup lifecycle and MCP transport wrapping
* fix isolation startup cleanup and optional Linux mounts
* fix isolation path handling for relative hooks
Preserve relative command and working-directory semantics when Linux isolation wraps subprocesses, and restore absolute argv path exposure to avoid startup regressions. Add hook coverage and docs updates so isolation-enabled process hooks keep working as configured.
* * fix ci
---
pkg/agent/hook_process.go | 5 +-
pkg/agent/hook_process_test.go | 126 ++++++++
pkg/agent/instance.go | 7 +
pkg/config/config.go | 42 ++-
pkg/config/config_test.go | 31 ++
pkg/config/defaults.go | 5 +
pkg/isolation/README.md | 238 ++++++++++++++
pkg/isolation/README_CN.md | 238 ++++++++++++++
pkg/isolation/platform_linux.go | 264 +++++++++++++++
pkg/isolation/platform_linux_test.go | 148 +++++++++
pkg/isolation/platform_other.go | 22 ++
pkg/isolation/platform_windows.go | 217 +++++++++++++
pkg/isolation/runtime.go | 443 ++++++++++++++++++++++++++
pkg/isolation/runtime_test.go | 245 ++++++++++++++
pkg/mcp/isolated_command_transport.go | 226 +++++++++++++
pkg/mcp/manager.go | 3 +-
pkg/providers/claude_cli_provider.go | 6 +-
pkg/providers/codex_cli_provider.go | 6 +-
pkg/tools/shell.go | 19 +-
19 files changed, 2266 insertions(+), 25 deletions(-)
create mode 100644 pkg/isolation/README.md
create mode 100644 pkg/isolation/README_CN.md
create mode 100644 pkg/isolation/platform_linux.go
create mode 100644 pkg/isolation/platform_linux_test.go
create mode 100644 pkg/isolation/platform_other.go
create mode 100644 pkg/isolation/platform_windows.go
create mode 100644 pkg/isolation/runtime.go
create mode 100644 pkg/isolation/runtime_test.go
create mode 100644 pkg/mcp/isolated_command_transport.go
diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go
index 59dc8ad62..ace95f44d 100644
--- a/pkg/agent/hook_process.go
+++ b/pkg/agent/hook_process.go
@@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
+ "github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
)
@@ -122,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)
}
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/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/config/config.go b/pkg/config/config.go
index 606f7a095..fd4466b8c 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -24,20 +24,21 @@ 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 {
- Version int `json:"version" yaml:"-"` // Config schema version for migration
- Agents AgentsConfig `json:"agents" yaml:"-"`
- Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
- Session SessionConfig `json:"session,omitempty" yaml:"-"`
- Channels ChannelsConfig `json:"channels" yaml:"channels"`
- ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
- Gateway GatewayConfig `json:"gateway" yaml:"-"`
- Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
- Tools ToolsConfig `json:"tools" yaml:",inline"`
- Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
- Devices DevicesConfig `json:"devices" yaml:"-"`
- Voice VoiceConfig `json:"voice" yaml:"-"`
+ Version int `json:"version" yaml:"-"` // Config schema version for migration
+ Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
+ Agents AgentsConfig `json:"agents" yaml:"-"`
+ Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
+ Session SessionConfig `json:"session,omitempty" yaml:"-"`
+ Channels ChannelsConfig `json:"channels" yaml:"channels"`
+ ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
+ Gateway GatewayConfig `json:"gateway" yaml:"-"`
+ Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
+ Tools ToolsConfig `json:"tools" yaml:",inline"`
+ Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
+ Devices DevicesConfig `json:"devices" yaml:"-"`
+ Voice VoiceConfig `json:"voice" yaml:"-"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"`
@@ -45,6 +46,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 1c6b784c7..f0449d98f 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -852,6 +852,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 c2e1a31f3..bb073d436 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/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..213c4b065
--- /dev/null
+++ b/pkg/isolation/runtime_test.go
@@ -0,0 +1,245 @@
+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) {
+ 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/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/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()
}
From 1dc25e7cf52e49de1c8d318b800d9b74a5478a12 Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 19:44:07 +0900
Subject: [PATCH 09/47] test(agent): remove unused respondWithMediaHook field
---
pkg/agent/hooks_test.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 92e9caae9..9049a5c72 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
- sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
From 087e35588547e0700fbf03b7022f22b6efb737ef Mon Sep 17 00:00:00 2001
From: k
Date: Wed, 8 Apr 2026 19:44:07 +0900
Subject: [PATCH 10/47] test(agent): remove unused respondWithMediaHook field
---
pkg/agent/hooks_test.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 92e9caae9..9049a5c72 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
- sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
From 06023c79fa8ef485dc17e13074b8f8d292bd981b Mon Sep 17 00:00:00 2001
From: sky5454
Date: Wed, 8 Apr 2026 21:43:51 +0800
Subject: [PATCH 11/47] feat(launcher): standard HTTP login/setup/logout flow
for dashboard, frontend and backend impl. and fix windows pid lock for ws
(#2339)
* feat(launcher): replace token-in-logs auth with standard HTTP login flow
## Problem
Previously users had to find the one-time token from console logs or
log files to access the dashboard - a non-standard, error-prone workflow
with no clear path for changing credentials.
## Solution: standard HTTP API login with bcrypt-backed password store
### Auth flow (new)
1. First run: browser opens, session guard detects uninitialized state,
redirects to /launcher-setup
2. User sets a password (min 8 chars) via POST /api/auth/setup {password, confirm},
bcrypt(cost=12) hash stored in ~/.picoclaw/launcher-auth.db (SQLite)
3. Subsequent logins: POST /api/auth/login {password}, HttpOnly cookie
picoclaw_launcher_auth (HMAC-SHA256 signed, 7-day expiry)
4. 401 on any API call, frontend redirects to /launcher-login
5. Logout: POST /api/auth/logout, cookie cleared, redirect to login
### Backend changes
- web/backend/api/auth.go: renamed Token to Password; added handleSetup;
launcherAuthStatusResponse now includes Initialized bool; PasswordStore
interface wires bcrypt store into handlers
- web/backend/dashboardauth/: new package - Store with New(dir) / Open(path);
SetPassword (bcrypt cost=12), VerifyPassword, IsInitialized
- sql.go: all DB-layer constants (DBFilename, sqliteDriver, bcryptCost,
four SQL query strings) - compile-time constants, zero runtime overhead
- web/backend/middleware/launcher_dashboard_auth.go: /launcher-setup and
/api/auth/setup added to public paths
- web/backend/main.go:
- dashboardauth.New(picoHome) replaces manual path construction
- maskSecret(): suffix only revealed when >=5 chars hidden (length >= 12),
preventing 8-char minimum passwords from leaking their tail
- web/backend/main_test.go: TestMaskSecret updated with boundary cases
### Forward-compatibility: pkg/credential integration
If the dashboard password is later reused as the enc:// passphrase,
the bcrypt hash in launcher-auth.db becomes an offline oracle.
Recommended mitigation (not yet implemented): derive two independent
subkeys via HKDF before use:
bcrypt(HKDF(password, info="picoclaw-dashboard-login-v1")) stored in DB
HKDF(password, info="picoclaw-credential-enc-v1") passed to PassphraseProvider
This isolates the two domains: cracking the bcrypt hash yields only the
login subkey, which is computationally independent of the enc:// subkey.
* fix(auth): replace wastedassign ok := false with var ok bool
* refactor(tray): remove copy-token clipboard feature
Dashboard login now uses standard web auth (bcrypt + session cookie).
The system tray 'Copy dashboard token' menu item is no longer needed.
- Delete tray_offers_copy.go and tray_offers_copy_stub.go
- Remove mCopyTok menu item and clipboard handler from systray.go
- Remove launcherDashboardTokenForClipboard var from main.go
- Remove MenuCopyToken/MenuCopyTokenHint keys from i18n.go
* feat(launcher-ui): standard HTTP login/setup/logout flow for dashboard
Replaces the previous "find token in logs" workflow with a proper
browser-based authentication UI backed by the new /api/auth/* endpoints.
### New pages
- /launcher-setup: first-run password initialization form (password +
confirm, min 8 chars); calls POST /api/auth/setup; redirects to login
on success
- /launcher-login: standard password login form; calls POST /api/auth/login;
sets HttpOnly session cookie on success
### Session guard (src/routes/__root.tsx)
A useEffect on every non-auth page load calls GET /api/auth/status:
- initialized=false -> redirect to /launcher-setup
- authenticated=false -> redirect to /launcher-login
This ensures the setup/login UI is shown even when the ?token= URL
mechanism auto-logs in (first-run case).
### Logout button (src/components/app-header.tsx)
IconLogout button added to the header with a confirm AlertDialog;
calls POST /api/auth/logout then redirects to /launcher-login.
### API layer
- src/api/launcher-auth.ts: LauncherAuthStatus gains initialized bool;
postLauncherDashboardSetup() added; LauncherAuthTokenHelp removed
- src/api/http.ts: 401 guard uses isLauncherAuthPathname() (covers both
/launcher-login and /launcher-setup) to prevent redirect loops
- src/lib/launcher-login-path.ts: isLauncherSetupPathname() and
isLauncherAuthPathname() added
### Routing
- src/routeTree.gen.ts: /launcher-setup route registered throughout
- src/routes/launcher-login.tsx: tokenHelp UI removed; useEffect added
to redirect to setup when initialized=false
### i18n
- en.json / zh.json: launcherSetup block added; launcherLogin keys
updated to use passwordLabel/passwordPlaceholder
* fix(lint): ts lint fixed 1
* fix(auth): detail auth error handle
* fix(login): frontend web auth error handle
* fix(frontend): auth error handler 5xx
---
web/backend/api/auth.go | 199 +++++++++++++++---
web/backend/api/auth_test.go | 25 +--
web/backend/dashboardauth/sql.go | 24 +++
web/backend/dashboardauth/store.go | 94 +++++++++
web/backend/i18n.go | 6 -
web/backend/main.go | 56 +++--
web/backend/main_test.go | 28 +++
.../middleware/launcher_dashboard_auth.go | 4 +-
web/backend/systray.go | 13 --
web/backend/tray_offers_copy.go | 5 -
web/backend/tray_offers_copy_stub.go | 5 -
web/frontend/src/api/http.ts | 12 +-
web/frontend/src/api/launcher-auth.ts | 44 ++--
web/frontend/src/components/app-header.tsx | 114 +++++++---
web/frontend/src/hooks/use-gateway.ts | 11 +-
web/frontend/src/i18n/locales/en.json | 38 ++--
web/frontend/src/i18n/locales/zh.json | 38 ++--
web/frontend/src/lib/launcher-login-path.ts | 9 +
web/frontend/src/routeTree.gen.ts | 21 ++
web/frontend/src/routes/__root.tsx | 76 +++++--
web/frontend/src/routes/launcher-login.tsx | 64 +-----
web/frontend/src/routes/launcher-setup.tsx | 146 +++++++++++++
web/frontend/src/store/gateway.ts | 11 +-
23 files changed, 795 insertions(+), 248 deletions(-)
create mode 100644 web/backend/dashboardauth/sql.go
create mode 100644 web/backend/dashboardauth/store.go
delete mode 100644 web/backend/tray_offers_copy.go
delete mode 100644 web/backend/tray_offers_copy_stub.go
create mode 100644 web/frontend/src/routes/launcher-setup.tsx
diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go
index 22f7ec2c2..0790a6b76 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,44 @@ 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
}
+// isStoreInitialized safely queries the store.
+// Returns (false, nil) when no store is configured (storeErr also nil).
+// 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)
+ }
+ 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 +114,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 {
+ // 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 +187,100 @@ 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.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..58ffb823a 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)
@@ -91,7 +82,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
- TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
@@ -125,11 +115,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 +176,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 +194,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/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..44605ba22
--- /dev/null
+++ b/web/backend/dashboardauth/store.go
@@ -0,0 +1,94 @@
+// 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/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..d9ea3474c 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,15 @@ 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)
+ if authStoreErr != nil {
+ logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
+ authStore = nil
+ } else {
+ defer authStore.Close()
+ }
// Determine listen address
var addr string
@@ -222,20 +247,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: authStore,
+ StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@@ -284,23 +300,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..9dcc025df 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -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/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/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")}
+
+
(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..b53abeb76 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?",
@@ -72,6 +77,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 +655,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..e2e8eae04 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": "今天我能为您做些什么?",
@@ -72,6 +77,11 @@
}
},
"header": {
+ "logout": {
+ "tooltip": "退出登录",
+ "confirm": "退出登录",
+ "description": "确定要退出仪表盘登录吗?"
+ },
"gateway": {
"stopDialog": {
"title": "停止服务?",
@@ -645,4 +655,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/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
From a2f02e4b186bc4c23c37c498c4750b5bd819c74b Mon Sep 17 00:00:00 2001
From: k
Date: Thu, 9 Apr 2026 07:47:42 +0900
Subject: [PATCH 12/47] Revert "test(agent): remove unused respondWithMediaHook
field"
This reverts commit 087e35588547e0700fbf03b7022f22b6efb737ef.
---
pkg/agent/hooks_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index 9049a5c72..92e9caae9 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -515,6 +515,7 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
+ sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
From a9720daa45c2bd34542d1440401417fe5c8f4603 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Thu, 9 Apr 2026 10:14:08 +0800
Subject: [PATCH 13/47] fix(test): skip TestPrepareCommand_AppliesUserEnv on
unsupported operating systems (#2434)
---
pkg/isolation/runtime_test.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pkg/isolation/runtime_test.go b/pkg/isolation/runtime_test.go
index 213c4b065..aca484bba 100644
--- a/pkg/isolation/runtime_test.go
+++ b/pkg/isolation/runtime_test.go
@@ -212,6 +212,9 @@ func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) {
}
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")
From 5e44a9941023b93b3a043c729f8bbfec14275e28 Mon Sep 17 00:00:00 2001
From: Guoguo <16666742+imguoguo@users.noreply.github.com>
Date: Thu, 9 Apr 2026 10:53:52 +0800
Subject: [PATCH 14/47] fix(docker): run self-built images as root for parity
with release (#2435)
The self-built docker/Dockerfile and docker/Dockerfile.heavy created a
dedicated picoclaw user (uid 1000) and stored config at
/home/picoclaw/.picoclaw, while the released images from
Dockerfile.goreleaser (and Dockerfile.full) run as root at
/root/.picoclaw. Both docker-compose files mount ./data:/root/.picoclaw,
so self-built images silently broke when used with the shared compose.
Drop the picoclaw user switch and align both Dockerfiles on root +
/root/.picoclaw. Dockerfile also adopts the release entrypoint.sh so
first-run behavior matches between self-built and release tags.
Co-authored-by: Claude Opus 4.6 (1M context)
---
docker/Dockerfile | 17 ++++-------------
docker/Dockerfile.heavy | 11 ++---------
2 files changed, 6 insertions(+), 22 deletions(-)
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"]
From 5b596ed2f0aea803fd7afdb4b526e2775c02d8b6 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Thu, 9 Apr 2026 22:15:46 +0800
Subject: [PATCH 15/47] fix(chat): keep tool summaries and assistant output
together
---
pkg/agent/loop.go | 2 +-
web/backend/api/session.go | 55 +++++++++++++++++-
web/backend/api/session_test.go | 98 +++++++++++++++++++++++++++++----
3 files changed, 141 insertions(+), 14 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index e089e6d3d..1b53e3dac 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -1409,7 +1409,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Media: msg.Media,
DefaultResponse: defaultResponse,
EnableSummary: true,
- SendResponse: false,
+ SendResponse: msg.Channel == "pico",
}
// context-dependent commands check their own Runtime fields and report
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index a2e931010..9e712be0c 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -4,6 +4,7 @@ import (
"bufio"
"encoding/json"
"errors"
+ "fmt"
"net/http"
"os"
"path/filepath"
@@ -14,6 +15,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
@@ -72,6 +74,8 @@ const (
// pkg/memory/jsonl.go so oversized lines fail consistently everywhere.
maxSessionJSONLLineSize = 10 * 1024 * 1024
maxSessionTitleRunes = 60
+ // Keep session reconstruction aligned with tool_feedback max args preview.
+ sessionToolFeedbackMaxArgsLength = 300
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
@@ -275,6 +279,11 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
}
case "assistant":
+ toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls)
+ if len(toolSummaryMessages) > 0 {
+ transcript = append(transcript, toolSummaryMessages...)
+ }
+
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
if len(visibleToolMessages) > 0 {
transcript = append(transcript, visibleToolMessages...)
@@ -283,7 +292,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
}
@@ -302,6 +311,50 @@ func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
}
+func visibleAssistantToolSummaryMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
+ if len(toolCalls) == 0 {
+ return nil
+ }
+
+ 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: formatToolCallSummary(name, utils.Truncate(argsPreview, sessionToolFeedbackMaxArgsLength)),
+ })
+ }
+
+ return messages
+}
+
+func formatToolCallSummary(name, argsPreview string) string {
+ return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", name, argsPreview)
+}
+
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 9248c11b7..167c17ecf 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -273,11 +273,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])
}
}
@@ -336,14 +339,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])
}
}
@@ -400,8 +406,76 @@ 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])
}
}
From 2aeed8fb3a5be7f00d32bf8c7a8e71e57e2aebd6 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Thu, 9 Apr 2026 22:32:35 +0800
Subject: [PATCH 16/47] fix(pico): stream assistant text between tool calls
---
pkg/agent/loop.go | 16 ++++++-
pkg/agent/loop_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 113 insertions(+), 1 deletion(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 1b53e3dac..8aa71a168 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -1409,7 +1409,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Media: msg.Media,
DefaultResponse: defaultResponse,
EnableSummary: true,
- SendResponse: msg.Channel == "pico",
+ SendResponse: false,
}
// context-dependent commands check their own Runtime fields and report
@@ -2253,6 +2253,20 @@ turnLoop:
}
logger.DebugCF("agent", "LLM response", llmResponseFields)
+ if al.bus != nil && ts.channel == "pico" {
+ liveContent := response.Content
+ if liveContent == "" && len(response.ToolCalls) == 0 && response.ReasoningContent != "" {
+ liveContent = response.ReasoningContent
+ }
+ if strings.TrimSpace(liveContent) != "" {
+ al.bus.PublishOutbound(turnCtx, bus.OutboundMessage{
+ Channel: ts.channel,
+ ChatID: ts.chatID,
+ Content: liveContent,
+ })
+ }
+ }
+
if len(response.ToolCalls) == 0 || gracefulTerminal {
responseContent := response.Content
if responseContent == "" && response.ReasoningContent != "" {
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 3d04b81cc..7c10e11aa 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -1069,6 +1069,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(
@@ -2732,6 +2766,70 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
}
}
+func TestProcessMessage_PicoPublishesAssistantContentDuringToolCalls(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.processMessage(context.Background(), bus.InboundMessage{
+ Channel: "pico",
+ SenderID: "user-1",
+ ChatID: "session-1",
+ Content: "run with tools",
+ })
+ if err != nil {
+ t.Fatalf("processMessage() error = %v", err)
+ }
+ if response != "final model text" {
+ t.Fatalf("processMessage() response = %q, want %q", response, "final model text")
+ }
+
+ 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")
+ }
+
+ 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 TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
From 9982ee29a88cbb8790ab74958b91e5127b347511 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Thu, 9 Apr 2026 22:59:36 +0800
Subject: [PATCH 17/47] fix(pico): avoid duplicate final websocket message
---
pkg/agent/loop.go | 10 +++-------
pkg/agent/loop_test.go | 30 ++++++++++++++++++++++--------
2 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 8aa71a168..431376be3 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -2253,16 +2253,12 @@ turnLoop:
}
logger.DebugCF("agent", "LLM response", llmResponseFields)
- if al.bus != nil && ts.channel == "pico" {
- liveContent := response.Content
- if liveContent == "" && len(response.ToolCalls) == 0 && response.ReasoningContent != "" {
- liveContent = response.ReasoningContent
- }
- if strings.TrimSpace(liveContent) != "" {
+ if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 {
+ if strings.TrimSpace(response.Content) != "" {
al.bus.PublishOutbound(turnCtx, bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
- Content: liveContent,
+ Content: response.Content,
})
}
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 7c10e11aa..371f91d89 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -2766,7 +2766,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
}
}
-func TestProcessMessage_PicoPublishesAssistantContentDuringToolCalls(t *testing.T) {
+func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
@@ -2790,17 +2790,21 @@ func TestProcessMessage_PicoPublishesAssistantContentDuringToolCalls(t *testing.
}
agent.Tools.Register(&toolLimitTestTool{})
- response, err := al.processMessage(context.Background(), bus.InboundMessage{
+ 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",
- })
- if err != nil {
- t.Fatalf("processMessage() error = %v", err)
- }
- if response != "final model text" {
- t.Fatalf("processMessage() response = %q, want %q", response, "final model text")
+ }); err != nil {
+ t.Fatalf("PublishInbound() error = %v", err)
}
outputs := make([]string, 0, 2)
@@ -2821,6 +2825,16 @@ func TestProcessMessage_PicoPublishesAssistantContentDuringToolCalls(t *testing.
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" {
From bd13092831ca87457cbae7dc53f3d9b4d6b22bbd Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Thu, 9 Apr 2026 23:52:02 +0800
Subject: [PATCH 18/47] fix(review): align tool feedback reconstruction with
runtime behavior
---
pkg/agent/loop.go | 16 +++++--
pkg/utils/tool_feedback.go | 9 ++++
pkg/utils/tool_feedback_test.go | 11 +++++
web/backend/api/session.go | 51 ++++++++++++++--------
web/backend/api/session_test.go | 77 +++++++++++++++++++++++++++++++++
5 files changed, 142 insertions(+), 22 deletions(-)
create mode 100644 pkg/utils/tool_feedback.go
create mode 100644 pkg/utils/tool_feedback_test.go
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 431376be3..89e92aa14 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -2255,11 +2255,21 @@ turnLoop:
if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 {
if strings.TrimSpace(response.Content) != "" {
- al.bus.PublishOutbound(turnCtx, bus.OutboundMessage{
+ 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,
+ })
+ }
}
}
@@ -2400,7 +2410,7 @@ turnLoop:
string(argsJSON),
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
- feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, feedbackPreview)
+ feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
Channel: ts.channel,
@@ -2682,7 +2692,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, bus.OutboundMessage{
Channel: ts.channel,
diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go
new file mode 100644
index 000000000..028908617
--- /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)
+}
\ No newline at end of file
diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go
new file mode 100644
index 000000000..9f64f66d7
--- /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)
+ }
+}
\ No newline at end of file
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index 9e712be0c..a368e9b79 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -4,7 +4,6 @@ import (
"bufio"
"encoding/json"
"errors"
- "fmt"
"net/http"
"os"
"path/filepath"
@@ -74,12 +73,15 @@ const (
// pkg/memory/jsonl.go so oversized lines fail consistently everywhere.
maxSessionJSONLLineSize = 10 * 1024 * 1024
maxSessionTitleRunes = 60
- // Keep session reconstruction aligned with tool_feedback max args preview.
- sessionToolFeedbackMaxArgsLength = 300
handledToolResponseSummaryText = "Requested output delivered via tool attachment."
)
+func defaultToolFeedbackMaxArgsLength() int {
+ defaults := config.AgentDefaults{}
+ return defaults.GetToolFeedbackMaxArgsLength()
+}
+
// extractPicoSessionID extracts the session UUID from a full session key.
// Returns the UUID and true if the key matches the Pico session pattern.
func extractPicoSessionID(key string) (string, bool) {
@@ -206,7 +208,7 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
}, nil
}
-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" {
@@ -223,7 +225,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,
@@ -264,7 +266,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 {
@@ -279,7 +281,7 @@ func visibleSessionMessages(messages []providers.Message) []sessionChatMessage {
}
case "assistant":
- toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls)
+ toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
if len(toolSummaryMessages) > 0 {
transcript = append(transcript, toolSummaryMessages...)
}
@@ -311,10 +313,13 @@ func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
}
-func visibleAssistantToolSummaryMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
+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 {
@@ -344,17 +349,13 @@ func visibleAssistantToolSummaryMessages(toolCalls []providers.ToolCall) []sessi
messages = append(messages, sessionChatMessage{
Role: "assistant",
- Content: formatToolCallSummary(name, utils.Truncate(argsPreview, sessionToolFeedbackMaxArgsLength)),
+ Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)),
})
}
return messages
}
-func formatToolCallSummary(name, argsPreview string) string {
- return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", name, argsPreview)
-}
-
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
@@ -400,7 +401,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")
@@ -416,14 +429,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
@@ -507,7 +520,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
}
seen[sessionID] = struct{}{}
- items = append(items, buildSessionListItem(sessionID, sess))
+ items = append(items, buildSessionListItem(sessionID, sess, toolFeedbackMaxArgsLength))
}
// Sort by updated descending (most recent first)
@@ -555,7 +568,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
@@ -582,7 +595,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 167c17ecf..5d7620362 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 {
@@ -479,6 +480,82 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
}
}
+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)
+ }
+}
+
func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
From 58f634b582cb335a8bb8b6682a7c143210a87e04 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Fri, 10 Apr 2026 00:02:20 +0800
Subject: [PATCH 19/47] style(lint): satisfy gci and golines for review fixes
---
pkg/utils/tool_feedback.go | 2 +-
pkg/utils/tool_feedback_test.go | 2 +-
web/backend/api/session.go | 5 ++++-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go
index 028908617..a6c8895b8 100644
--- a/pkg/utils/tool_feedback.go
+++ b/pkg/utils/tool_feedback.go
@@ -6,4 +6,4 @@ import "fmt"
// 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)
-}
\ No newline at end of file
+}
diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go
index 9f64f66d7..d7a55ce6b 100644
--- a/pkg/utils/tool_feedback_test.go
+++ b/pkg/utils/tool_feedback_test.go
@@ -8,4 +8,4 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
-}
\ No newline at end of file
+}
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index a368e9b79..ae580d9aa 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -313,7 +313,10 @@ func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
}
-func visibleAssistantToolSummaryMessages(toolCalls []providers.ToolCall, toolFeedbackMaxArgsLength int) []sessionChatMessage {
+func visibleAssistantToolSummaryMessages(
+ toolCalls []providers.ToolCall,
+ toolFeedbackMaxArgsLength int,
+) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
}
From bd88385923306f6d78ed6ba749606c3c3008160f Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Fri, 10 Apr 2026 00:19:45 +0800
Subject: [PATCH 20/47] fix(agent): gate pico interim publish for internal
turns
---
pkg/agent/loop.go | 28 +++++++++++++-----------
pkg/agent/loop_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 13 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 89e92aa14..ac230aa86 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -88,6 +88,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)
@@ -1398,18 +1399,19 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
opts := processOptions{
- SessionKey: sessionKey,
- Channel: msg.Channel,
- ChatID: msg.ChatID,
- MessageID: msg.MessageID,
- ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
- SenderID: msg.SenderID,
- SenderDisplayName: msg.Sender.DisplayName,
- UserMessage: msg.Content,
- Media: msg.Media,
- DefaultResponse: defaultResponse,
- EnableSummary: true,
- SendResponse: false,
+ SessionKey: sessionKey,
+ Channel: msg.Channel,
+ ChatID: msg.ChatID,
+ MessageID: msg.MessageID,
+ ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
+ SenderID: msg.SenderID,
+ SenderDisplayName: msg.Sender.DisplayName,
+ UserMessage: msg.Content,
+ Media: msg.Media,
+ DefaultResponse: defaultResponse,
+ EnableSummary: true,
+ SendResponse: false,
+ AllowInterimPicoPublish: true,
}
// context-dependent commands check their own Runtime fields and report
@@ -2253,7 +2255,7 @@ turnLoop:
}
logger.DebugCF("agent", "LLM response", llmResponseFields)
- if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 {
+ 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{
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 371f91d89..a67c8d040 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -2844,6 +2844,55 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
}
}
+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()
From c71cd1eede7e6281ca7091e723a59b64c31503f9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:18:14 +0000
Subject: [PATCH 21/47] build(deps): bump github.com/aws/aws-sdk-go-v2/config
Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.12 to 1.32.14.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.12...config/v1.32.14)
---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
dependency-version: 1.32.14
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
go.mod | 17 ++++++++---------
go.sum | 34 ++++++++++++++++------------------
2 files changed, 24 insertions(+), 27 deletions(-)
diff --git a/go.mod b/go.mod
index 1ff7cb306..7d641c11e 100644
--- a/go.mod
+++ b/go.mod
@@ -9,9 +9,8 @@ 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
@@ -54,17 +53,17 @@ 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/beeper/argo-go v1.1.2 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
diff --git a/go.sum b/go.sum
index 765a3211a..e3e95d47a 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,16 +43,16 @@ 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/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
From 01a33bbb618327c4edc8cbd9caabc71a681b213a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:18:19 +0000
Subject: [PATCH 22/47] build(deps): bump github.com/mymmrac/telego from 1.7.0
to 1.8.0
Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/mymmrac/telego/releases)
- [Commits](https://github.com/mymmrac/telego/compare/v1.7.0...v1.8.0)
---
updated-dependencies:
- dependency-name: github.com/mymmrac/telego
dependency-version: 1.8.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
go.mod | 3 +--
go.sum | 6 ++----
2 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/go.mod b/go.mod
index 1ff7cb306..013456afc 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,6 @@ 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/service/bedrockruntime v1.50.4
@@ -27,7 +26,7 @@ require (
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/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
diff --git a/go.sum b/go.sum
index 765a3211a..8e0f12e4c 100644
--- a/go.sum
+++ b/go.sum
@@ -23,8 +23,6 @@ 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=
@@ -189,8 +187,8 @@ github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDw
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/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=
From 919e9eb64547741e1aacf9f501496b4f0f2aac7f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:18:28 +0000
Subject: [PATCH 23/47] build(deps): bump modernc.org/sqlite from 1.48.0 to
1.48.2
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.48.0 to 1.48.2.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.48.0...v1.48.2)
---
updated-dependencies:
- dependency-name: modernc.org/sqlite
dependency-version: 1.48.2
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
go.mod | 3 +--
go.sum | 6 ++----
2 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/go.mod b/go.mod
index 1ff7cb306..f5e0ce877 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,6 @@ 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/service/bedrockruntime v1.50.4
@@ -46,7 +45,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
)
diff --git a/go.sum b/go.sum
index 765a3211a..d9ecc0d9a 100644
--- a/go.sum
+++ b/go.sum
@@ -23,8 +23,6 @@ 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=
@@ -458,8 +456,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=
From 491418775bf2b7951890b951f53044b3006afc5d Mon Sep 17 00:00:00 2001
From: Mauro
Date: Fri, 10 Apr 2026 04:10:45 +0200
Subject: [PATCH 24/47] fix(gateway): log startup errors before exit (#2414)
* fix(gateway): log startup errors before exit
* preserve deferred startup failure logging
---
pkg/gateway/gateway.go | 17 +++++-
pkg/gateway/gateway_test.go | 108 ++++++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+), 3 deletions(-)
create mode 100644 pkg/gateway/gateway_test.go
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)
+}
From 0e57a446dc54978ec85cf6b374ab6c2322a8e11c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:13:13 +0800
Subject: [PATCH 25/47] build(deps-dev): bump vite from 8.0.3 to 8.0.8 in
/web/frontend (#2451)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)
---
updated-dependencies:
- dependency-name: vite
dependency-version: 8.0.8
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 241 ++++++++++++++++++------------------
2 files changed, 123 insertions(+), 120 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index c802c71ff..b584f9076 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -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..766e5fbba 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -16,7 +16,7 @@ importers:
version: 3.41.1(react@19.2.4)
'@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)
@@ -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.4(react@19.2.4))(react@19.2.4))(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:
@@ -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==}
@@ -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==}
@@ -3181,6 +3181,10 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.9:
+ resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
+ engines: {node: ^10 || ^12 || >=14}
+
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@@ -3415,8 +3419,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
@@ -3599,8 +3603,8 @@ 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:
@@ -3797,14 +3801,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
@@ -4140,18 +4144,18 @@ snapshots:
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
@@ -4380,10 +4384,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,7 +4420,7 @@ 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': {}
@@ -5165,57 +5169,56 @@ snapshots:
'@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': {}
@@ -5296,12 +5299,12 @@ 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': {}
@@ -5367,7 +5370,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.4(react@19.2.4))(react@19.2.4))(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)
@@ -5384,7 +5387,7 @@ snapshots:
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)
+ 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 +5401,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 +5547,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 +5571,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:
@@ -7154,6 +7157,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.9:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {}
@@ -7408,29 +7417,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:
@@ -7651,7 +7657,7 @@ 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
@@ -7848,22 +7854,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: {}
From 484070736d0f980d0198ee23a2e4c279cfa0a5b5 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:13:42 +0800
Subject: [PATCH 26/47] build(deps): bump jotai from 2.19.0 to 2.19.1 in
/web/frontend (#2452)
Bumps [jotai](https://github.com/pmndrs/jotai) from 2.19.0 to 2.19.1.
- [Release notes](https://github.com/pmndrs/jotai/releases)
- [Commits](https://github.com/pmndrs/jotai/compare/v2.19.0...v2.19.1)
---
updated-dependencies:
- dependency-name: jotai
dependency-version: 2.19.1
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index b584f9076..0e76145d0 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -27,7 +27,7 @@
"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",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 766e5fbba..b0cf84766 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -42,8 +42,8 @@ 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.4)
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)
@@ -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'
@@ -6461,7 +6461,7 @@ 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.4):
optionalDependencies:
'@babel/core': 7.29.0
'@babel/template': 7.28.6
From c6d15da1eafc0f00045a4295a2c9ebbdfe023689 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:18:25 +0800
Subject: [PATCH 27/47] build(deps): bump golang.org/x/sys from 0.42.0 to
0.43.0 (#2450)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.43.0)
---
updated-dependencies:
- dependency-name: golang.org/x/sys
dependency-version: 0.43.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 1ddc059e8..851e1c860 100644
--- a/go.mod
+++ b/go.mod
@@ -130,7 +130,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 2dd86be94..c4dd2e11b 100644
--- a/go.sum
+++ b/go.sum
@@ -373,8 +373,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=
From 19493140eb42da44456d019835aba32580638800 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:19:39 +0800
Subject: [PATCH 28/47] build(deps): bump react from 19.2.4 to 19.2.5 in
/web/frontend (#2456)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)
---
updated-dependencies:
- dependency-name: react
dependency-version: 19.2.5
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 1138 +++++++++++++++++------------------
2 files changed, 570 insertions(+), 570 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 0e76145d0..468554ce0 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -29,7 +29,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.19.1",
"radix-ui": "^1.4.3",
- "react": "^19.2.0",
+ "react": "^19.2.5",
"react-dom": "^19.2.0",
"react-i18next": "^17.0.2",
"react-markdown": "^10.1.0",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index b0cf84766..33eeff59f 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.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)
+ version: 5.96.1(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.4(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.4(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -43,25 +43,25 @@ importers:
version: 8.2.1
jotai:
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.4)
+ 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.4(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)
+ version: 19.2.4(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.4(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
@@ -76,7 +76,7 @@ importers:
version: 4.1.2(@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.4(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.8(@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.4(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)
@@ -3363,8 +3363,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:
@@ -4281,11 +4281,11 @@ 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.4(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.4(react@19.2.5)
'@floating-ui/utils@0.2.11': {}
@@ -4426,743 +4426,743 @@ snapshots:
'@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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -5226,10 +5226,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': {}
@@ -5310,37 +5310,37 @@ snapshots:
'@tanstack/query-core@5.96.1': {}
- '@tanstack/react-query@5.96.1(react@19.2.4)':
+ '@tanstack/react-query@5.96.1(react@19.2.5)':
dependencies:
'@tanstack/query-core': 5.96.1
- react: 19.2.4
+ 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.4(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5)
+ use-sync-external-store: 1.6.0(react@19.2.5)
'@tanstack/router-core@1.168.7':
dependencies:
@@ -5370,7 +5370,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.8(@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.4(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)
@@ -5386,7 +5386,7 @@ 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)
+ '@tanstack/react-router': 1.168.8(react-dom@19.2.4(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
@@ -6461,12 +6461,12 @@ snapshots:
jose@6.2.2: {}
- jotai@2.19.1(@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: {}
@@ -7199,65 +7199,65 @@ snapshots:
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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.4(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -7271,23 +7271,23 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- react-dom@19.2.4(react@19.2.4):
+ react-dom@19.2.4(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.4(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.4(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
@@ -7296,7 +7296,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
@@ -7305,43 +7305,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:
@@ -7578,10 +7578,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.4(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.4(react@19.2.5)
source-map-js@1.2.1: {}
@@ -7795,43 +7795,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: {}
From f1fe2db7ac3a6363306da8c0b764cb977d9d29a9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:27:18 +0800
Subject: [PATCH 29/47] build(deps): bump @tanstack/react-query in
/web/frontend (#2458)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.1 to 5.97.0.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.97.0/packages/react-query)
---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
dependency-version: 5.97.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 18 +++++++++---------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 468554ce0..26fe2ba0c 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",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 33eeff59f..fd34c637b 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^4.2.2
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.5)
+ 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.5))(react@19.2.5)
@@ -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
@@ -5308,11 +5308,11 @@ snapshots:
'@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.5)':
+ '@tanstack/react-query@5.97.0(react@19.2.5)':
dependencies:
- '@tanstack/query-core': 5.96.1
+ '@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.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)':
From e58f00b0c1464bbc50c8316f60db68a65368f424 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:28:03 +0800
Subject: [PATCH 30/47] build(deps): bump shadcn from 4.1.2 to 4.2.0 in
/web/frontend (#2459)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.2.0/packages/shadcn)
---
updated-dependencies:
- dependency-name: shadcn
dependency-version: 4.2.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 129 +++++++++++++++++++-----------------
2 files changed, 70 insertions(+), 61 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 26fe2ba0c..4515714ab 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -37,7 +37,7 @@
"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",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index fd34c637b..11eab629e 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -72,8 +72,8 @@ 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.5))(react@19.2.5)
@@ -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':
@@ -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
@@ -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:
@@ -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:
@@ -3272,8 +3272,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:
@@ -3471,8 +3471,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:
@@ -3483,8 +3483,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:
@@ -3607,11 +3607,11 @@ packages:
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:
@@ -3910,6 +3910,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'}
@@ -4128,10 +4132,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)
@@ -4139,6 +4143,7 @@ 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:
@@ -4291,9 +4296,9 @@ snapshots:
'@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': {}
@@ -4355,7 +4360,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
@@ -4365,7 +4370,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
@@ -5649,7 +5654,7 @@ snapshots:
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.10.13: {}
+ baseline-browser-mapping@2.10.17: {}
binary-extensions@2.3.0: {}
@@ -5661,7 +5666,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:
@@ -5681,9 +5686,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)
@@ -5705,7 +5710,7 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001784: {}
+ caniuse-lite@1.0.30001787: {}
ccount@2.0.1: {}
@@ -5765,7 +5770,7 @@ snapshots:
commander@14.0.3: {}
- content-disposition@1.0.1: {}
+ content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -5844,7 +5849,7 @@ snapshots:
diff@8.0.4: {}
- dotenv@17.4.0: {}
+ dotenv@17.4.1: {}
dunder-proto@1.0.1:
dependencies:
@@ -5861,7 +5866,7 @@ snapshots:
ee-first@1.1.1: {}
- electron-to-chromium@1.5.331: {}
+ electron-to-chromium@1.5.334: {}
emoji-regex@10.6.0: {}
@@ -6060,7 +6065,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
@@ -6078,7 +6083,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
@@ -6328,7 +6333,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.10: {}
+ hono@4.12.12: {}
html-parse-stringify@3.0.1:
dependencies:
@@ -6968,7 +6973,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
@@ -7193,7 +7198,7 @@ snapshots:
punycode@2.3.1: {}
- qs@6.15.0:
+ qs@6.15.1:
dependencies:
side-channel: 1.1.0
@@ -7495,13 +7500,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
@@ -7516,11 +7521,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
@@ -7544,7 +7549,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
@@ -7568,7 +7573,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
@@ -7662,11 +7667,11 @@ snapshots:
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:
@@ -7676,7 +7681,7 @@ snapshots:
tough-cookie@6.0.1:
dependencies:
- tldts: 7.0.27
+ tldts: 7.0.28
trim-lines@3.0.1: {}
@@ -7929,6 +7934,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: {}
From 7788ed467702e69d1ab3a1e9b52845bfa005e078 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 10:46:45 +0800
Subject: [PATCH 31/47] build(deps): bump
github.com/modelcontextprotocol/go-sdk (#2455)
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.4.1 to 1.5.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.4.1...v1.5.0)
---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
dependency-version: 1.5.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
go.mod | 2 +-
go.sum | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/go.mod b/go.mod
index 851e1c860..9eaa72a0b 100644
--- a/go.mod
+++ b/go.mod
@@ -25,7 +25,7 @@ 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/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
diff --git a/go.sum b/go.sum
index c4dd2e11b..6a2194960 100644
--- a/go.sum
+++ b/go.sum
@@ -115,8 +115,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=
@@ -185,8 +185,8 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe
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/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=
From 795ec9af053153176a46c8d0d03d9c4b111a6240 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 10 Apr 2026 11:12:54 +0800
Subject: [PATCH 32/47] fix(launcher): fall back to token auth on unsupported
platforms (#2466)
Handle platforms where the dashboard password store is unavailable
by treating legacy token auth as initialized, rejecting password
setup, and adding platform-specific store stubs and tests.
---
web/backend/api/auth.go | 20 +++++-
web/backend/api/auth_test.go | 61 +++++++++++++++++++
web/backend/dashboardauth/platform.go | 7 +++
web/backend/dashboardauth/store.go | 2 +
.../dashboardauth/store_unsupported.go | 60 ++++++++++++++++++
web/backend/main.go | 20 ++++--
6 files changed, 163 insertions(+), 7 deletions(-)
create mode 100644 web/backend/dashboardauth/platform.go
create mode 100644 web/backend/dashboardauth/store_unsupported.go
diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go
index 0790a6b76..3cfc3e20d 100644
--- a/web/backend/api/auth.go
+++ b/web/backend/api/auth.go
@@ -81,8 +81,13 @@ type launcherAuthHandlers struct {
loginLimit *loginRateLimiter
}
+func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
+ return h.store == nil && h.storeErr == nil && h.token != ""
+}
+
// isStoreInitialized safely queries the store.
-// Returns (false, nil) when no store is configured (storeErr also nil).
+// 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
@@ -95,6 +100,9 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er
"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)
@@ -129,7 +137,7 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
}
}
- if initialized {
+ if initialized && h.store != nil {
// Bcrypt path: verify against the stored hash.
var err error
ok, err = h.store.VerifyPassword(r.Context(), in)
@@ -218,6 +226,14 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
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"}`))
diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go
index 58ffb823a..58f819ec6 100644
--- a/web/backend/api/auth_test.go
+++ b/web/backend/api/auth_test.go
@@ -75,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")
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/store.go b/web/backend/dashboardauth/store.go
index 44605ba22..870796bba 100644
--- a/web/backend/dashboardauth/store.go
+++ b/web/backend/dashboardauth/store.go
@@ -1,3 +1,5 @@
+//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.
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/main.go b/web/backend/main.go
index d9ea3474c..c5d25f6ef 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -229,11 +229,21 @@ func main() {
// Open the bcrypt password store (creates the DB file on first run).
authStore, authStoreErr := dashboardauth.New(picoHome)
- if authStoreErr != nil {
- logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
- authStore = nil
- } else {
+ 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
@@ -250,7 +260,7 @@ func main() {
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
- PasswordStore: authStore,
+ PasswordStore: passwordStore,
StoreError: authStoreErr,
})
From d9977715a39dad47094e89eeb80e059bdc6c59e7 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 10 Apr 2026 11:13:05 +0800
Subject: [PATCH 33/47] fix(launcher): align react and react-dom versions
(#2467)
Pin react and react-dom to 19.2.5 to avoid runtime crashes caused by a version mismatch.
Refresh the pnpm lockfile to keep frontend dependencies in sync.
---
web/frontend/package.json | 4 +-
web/frontend/pnpm-lock.yaml | 538 ++++++++++++++++++------------------
2 files changed, 266 insertions(+), 276 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 4515714ab..51e6f1dd9 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -29,8 +29,8 @@
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.19.1",
"radix-ui": "^1.4.3",
- "react": "^19.2.5",
- "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",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 11eab629e..e104eaee6 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -22,10 +22,10 @@ importers:
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.5))(react@19.2.5)
+ 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.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)
+ 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
@@ -46,16 +46,16 @@ importers:
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.5))(react@19.2.5)
+ 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.5
+ specifier: 19.2.5
version: 19.2.5
react-dom:
- specifier: ^19.2.0
- version: 19.2.4(react@19.2.5)
+ 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.5))(react@19.2.5)(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.5)
@@ -76,7 +76,7 @@ importers:
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.5))(react@19.2.5)
+ 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.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))
+ 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)
@@ -3177,10 +3177,6 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
- postcss@8.5.8:
- resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
- engines: {node: ^10 || ^12 || >=14}
-
postcss@8.5.9:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
@@ -3300,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==}
@@ -4286,11 +4282,11 @@ 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.5))(react@19.2.5)':
+ '@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.5
- react-dom: 19.2.4(react@19.2.5)
+ react-dom: 19.2.5(react@19.2.5)
'@floating-ui/utils@0.2.11': {}
@@ -4431,117 +4427,117 @@ snapshots:
'@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.5))(react@19.2.5)':
+ '@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.5))(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.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-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.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)':
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.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.4(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-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.4(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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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-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.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-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.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)':
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.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.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-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.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)':
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.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.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-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.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)':
dependencies:
'@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.4(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-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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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)
'@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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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)
'@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.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-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.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)':
dependencies:
'@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.4(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.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)
@@ -4552,16 +4548,16 @@ snapshots:
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.5))(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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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-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.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)
@@ -4572,23 +4568,23 @@ snapshots:
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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(react@19.2.5))(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.4(react@19.2.5))(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.4(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.4(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.4(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.5
- react-dom: 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
@@ -4600,30 +4596,30 @@ snapshots:
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.5))(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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
- react-dom: 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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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-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.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)
@@ -4634,44 +4630,44 @@ snapshots:
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.5))(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)':
dependencies:
'@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.4(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)
react: 19.2.5
- react-dom: 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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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-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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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.4(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.4(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.4(react@19.2.5))(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.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)
@@ -4683,302 +4679,302 @@ snapshots:
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.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)':
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.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.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-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.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)':
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.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-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.5))(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.4(react@19.2.5))(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.4(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.4(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.4(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.4(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.4(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-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.5
- react-dom: 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.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)':
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.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-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.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.4(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.4(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-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.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-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.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)':
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.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-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.5))(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.4(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.4(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-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.4(react@19.2.5))(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.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-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.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)':
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.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-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.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.4(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-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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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-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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(react@19.2.5))(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.4(react@19.2.5))(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.4(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.4(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.4(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.4(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-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.5
- react-dom: 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.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)':
dependencies:
- '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(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.4(react@19.2.5))(react@19.2.5)
+ '@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.4(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)
'@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.5
- react-dom: 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.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)':
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.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-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
- react-dom: 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-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.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)':
dependencies:
'@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.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-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.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)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
- react-dom: 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-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.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)':
dependencies:
'@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.4(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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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.4(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-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.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-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.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)':
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.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.4(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.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-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.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)':
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.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.4(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.4(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-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
- react-dom: 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-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.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)':
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.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-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.5))(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.4(react@19.2.5))(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.4(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.4(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.4(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-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.4(react@19.2.5))(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.5
- react-dom: 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.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)':
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.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.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-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.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)':
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.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-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.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)
'@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.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)
@@ -4990,114 +4986,114 @@ snapshots:
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.5))(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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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.4(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-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
- react-dom: 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-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.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)':
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.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-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.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.4(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.4(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.4(react@19.2.5))(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.4(react@19.2.5))(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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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.4(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-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.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-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.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)':
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.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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(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.4(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.4(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.4(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-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.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-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.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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@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.4(react@19.2.5))(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.4(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.4(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.4(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.4(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-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.4(react@19.2.5))(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.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)
@@ -5163,11 +5159,11 @@ snapshots:
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.5))(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)':
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.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.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)
@@ -5320,31 +5316,31 @@ snapshots:
'@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.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)':
+ '@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.5))(react@19.2.5)
+ '@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.5
- react-dom: 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.5))(react@19.2.5)':
+ '@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.5))(react@19.2.5)
+ '@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.5
- react-dom: 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.5))(react@19.2.5)':
+ '@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.5
- react-dom: 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':
@@ -5375,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.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))':
+ '@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)
@@ -5391,7 +5387,7 @@ snapshots:
unplugin: 2.3.11
zod: 3.25.76
optionalDependencies:
- '@tanstack/react-router': 1.168.8(react-dom@19.2.4(react@19.2.5))(react@19.2.5)
+ '@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
@@ -7156,12 +7152,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss@8.5.8:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
postcss@8.5.9:
dependencies:
nanoid: 3.3.11
@@ -7204,55 +7194,55 @@ snapshots:
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.5))(react@19.2.5):
+ 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.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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(react@19.2.5)
+ '@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.4(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.4(react@19.2.5))(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.4(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.4(react@19.2.5))(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(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.4(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.4(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.4(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.4(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.4(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.4(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.4(react@19.2.5))(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)
@@ -7260,9 +7250,9 @@ snapshots:
'@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.4(react@19.2.5))(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.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)
@@ -7276,12 +7266,12 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- react-dom@19.2.4(react@19.2.5):
+ react-dom@19.2.5(react@19.2.5):
dependencies:
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.5))(react@19.2.5)(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
@@ -7289,7 +7279,7 @@ snapshots:
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
- react-dom: 19.2.4(react@19.2.5)
+ 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.5):
@@ -7583,10 +7573,10 @@ snapshots:
sisteransi@1.0.5: {}
- sonner@2.0.7(react-dom@19.2.4(react@19.2.5))(react@19.2.5):
+ sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
- react-dom: 19.2.4(react@19.2.5)
+ react-dom: 19.2.5(react@19.2.5)
source-map-js@1.2.1: {}
From 187189ad4adacf6ada228a30b32da349a95a518b Mon Sep 17 00:00:00 2001
From: winterfx <136159170+winterfx@users.noreply.github.com>
Date: Fri, 10 Apr 2026 11:59:50 +0800
Subject: [PATCH 34/47] fix(seahorse): sanitize user input for FTS5 MATCH
queries (#2436)
User input containing FTS5 operators (-, +, *, OR, NOT, :, quotes,
parentheses) could cause query errors or unexpected search results.
Wrap each token in double quotes to force literal matching while
preserving user-quoted phrases.
Co-authored-by: Claude Opus 4.6
---
pkg/seahorse/fts5_sanitize.go | 70 +++++++++
pkg/seahorse/fts5_sanitize_test.go | 237 +++++++++++++++++++++++++++++
pkg/seahorse/store.go | 14 +-
3 files changed, 319 insertions(+), 2 deletions(-)
create mode 100644 pkg/seahorse/fts5_sanitize.go
create mode 100644 pkg/seahorse/fts5_sanitize_test.go
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/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 = ?")
From c8bac699fef02e509a367b627dde61aed5a4a666 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Fri, 10 Apr 2026 20:23:12 +0800
Subject: [PATCH 35/47] fix(pico): separate thought and normal messages
---
pkg/agent/loop.go | 55 ++++++++++++++--
pkg/agent/loop_test.go | 56 ++++++++++++++++
pkg/channels/pico/client.go | 8 ++-
pkg/channels/pico/client_test.go | 64 +++++++++++++++++++
pkg/channels/pico/pico.go | 16 ++++-
pkg/channels/pico/protocol.go | 10 +++
pkg/providers/antigravity_provider.go | 17 +++--
pkg/providers/antigravity_provider_test.go | 24 +++++++
.../src/components/chat/assistant-message.tsx | 38 +++++++++--
.../src/components/chat/chat-page.tsx | 1 +
web/frontend/src/features/chat/history.ts | 3 +-
web/frontend/src/features/chat/protocol.ts | 27 +++++++-
web/frontend/src/i18n/locales/en.json | 1 +
web/frontend/src/i18n/locales/zh.json | 1 +
web/frontend/src/store/chat.ts | 3 +
15 files changed, 300 insertions(+), 24 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index ac230aa86..03fdfec82 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -105,6 +105,8 @@ const (
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"
@@ -1622,6 +1624,41 @@ 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{
+ Channel: "pico",
+ ChatID: chatID,
+ Content: reasoningContent,
+ Metadata: map[string]string{
+ metadataKeyMessageKind: messageKindThought,
+ },
+ }); 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,
@@ -2223,12 +2260,16 @@ turnLoop:
if reasoningContent == "" {
reasoningContent = response.ReasoningContent
}
- go al.handleReasoning(
- turnCtx,
- reasoningContent,
- ts.channel,
- al.targetReasoningChannelID(ts.channel),
- )
+ if ts.channel == "pico" {
+ 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"),
@@ -2277,7 +2318,7 @@ turnLoop:
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 {
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index a67c8d040..56ea000c8 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -2660,6 +2660,62 @@ 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.Metadata[metadataKeyMessageKind] != messageKindThought {
+ t.Fatalf("thought metadata kind = %q, want %q", thoughtMsg.Metadata[metadataKeyMessageKind], messageKindThought)
+ }
+}
+
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go
index b4bfd09e5..bf3e38cf4 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
}
@@ -285,7 +289,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 e22da1ba1..6525c2d4a 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(metadata map[string]string) bool {
+ if len(metadata) == 0 {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(metadata["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.Metadata)
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/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/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 9966226b2..418516172 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -1,5 +1,6 @@
-import { IconCheck, IconCopy } from "@tabler/icons-react"
+import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react"
+import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
@@ -7,16 +8,20 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
+import { cn } from "@/lib/utils"
interface AssistantMessageProps {
content: string
+ isThought?: boolean
timestamp?: string | number
}
export function AssistantMessage({
content,
+ isThought = false,
timestamp = "",
}: AssistantMessageProps) {
+ const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
@@ -33,6 +38,12 @@ export function AssistantMessage({
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/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index b53abeb76..2434d4576 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -47,6 +47,7 @@
"step3": "Preparing response...",
"step4": "Almost there..."
},
+ "reasoningLabel": "Reasoning",
"history": "History",
"noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index e2e8eae04..c03d4181d 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -47,6 +47,7 @@
"step3": "准备回复...",
"step4": "马上就好..."
},
+ "reasoningLabel": "思考",
"history": "历史记录",
"noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败",
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[]
}
From 459e78c076fb7659ce20193a2b8f42b10f57e830 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 00:50:24 +0800
Subject: [PATCH 36/47] fix(gemini): harden dedicated provider compatibility
---
pkg/providers/factory_provider.go | 22 +-
pkg/providers/factory_provider_test.go | 56 ++
pkg/providers/gemini_provider.go | 758 ++++++++++++++++++++++++
pkg/providers/gemini_provider_test.go | 440 ++++++++++++++
pkg/providers/openai_compat/provider.go | 5 +-
web/backend/api/session.go | 13 +
web/backend/api/session_test.go | 53 ++
7 files changed, 1342 insertions(+), 5 deletions(-)
create mode 100644 pkg/providers/gemini_provider.go
create mode 100644 pkg/providers/gemini_provider_test.go
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..b3042fcd7
--- /dev/null
+++ b/pkg/providers/gemini_provider.go
@@ -0,0 +1,758 @@
+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)
+ var systemInstruction *geminiContent
+
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ if strings.TrimSpace(msg.Content) != "" {
+ systemInstruction = &geminiContent{Parts: []geminiPart{{Text: 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
+ part.ThoughtSignatureSnake = 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 systemInstruction != nil {
+ body["systemInstruction"] = systemInstruction
+ }
+
+ 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{"includeThoughts": true}
+ rawLevel, _ := options["thinking_level"].(string)
+ rawLevel = strings.ToLower(strings.TrimSpace(rawLevel))
+
+ if isGemini25Model(model) {
+ if budget, ok := mapGeminiThinkingBudget(rawLevel, model); ok {
+ config["thinkingBudget"] = budget
+ }
+ 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 mapGeminiThinkingBudget(level string, model string) (int, bool) {
+ level = strings.ToLower(strings.TrimSpace(level))
+ if level == "" {
+ return 0, false
+ }
+
+ switch level {
+ case "adaptive":
+ return -1, true
+ case "minimal":
+ if strings.Contains(strings.ToLower(model), "pro") {
+ return 128, true
+ }
+ return 0, true
+ case "off":
+ if strings.Contains(strings.ToLower(model), "pro") {
+ // Gemini 2.5 Pro cannot disable thinking; use the lowest supported budget.
+ return 128, true
+ }
+ 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.TrimPrefix(line, "data: ")
+ if data == "[DONE]" {
+ break
+ }
+
+ var chunk geminiGenerateContentResponse
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ continue
+ }
+
+ 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)
+ key := tc.ID
+ if strings.TrimSpace(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..c1bdf7c7f
--- /dev/null
+++ b/pkg/providers/gemini_provider_test.go
@@ -0,0 +1,440 @@
+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_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_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/web/backend/api/session.go b/web/backend/api/session.go
index ae580d9aa..9bb6055e2 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -281,6 +281,12 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
}
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...)
@@ -309,6 +315,13 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
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
}
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 5d7620362..599921bfe 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -218,6 +218,59 @@ func TestHandleGetSession_JSONLStorage(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()
From 83e93ca572382ce564f516959f38bb010e568d4d Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 01:15:38 +0800
Subject: [PATCH 37/47] fix(gemini): align thinking-off and system prompt
semantics
---
pkg/providers/gemini_provider.go | 32 +++++++-----
pkg/providers/gemini_provider_test.go | 75 +++++++++++++++++++++++++++
2 files changed, 93 insertions(+), 14 deletions(-)
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
index b3042fcd7..5952188fd 100644
--- a/pkg/providers/gemini_provider.go
+++ b/pkg/providers/gemini_provider.go
@@ -174,13 +174,13 @@ func (p *GeminiProvider) buildRequestBody(
) map[string]any {
contents := make([]geminiContent, 0, len(messages))
toolCallNames := make(map[string]string)
- var systemInstruction *geminiContent
+ systemPrompts := make([]string, 0, 1)
for _, msg := range messages {
switch msg.Role {
case "system":
if strings.TrimSpace(msg.Content) != "" {
- systemInstruction = &geminiContent{Parts: []geminiPart{{Text: msg.Content}}}
+ systemPrompts = append(systemPrompts, msg.Content)
}
case "user":
@@ -248,8 +248,12 @@ func (p *GeminiProvider) buildRequestBody(
body := map[string]any{
"contents": contents,
}
- if systemInstruction != nil {
- body["systemInstruction"] = systemInstruction
+ 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 {
@@ -331,12 +335,19 @@ func buildGeminiThinkingConfig(model string, options map[string]any) map[string]
return nil
}
- config := map[string]any{"includeThoughts": true}
+ 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 budget, ok := mapGeminiThinkingBudget(rawLevel, model); ok {
+ if budget, ok := mapGeminiThinkingBudget(rawLevel); ok {
config["thinkingBudget"] = budget
}
return config
@@ -358,7 +369,7 @@ func isGemini25Model(model string) bool {
return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25")
}
-func mapGeminiThinkingBudget(level string, model string) (int, bool) {
+func mapGeminiThinkingBudget(level string) (int, bool) {
level = strings.ToLower(strings.TrimSpace(level))
if level == "" {
return 0, false
@@ -368,15 +379,8 @@ func mapGeminiThinkingBudget(level string, model string) (int, bool) {
case "adaptive":
return -1, true
case "minimal":
- if strings.Contains(strings.ToLower(model), "pro") {
- return 128, true
- }
return 0, true
case "off":
- if strings.Contains(strings.ToLower(model), "pro") {
- // Gemini 2.5 Pro cannot disable thinking; use the lowest supported budget.
- return 128, true
- }
return 0, true
case "low":
return 1024, true
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
index c1bdf7c7f..19b9fcd63 100644
--- a/pkg/providers/gemini_provider_test.go
+++ b/pkg/providers/gemini_provider_test.go
@@ -312,6 +312,81 @@ func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testi
}
}
+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_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(
From cbae69ad640f4fdf9e7c1f0f4cfa38c6c0daf497 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 01:38:13 +0800
Subject: [PATCH 38/47] fix(gemini): honor pro-model thinking constraints
---
pkg/providers/gemini_provider.go | 19 ++++++++++
pkg/providers/gemini_provider_test.go | 50 +++++++++++++++++++++++++++
2 files changed, 69 insertions(+)
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
index 5952188fd..7b913b775 100644
--- a/pkg/providers/gemini_provider.go
+++ b/pkg/providers/gemini_provider.go
@@ -347,12 +347,21 @@ func buildGeminiThinkingConfig(model string, options map[string]any) map[string]
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
}
@@ -369,6 +378,16 @@ func isGemini25Model(model string) bool {
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 == "" {
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
index 19b9fcd63..cbfb97c45 100644
--- a/pkg/providers/gemini_provider_test.go
+++ b/pkg/providers/gemini_provider_test.go
@@ -362,6 +362,56 @@ func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testin
}
}
+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(
From b73caebe6f3d798499bce4c69e0ff743b1cd03ca Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 01:44:39 +0800
Subject: [PATCH 39/47] fix(chat): improve thought readability in dark mode
---
web/frontend/src/components/chat/assistant-message.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 418516172..8dcbe15a1 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -39,7 +39,7 @@ export function AssistantMessage({
PicoClaw
{isThought && (
-
+
{t("chat.reasoningLabel")}
@@ -57,7 +57,7 @@ export function AssistantMessage({
className={cn(
"relative overflow-hidden rounded-xl border",
isThought
- ? "border-amber-200/90 bg-amber-50/70 text-amber-950"
+ ? "border-amber-200/90 bg-amber-50/70 text-amber-950 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-100"
: "bg-card text-card-foreground",
)}
>
@@ -82,7 +82,7 @@ export function AssistantMessage({
className={cn(
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
isThought
- ? "bg-amber-100/70 hover:bg-amber-200/80"
+ ? "bg-amber-100/70 hover:bg-amber-200/80 dark:bg-amber-500/20 dark:hover:bg-amber-400/30"
: "bg-background/50 hover:bg-background/80",
)}
onClick={handleCopy}
From 86917faa9ba2fc8f7d08b3106080c15a04daaa89 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 02:23:35 +0800
Subject: [PATCH 40/47] fix(ci): resolve lint header casing and fallback test
routing
---
pkg/agent/loop_test.go | 2 +-
pkg/providers/gemini_provider.go | 2 +-
pkg/providers/gemini_provider_test.go | 12 ++++++------
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 56ea000c8..7fe5836b3 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -1921,7 +1921,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,
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
index 7b913b775..370ce5674 100644
--- a/pkg/providers/gemini_provider.go
+++ b/pkg/providers/gemini_provider.go
@@ -153,7 +153,7 @@ func (p *GeminiProvider) ChatStream(
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)
+ req.Header.Set("X-Goog-Api-Key", p.apiKey)
}
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
index cbfb97c45..9debcd79f 100644
--- a/pkg/providers/gemini_provider_test.go
+++ b/pkg/providers/gemini_provider_test.go
@@ -19,8 +19,8 @@ func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) {
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 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)
@@ -489,8 +489,8 @@ func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
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)
+ 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{
@@ -533,8 +533,8 @@ func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
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)
+ 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{
From e9f55d776de85117710c3f289d852959d2c1f7c1 Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 11:18:41 +0800
Subject: [PATCH 41/47] fix(review): address copilot backpressure and SSE parse
feedback
---
pkg/agent/loop.go | 2 +-
pkg/providers/gemini_provider.go | 7 ++-
pkg/providers/gemini_provider_test.go | 77 +++++++++++++++++++++++++++
3 files changed, 83 insertions(+), 3 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 03fdfec82..a856c0fca 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -2261,7 +2261,7 @@ turnLoop:
reasoningContent = response.ReasoningContent
}
if ts.channel == "pico" {
- al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
+ go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
} else {
go al.handleReasoning(
turnCtx,
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
index 370ce5674..96f8da66d 100644
--- a/pkg/providers/gemini_provider.go
+++ b/pkg/providers/gemini_provider.go
@@ -481,14 +481,17 @@ func parseGeminiStreamResponse(
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := strings.TrimPrefix(line, "data: ")
+ 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 {
- continue
+ return nil, fmt.Errorf("invalid gemini stream chunk: %w", err)
}
for _, candidate := range chunk.Candidates {
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
index 9debcd79f..3c90cc4e2 100644
--- a/pkg/providers/gemini_provider_test.go
+++ b/pkg/providers/gemini_provider_test.go
@@ -212,6 +212,83 @@ func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) {
}
}
+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_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) {
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
From 6fbd7e0a3fb04929f21f8d1b3ebd2261057c144f Mon Sep 17 00:00:00 2001
From: lc6464 <64722907+lc6464@users.noreply.github.com>
Date: Sat, 11 Apr 2026 12:02:58 +0800
Subject: [PATCH 42/47] fix(gemini): align thoughtSignature and stream tool IDs
---
pkg/providers/gemini_provider.go | 24 +++--
pkg/providers/gemini_provider_test.go | 121 ++++++++++++++++++++++++++
2 files changed, 139 insertions(+), 6 deletions(-)
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
index 96f8da66d..561387534 100644
--- a/pkg/providers/gemini_provider.go
+++ b/pkg/providers/gemini_provider.go
@@ -226,7 +226,6 @@ func (p *GeminiProvider) buildRequestBody(
}
if thoughtSignature != "" {
part.ThoughtSignature = thoughtSignature
- part.ThoughtSignatureSnake = thoughtSignature
}
content.Parts = append(content.Parts, part)
}
@@ -508,12 +507,25 @@ func parseGeminiStreamResponse(
}
if part.FunctionCall != nil {
tc := buildGeminiToolCall(part)
- key := tc.ID
- if strings.TrimSpace(key) == "" {
- fallbackIndex++
- key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex)
- tc.ID = key
+ 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)
}
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
index 3c90cc4e2..a0ab748eb 100644
--- a/pkg/providers/gemini_provider_test.go
+++ b/pkg/providers/gemini_provider_test.go
@@ -289,6 +289,127 @@ func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
}
}
+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)
From 080f532d825a4585b8030dce1ec14ef803a477c1 Mon Sep 17 00:00:00 2001
From: sky5454
Date: Sun, 12 Apr 2026 17:41:10 +0800
Subject: [PATCH 43/47] build: add Android arm64 cross-compile support
- Add build-android-arm64, build-launcher-android-arm64, build-all-android
targets to Makefile and web/Makefile
- Use -tags stdjson (no goolm) for Android; CGO_ENABLED=0 throughout
- Output staged as build/android-staging/arm64-v8a/libpicoclaw{,-web}.so
for JNI consumption; zip packaging handled by CI
- Exclude Matrix channel from android builds (channel_matrix.go) to avoid
modernc.org/sqlite CGO dependency
- Exclude systray from android builds; use headless stub instead
(systray.go / systray_stub_nocgo.go)
---
Makefile | 34 +++++++++++++++++++++++++++++++
pkg/gateway/channel_matrix.go | 2 +-
web/Makefile | 15 +++++++++++++-
web/backend/systray.go | 2 +-
web/backend/systray_stub_nocgo.go | 2 +-
5 files changed, 51 insertions(+), 4 deletions(-)
diff --git a/Makefile b/Makefile
index f7ebc7411..ef0dcd9b1 100644
--- a/Makefile
+++ b/Makefile
@@ -205,6 +205,40 @@ 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 \
+ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
+ WEB_GO='GOOS=android GOARCH=arm64 CGO_ENABLED=0 go' \
+ GO_BUILD_TAGS='stdjson' \
+ LDFLAGS='$(LDFLAGS)'
+ @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
+
+## build-all-android: Build core and launcher for all Android architectures and package as universal zip
+build-all-android: 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)"
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/web/Makefile b/web/Makefile
index 891c170c2..58b65621d 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-all-android
# 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-all-android: 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/systray.go b/web/backend/systray.go
index 9dcc025df..204bd7dc6 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -1,4 +1,4 @@
-//go:build (!darwin && !freebsd) || cgo
+//go:build (!darwin && !freebsd && !android) || cgo
package main
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
From 168b6bec5817e4b214e3be2596beb94fe8f07715 Mon Sep 17 00:00:00 2001
From: sky5454
Date: Sun, 12 Apr 2026 18:35:05 +0800
Subject: [PATCH 44/47] build(android): ci build added
---
.github/workflows/release.yml | 11 +++++++++++
Makefile | 1 +
2 files changed, 12 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ce341770..03c7ce7d8 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-all-android
+ gh release upload "${{ inputs.tag }}" \
+ build/picoclaw-android-universal.zip \
+ --clobber
+
- name: Apply release flags
shell: bash
env:
diff --git a/Makefile b/Makefile
index ef0dcd9b1..1cc853458 100644
--- a/Makefile
+++ b/Makefile
@@ -260,6 +260,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-all-android
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills
From b6617a4b176f5e1c622e40fbe282b102231e3692 Mon Sep 17 00:00:00 2001
From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com>
Date: Sun, 12 Apr 2026 03:44:24 -0700
Subject: [PATCH 45/47] feat(cli): structured terminal UI for PicoClaw CLI like
modern CLIs (#2229)
* feat(cli): add boxed help/error UI with no-color support
* fix: CI testing error
* fix: lint errors
* fix linter error
* fix: address review
---
cmd/picoclaw/internal/cliui/cliui.go | 147 +++++++++++
cmd/picoclaw/internal/cliui/cliui_test.go | 180 +++++++++++++
cmd/picoclaw/internal/cliui/help_cmd.go | 298 ++++++++++++++++++++++
cmd/picoclaw/internal/cliui/help_error.go | 75 ++++++
cmd/picoclaw/internal/cliui/onboard.go | 110 ++++++++
cmd/picoclaw/internal/cliui/status.go | 168 ++++++++++++
cmd/picoclaw/internal/cliui/version.go | 61 +++++
cmd/picoclaw/internal/onboard/helpers.go | 25 +-
cmd/picoclaw/internal/status/helpers.go | 130 ++++++++--
cmd/picoclaw/internal/version/command.go | 11 +-
cmd/picoclaw/main.go | 84 +++++-
cmd/picoclaw/main_test.go | 9 +-
go.mod | 11 +-
go.sum | 19 ++
14 files changed, 1257 insertions(+), 71 deletions(-)
create mode 100644 cmd/picoclaw/internal/cliui/cliui.go
create mode 100644 cmd/picoclaw/internal/cliui/cliui_test.go
create mode 100644 cmd/picoclaw/internal/cliui/help_cmd.go
create mode 100644 cmd/picoclaw/internal/cliui/help_error.go
create mode 100644 cmd/picoclaw/internal/cliui/onboard.go
create mode 100644 cmd/picoclaw/internal/cliui/status.go
create mode 100644 cmd/picoclaw/internal/cliui/version.go
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/go.mod b/go.mod
index 9eaa72a0b..b7259bde7 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
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
@@ -25,6 +26,7 @@ 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/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
@@ -35,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
@@ -65,7 +68,12 @@ require (
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
@@ -79,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
@@ -88,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
diff --git a/go.sum b/go.sum
index 6a2194960..8306976c4 100644
--- a/go.sum
+++ b/go.sum
@@ -55,6 +55,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU
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=
@@ -67,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=
@@ -179,12 +191,16 @@ 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/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=
@@ -218,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=
@@ -276,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=
From 681b2a258b727102f8d89e27ed58b051d363072e Mon Sep 17 00:00:00 2001
From: sky5454
Date: Sun, 12 Apr 2026 18:50:52 +0800
Subject: [PATCH 46/47] =?UTF-8?q?build:=20address=20PR=20review=20?=
=?UTF-8?q?=E2=80=94=20fix=20Android=20launcher=20flags,=20systray=20tag,?=
=?UTF-8?q?=20rename=20target?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/release.yml | 2 +-
Makefile | 13 +++++--------
web/Makefile | 4 ++--
web/backend/systray.go | 2 +-
4 files changed, 9 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 03c7ce7d8..aab9cf874 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -116,7 +116,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sudo apt-get install -y zip
- make build-all-android
+ make build-android-bundle
gh release upload "${{ inputs.tag }}" \
build/picoclaw-android-universal.zip \
--clobber
diff --git a/Makefile b/Makefile
index 1cc853458..beddd1138 100644
--- a/Makefile
+++ b/Makefile
@@ -216,15 +216,12 @@ build-android-arm64: generate
build-launcher-android-arm64:
@echo "Building picoclaw-launcher for android/arm64..."
@mkdir -p $(BUILD_DIR)
- @$(MAKE) -C web build \
- OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
- WEB_GO='GOOS=android GOARCH=arm64 CGO_ENABLED=0 go' \
- GO_BUILD_TAGS='stdjson' \
- LDFLAGS='$(LDFLAGS)'
+ @$(MAKE) -C web build-android-arm64 \
+ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64"
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
-## build-all-android: Build core and launcher for all Android architectures and package as universal zip
-build-all-android: generate
+## 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)
@@ -260,7 +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-all-android
+ @$(MAKE) build-android-bundle
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills
diff --git a/web/Makefile b/web/Makefile
index 58b65621d..cf5ea774a 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -1,5 +1,5 @@
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
- build-android-arm64 build-all-android
+ build-android-arm64 build-android-bundle
# Go variables
GO?=CGO_ENABLED=0 go
@@ -99,7 +99,7 @@ build-android-arm64: build-frontend
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
# Build launcher for all Android architectures
-build-all-android: build-frontend
+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"
diff --git a/web/backend/systray.go b/web/backend/systray.go
index 204bd7dc6..41fea1fbe 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -1,4 +1,4 @@
-//go:build (!darwin && !freebsd && !android) || cgo
+//go:build !android && ((!darwin && !freebsd) || cgo)
package main
From 2b2bc26f8ea7aa29ae5d33146c389fe0ce536cbb Mon Sep 17 00:00:00 2001
From: Guoguo <16666742+imguoguo@users.noreply.github.com>
Date: Mon, 13 Apr 2026 10:46:17 +0800
Subject: [PATCH 47/47] docs: fix Conventional Commits links in CONTRIBUTING
files (#2494)
- CONTRIBUTING.md: change link from zh-hans to en locale
- CONTRIBUTING.zh.md: fix NBSP causing surrounding text to be absorbed into the link
- Both files now use proper markdown link syntax
---
CONTRIBUTING.md | 2 +-
CONTRIBUTING.zh.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
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/) 规范来撰写
### 保持与上游同步