-
-**Getting started:**
-
-Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
-
-For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
diff --git a/assets/launcher-tui.jpg b/assets/launcher-tui.jpg
deleted file mode 100644
index 659c97794..000000000
Binary files a/assets/launcher-tui.jpg and /dev/null differ
diff --git a/assets/wechat.png b/assets/wechat.png
index b368f75d3..18247ff82 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw-launcher-tui/README.md b/cmd/picoclaw-launcher-tui/README.md
deleted file mode 100644
index a942045a5..000000000
--- a/cmd/picoclaw-launcher-tui/README.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Picoclaw Launcher TUI
-
-This directory contains the terminal-based TUI launcher for `picoclaw`.
-It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
-
-## Architecture
-
-The TUI launcher is implemented purely in Go with no external runtime dependencies:
-* **`main.go`**: Application entry point, handles initialization and main event loop
-* **`ui/`**: TUI interface components built on tview + tcell framework:
- - `home.go`: Main dashboard with navigation menu
- - `schemes.go`: AI model scheme management
- - `users.go`: User and API key management for model providers
- - `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
- - `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
- - `app.go`: Core TUI application framework and navigation logic
- - `models.go`: Data structures and state management
-* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
-
-## Getting Started
-
-### Prerequisites
-
-* Go 1.25+
-* Terminal with 256-color support (most modern terminals are compatible)
-
-### Development
-
-Run the TUI launcher directly in development mode:
-
-```bash
-# From project root
-go run ./cmd/picoclaw-launcher-tui
-
-# Or from this directory
-go run .
-```
-
-### Build
-
-Build the standalone TUI launcher binary:
-
-```bash
-# From project root (recommended)
-make build-launcher-tui
-
-# Output will be at:
-# build/picoclaw-launcher-tui--
-# with symlink build/picoclaw-launcher-tui
-
-# Or build directly from this directory
-go build -o picoclaw-launcher-tui .
-```
-
-### Key Features
-
-* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
-* ⚙️ AI model scheme and API key management
-* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
-* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
-* 💬 One-click launch of interactive AI chat session
-* 🎯 Keyboard-first design with intuitive shortcuts
-
-### Other Commands
-
-```bash
-# Run with custom config file path
-go run . /path/to/custom/config.json
-```
diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go
deleted file mode 100644
index 227b9fa3d..000000000
--- a/cmd/picoclaw-launcher-tui/config/config.go
+++ /dev/null
@@ -1,236 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-// Package config provides types and I/O for ~/.picoclaw/tui.toml.
-package config
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/BurntSushi/toml"
-
- "github.com/sipeed/picoclaw/pkg/fileutil"
-)
-
-// DefaultConfigPath returns the default path to the tui.toml config file.
-func DefaultConfigPath() string {
- home, err := os.UserHomeDir()
- if err != nil {
- home = "."
- }
- return filepath.Join(home, ".picoclaw", "tui.toml")
-}
-
-// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
-type TUIConfig struct {
- Version string `toml:"version"`
- Model Model `toml:"model"`
- Provider Provider `toml:"provider"`
-}
-
-type Model struct {
- Type string `toml:"type"` // "provider" (default) | "manual"
-}
-
-type Provider struct {
- Schemes []Scheme `toml:"schemes"`
- Users []User `toml:"users"`
- Current ProviderCurrent `toml:"current"`
-}
-
-type Scheme struct {
- Name string `toml:"name"` // unique key
- BaseURL string `toml:"baseURL"` // required
- Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
-}
-
-type User struct {
- Name string `toml:"name"`
- Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
- Type string `toml:"type"` // "key" (default) | "OAuth"
- Key string `toml:"key"`
-}
-
-type ProviderCurrent struct {
- Scheme string `toml:"scheme"` // references Scheme.Name
- User string `toml:"user"` // references User.Name where User.Scheme == Scheme
- Model string `toml:"model"` // from GET /models
-}
-
-// DefaultConfig returns a minimal valid TUIConfig.
-func DefaultConfig() *TUIConfig {
- return &TUIConfig{
- Version: "1.0",
- Model: Model{Type: "provider"},
- Provider: Provider{
- Schemes: []Scheme{},
- Users: []User{},
- Current: ProviderCurrent{},
- },
- }
-}
-
-// Load reads the TUI config from path. Returns a default config if the file does not exist.
-func Load(path string) (*TUIConfig, error) {
- data, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return DefaultConfig(), nil
- }
- if err != nil {
- return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
- }
-
- cfg := DefaultConfig()
- if _, err := toml.Decode(string(data), cfg); err != nil {
- return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
- }
-
- applyDefaults(cfg)
- return cfg, nil
-}
-
-// Save writes cfg to path atomically (safe for flash / SD storage).
-func Save(path string, cfg *TUIConfig) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
- var buf bytes.Buffer
- enc := toml.NewEncoder(&buf)
- if err := enc.Encode(cfg); err != nil {
- return fmt.Errorf("failed to encode config: %w", err)
- }
- if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
- return fmt.Errorf("failed to write config file %q: %w", path, err)
- }
- return nil
-}
-
-func applyDefaults(cfg *TUIConfig) {
- if cfg.Version == "" {
- cfg.Version = "1.0"
- }
- if cfg.Model.Type == "" {
- cfg.Model.Type = "provider"
- }
- for i := range cfg.Provider.Schemes {
- if cfg.Provider.Schemes[i].Type == "" {
- cfg.Provider.Schemes[i].Type = "openai-compatible"
- }
- }
- for i := range cfg.Provider.Users {
- if cfg.Provider.Users[i].Type == "" {
- cfg.Provider.Users[i].Type = "key"
- }
- }
-}
-
-// SchemeByName returns the first Scheme whose Name matches, or nil.
-func (p *Provider) SchemeByName(name string) *Scheme {
- for i := range p.Schemes {
- if p.Schemes[i].Name == name {
- return &p.Schemes[i]
- }
- }
- return nil
-}
-
-// UsersForScheme returns all users whose Scheme field matches schemeName.
-func (p *Provider) UsersForScheme(schemeName string) []User {
- var out []User
- for _, u := range p.Users {
- if u.Scheme == schemeName {
- out = append(out, u)
- }
- }
- return out
-}
-
-// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
-// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
-// Preserves all other existing fields in the config file unchanged.
-func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
- home, err := os.UserHomeDir()
- if err != nil {
- home = "."
- }
- mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
-
- var cfg map[string]any
- if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
- if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
- cfg = make(map[string]any)
- }
- } else {
- cfg = make(map[string]any)
- }
-
- if _, ok := cfg["agents"]; !ok {
- cfg["agents"] = make(map[string]any)
- }
- agents, ok := cfg["agents"].(map[string]any)
- if ok {
- if _, ok := agents["defaults"]; !ok {
- agents["defaults"] = make(map[string]any)
- }
- defaults, ok := agents["defaults"].(map[string]any)
- if ok {
- defaults["model"] = "tui-prefer"
- }
- }
-
- tuiModel := map[string]any{
- "model_name": "tui-prefer",
- "model": modelID,
- "api_key": user.Key,
- "api_base": scheme.BaseURL,
- }
-
- modelList := []any{}
- if ml, ok := cfg["model_list"].([]any); ok {
- modelList = ml
- }
-
- found := false
- for i, m := range modelList {
- if entry, ok := m.(map[string]any); ok {
- if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
- modelList[i] = tuiModel
- found = true
- break
- }
- }
- }
- if !found {
- modelList = append(modelList, tuiModel)
- }
- cfg["model_list"] = modelList
-
- data, err := json.MarshalIndent(cfg, "", " ")
- if err != nil {
- return err
- }
-
- if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
- return err
- }
-
- return os.WriteFile(mainConfigPath, data, 0o600)
-}
-
-func (cfg *TUIConfig) CurrentModelLabel() string {
- cur := cfg.Provider.Current
- if cur.Model == "" {
- return "(not configured)"
- }
- label := cur.Scheme
- if label != "" {
- label += " / "
- }
- return label + cur.Model
-}
diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go
deleted file mode 100644
index 3cb7110c1..000000000
--- a/cmd/picoclaw-launcher-tui/main.go
+++ /dev/null
@@ -1,48 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package main
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
-
- tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
- "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
-)
-
-func main() {
- configPath := tuicfg.DefaultConfigPath()
- if len(os.Args) > 1 {
- configPath = os.Args[1]
- }
-
- configDir := filepath.Dir(configPath)
- if _, err := os.Stat(configDir); os.IsNotExist(err) {
- cmd := exec.Command("picoclaw", "onboard")
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- _ = cmd.Run()
- }
-
- cfg, err := tuicfg.Load(configPath)
- if err != nil {
- fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
- os.Exit(1)
- }
-
- app := ui.New(cfg, configPath)
- // Bind model selection hook to sync to main config
- app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
- _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
- }
- if err := app.Run(); err != nil {
- fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
- os.Exit(1)
- }
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
deleted file mode 100644
index a65693b01..000000000
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ /dev/null
@@ -1,325 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "fmt"
- "sync"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
-)
-
-// App is the root TUI application.
-type App struct {
- tapp *tview.Application
- pages *tview.Pages
- pageStack []string
- cfg *tuicfg.TUIConfig
- configPath string
- pageRefreshFns map[string]func()
- headerModelTV *tview.TextView
- modalOpen map[string]bool
-
- // OnModelSelected is called when a model is selected in the UI.
- // Can be nil to disable.
- OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
-
- modelCache map[string][]modelEntry
- modelCacheMu sync.RWMutex
- refreshMu sync.Mutex
-}
-
-// cacheKey returns the map key for a (scheme, user) pair.
-func cacheKey(schemeName, userName string) string {
- return fmt.Sprintf("%s/%s", schemeName, userName)
-}
-
-// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
-func (a *App) cachedModels(schemeName, userName string) []modelEntry {
- a.modelCacheMu.RLock()
- defer a.modelCacheMu.RUnlock()
- entries := a.modelCache[cacheKey(schemeName, userName)]
- return append([]modelEntry(nil), entries...)
-}
-
-// refreshModelCache fetches models for every user in the config concurrently.
-// Serialized by refreshMu so concurrent calls don't race on the cache map.
-// When all fetches complete it calls onDone via QueueUpdateDraw.
-func (a *App) refreshModelCache(onDone func()) {
- go func() {
- a.refreshMu.Lock()
- defer a.refreshMu.Unlock()
-
- users := a.cfg.Provider.Users
- schemes := a.cfg.Provider.Schemes
-
- schemeURL := make(map[string]string, len(schemes))
- for _, s := range schemes {
- schemeURL[s.Name] = s.BaseURL
- }
-
- var wg sync.WaitGroup
- for _, u := range users {
- baseURL, ok := schemeURL[u.Scheme]
- if !ok || baseURL == "" {
- continue
- }
- if u.Key == "" {
- a.modelCacheMu.Lock()
- if a.modelCache == nil {
- a.modelCache = make(map[string][]modelEntry)
- }
- a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
- a.modelCacheMu.Unlock()
- continue
- }
- wg.Add(1)
- bURL := baseURL
- go func() {
- defer wg.Done()
- entries, err := fetchModels(bURL, u.Key)
- a.modelCacheMu.Lock()
- if a.modelCache == nil {
- a.modelCache = make(map[string][]modelEntry)
- }
- if err != nil || len(entries) == 0 {
- a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
- } else {
- a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
- }
- a.modelCacheMu.Unlock()
- }()
- }
- wg.Wait()
-
- if onDone != nil {
- a.tapp.QueueUpdateDraw(onDone)
- }
- }()
-}
-
-// New creates and wires up the TUI application.
-func New(cfg *tuicfg.TUIConfig, configPath string) *App {
- // Cyberpunk Theme Colors
- // Dark background
- tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
- tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
- tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
-
- // Borders and Titles
- tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
- tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
- tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
-
- // Text
- tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
- tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
- tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
- tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
- tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
-
- a := &App{
- tapp: tview.NewApplication(),
- pages: tview.NewPages(),
- pageStack: []string{},
- cfg: cfg,
- configPath: configPath,
- pageRefreshFns: make(map[string]func()),
- modalOpen: make(map[string]bool),
- }
-
- a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape {
- if len(a.modalOpen) > 0 {
- return event
- }
- return a.goBack()
- }
- return event
- })
-
- a.buildPages()
- return a
-}
-
-// Run starts the TUI event loop.
-func (a *App) Run() error {
- return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
-}
-
-func (a *App) buildPages() {
- a.pages.AddPage("home", a.newHomePage(), true, true)
- a.pageStack = []string{"home"}
-}
-
-func (a *App) navigateTo(name string, page tview.Primitive) {
- a.pages.RemovePage(name)
- a.pages.AddPage(name, page, true, false)
- a.pageStack = append(a.pageStack, name)
- a.pages.SwitchToPage(name)
-}
-
-func (a *App) goBack() *tcell.EventKey {
- if len(a.pageStack) <= 1 {
- return nil
- }
- popped := a.pageStack[len(a.pageStack)-1]
- a.pageStack = a.pageStack[:len(a.pageStack)-1]
- a.pages.RemovePage(popped)
- prev := a.pageStack[len(a.pageStack)-1]
- if fn, ok := a.pageRefreshFns[prev]; ok {
- fn()
- }
- if prev == "home" && a.headerModelTV != nil {
- a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
- }
- a.pages.SwitchToPage(prev)
- return nil
-}
-
-func (a *App) showModal(name string, primitive tview.Primitive) {
- a.modalOpen[name] = true
- a.pages.AddPage(name, primitive, true, true)
-}
-
-func (a *App) hideModal(name string) {
- delete(a.modalOpen, name)
- a.pages.HidePage(name)
- a.pages.RemovePage(name)
-}
-
-func (a *App) save() {
- if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
- a.showError("save failed: " + err.Error())
- }
-}
-
-func (a *App) showError(msg string) {
- modal := tview.NewModal().
- SetText(" [red::b]ERROR[-::-]\n\n" + msg).
- AddButtons([]string{"OK"}).
- SetDoneFunc(func(_ int, _ string) {
- a.hideModal("error")
- })
- // Cyberpunk Modal Style
- modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
- modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
- modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
- modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
- a.showModal("error", modal)
-}
-
-func (a *App) confirmDelete(label string, onConfirm func()) {
- modal := tview.NewModal().
- SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
- AddButtons([]string{"Delete", "Cancel"}).
- SetDoneFunc(func(_ int, buttonLabel string) {
- a.hideModal("confirm-delete")
- if buttonLabel == "Delete" {
- onConfirm()
- }
- })
- // Cyberpunk Modal Style
- modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
- modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
- modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
- modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
- a.showModal("confirm-delete", modal)
-}
-
-func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
- return tview.NewFlex().
- AddItem(tview.NewBox(), 0, 1, false).
- AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
- AddItem(tview.NewBox(), 0, 1, false).
- AddItem(form, height, 1, true).
- AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
- AddItem(tview.NewBox(), 0, 1, false)
-}
-
-func hintBar(text string) *tview.TextView {
- tv := tview.NewTextView().
- SetText(text).
- SetDynamicColors(true).
- SetTextAlign(tview.AlignCenter).
- SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
- tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
- return tv
-}
-
-func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
- var modelTV *tview.TextView
- if pageID == "home" {
- if a.headerModelTV == nil {
- a.headerModelTV = tview.NewTextView()
- a.headerModelTV.SetTextAlign(tview.AlignRight).
- SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
- SetDynamicColors(true).
- SetBackgroundColor(tcell.NewHexColor(0x050510))
- }
- modelTV = a.headerModelTV
- modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
- } else {
- modelTV = tview.NewTextView()
- modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
- }
-
- headerLeft := tview.NewTextView().
- SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
- SetDynamicColors(true).
- SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- header := tview.NewFlex().
- AddItem(headerLeft, 0, 1, false).
- AddItem(modelTV, 0, 1, false)
-
- sidebar := tview.NewTextView().
- SetDynamicColors(true).
- SetWrap(false)
- sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
-
- // Cyberpunk Sidebar Styling
- activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
- activeSuffix := "[-]"
- inactivePrefix := "[#808080] "
- inactiveSuffix := "[-]"
-
- sbText := "\n\n" // Top padding
-
- menuItem := func(id, label string) string {
- if pageID == id {
- return activePrefix + label + activeSuffix + "\n\n"
- }
- return inactivePrefix + label + inactiveSuffix + "\n\n"
- }
-
- sbText += menuItem("home", "HOME")
- sbText += menuItem("schemes", "SCHEMES")
- sbText += menuItem("users", "USERS")
- sbText += menuItem("models", "MODELS")
- sbText += menuItem("channels", "CHANNELS")
- sbText += menuItem("gateway", "GATEWAY")
-
- sidebar.SetText(sbText)
-
- footer := hintBar(hint)
-
- grid := tview.NewGrid().
- SetRows(1, 0, 1).
- SetColumns(20, 0). // Slightly wider sidebar
- AddItem(header, 0, 0, 1, 2, 0, 0, false).
- AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
- AddItem(content, 1, 1, 1, 1, 0, 0, true).
- AddItem(footer, 2, 0, 1, 2, 0, 0, false)
-
- // Add a border around the content area if possible, or ensure content has its own border
- // grid.SetBorders(false) // Grid borders usually look bad, handled by components
-
- return grid
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go
deleted file mode 100644
index c976f1fcd..000000000
--- a/cmd/picoclaw-launcher-tui/ui/channels.go
+++ /dev/null
@@ -1,202 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "reflect"
- "strconv"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-)
-
-func (a *App) newChannelsPage() tview.Primitive {
- list := tview.NewList()
- list.SetBorder(true).
- SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
- SetTitleColor(tcell.NewHexColor(0x00f0ff)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
- list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
- list.SetSelectedStyle(
- tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
- )
- list.SetHighlightFullLine(true)
- list.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- rebuild := func() {
- sel := list.GetCurrentItem()
- list.Clear()
-
- home, err := os.UserHomeDir()
- if err != nil {
- home = "."
- }
- configPath := filepath.Join(home, ".picoclaw", "config.json")
-
- var cfg map[string]any
- if data, err := os.ReadFile(configPath); err == nil {
- _ = json.Unmarshal(data, &cfg)
- }
-
- if chRaw, ok := cfg["channels"].(map[string]any); ok {
- for name, ch := range chRaw {
- chMap, ok := ch.(map[string]any)
- enabled := "disabled"
- if ok {
- if e, ok := chMap["enabled"].(bool); ok && e {
- enabled = "enabled"
- }
- }
- list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
- a.showChannelEditForm(configPath, name, chMap)
- })
- }
- }
-
- if sel >= 0 && sel < list.GetItemCount() {
- list.SetCurrentItem(sel)
- }
- }
- rebuild()
-
- a.pageRefreshFns["channels"] = rebuild
-
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape {
- return a.goBack()
- }
- return event
- })
-
- return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
-}
-
-func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
- form := tview.NewForm()
- form.SetBorder(true).
- SetTitle(" [::b]EDIT CHANNEL ").
- SetTitleColor(tcell.NewHexColor(0x39ff14)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
- form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
- form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
- form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
- form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
- form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
-
- fields := make(map[string]*tview.InputField)
- var nameField *tview.InputField
-
- if channelName == "" {
- nameField = tview.NewInputField().
- SetLabel("Channel Name").
- SetText("").
- SetFieldWidth(28)
- form.AddFormItem(nameField)
- }
-
- for k, v := range existing {
- if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
- continue
- }
- valStr := fmt.Sprintf("%v", v)
- field := tview.NewInputField().
- SetLabel(k).
- SetText(valStr).
- SetFieldWidth(28)
- form.AddFormItem(field)
- fields[k] = field
- }
-
- form.AddButton("SAVE", func() {
- var cfg map[string]any
- if data, err := os.ReadFile(configPath); err == nil {
- if err := json.Unmarshal(data, &cfg); err != nil {
- cfg = make(map[string]any)
- }
- } else {
- cfg = make(map[string]any)
- }
-
- if _, ok := cfg["channels"]; !ok {
- cfg["channels"] = make(map[string]any)
- }
- channels, ok := cfg["channels"].(map[string]any)
- if !ok {
- channels = make(map[string]any)
- cfg["channels"] = channels
- }
-
- finalName := channelName
- if channelName == "" {
- if nameField == nil || nameField.GetText() == "" {
- a.showError("Channel name is required")
- return
- }
- finalName = nameField.GetText()
- }
-
- updated := make(map[string]any)
- if existing != nil {
- for k, v := range existing {
- updated[k] = v
- }
- }
- for k, field := range fields {
- val := field.GetText()
- if val == "true" {
- updated[k] = true
- } else if val == "false" {
- updated[k] = false
- } else if num, err := strconv.Atoi(val); err == nil {
- updated[k] = num
- } else {
- updated[k] = val
- }
- }
-
- if channelName != "" && finalName != channelName {
- delete(channels, channelName)
- }
- channels[finalName] = updated
-
- data, err := json.MarshalIndent(cfg, "", " ")
- if err != nil {
- a.showError(fmt.Sprintf("Failed to save config: %v", err))
- return
- }
- if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
- a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
- return
- }
- if err := os.WriteFile(configPath, data, 0o600); err != nil {
- a.showError(fmt.Sprintf("Failed to write config: %v", err))
- return
- }
-
- a.hideModal("channel-edit")
- a.goBack()
- })
-
- form.AddButton("CANCEL", func() {
- a.hideModal("channel-edit")
- })
-
- form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape {
- a.hideModal("channel-edit")
- return nil
- }
- return event
- })
-
- a.showModal("channel-edit", centeredForm(form, 4, 20))
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go
deleted file mode 100644
index 781204bf2..000000000
--- a/cmd/picoclaw-launcher-tui/ui/gateway.go
+++ /dev/null
@@ -1,229 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "fmt"
- "os/exec"
- "runtime"
- "strconv"
- "strings"
- "time"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- "github.com/sipeed/picoclaw/pkg/config"
- ppid "github.com/sipeed/picoclaw/pkg/pid"
-)
-
-type gatewayStatus struct {
- running bool
- pid int
- version string
-}
-
-func picoHome() string {
- return config.GetHome()
-}
-
-func getGatewayStatus() gatewayStatus {
- data := ppid.ReadPidFileWithCheck(picoHome())
- if data == nil {
- return gatewayStatus{running: false}
- }
- return gatewayStatus{
- running: true,
- pid: data.PID,
- version: data.Version,
- }
-}
-
-func startGateway() error {
- status := getGatewayStatus()
- if status.running {
- return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
- }
-
- var cmd *exec.Cmd
-
- if runtime.GOOS == "windows" {
- cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
- } else {
- cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
- }
-
- err := cmd.Start()
- if err != nil {
- return err
- }
-
- time.Sleep(1 * time.Second)
-
- if runtime.GOOS == "windows" {
- cmd := exec.Command(
- "wmic",
- "process",
- "where",
- "name='picoclaw.exe' and commandline like '%gateway%'",
- "get",
- "processid",
- )
- output, err := cmd.Output()
- if err != nil {
- return fmt.Errorf("failed to get gateway PID: %w", err)
- }
- lines := strings.Split(string(output), "\n")
- for _, line := range lines[1:] {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- _, err := strconv.Atoi(line)
- if err == nil {
- break
- }
- }
- }
-
- status = getGatewayStatus()
- if !status.running {
- return fmt.Errorf("failed to start gateway")
- }
- return nil
-}
-
-func stopGateway() error {
- status := getGatewayStatus()
- if !status.running {
- return fmt.Errorf("gateway is not running")
- }
-
- var err error
- if runtime.GOOS == "windows" {
- err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
- } else {
- err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
- }
- if err != nil {
- return err
- }
-
- // Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
- for i := 0; i < 5; i++ {
- if !getGatewayStatus().running {
- break
- }
- time.Sleep(200 * time.Millisecond)
- }
-
- return nil
-}
-
-func (a *App) newGatewayPage() tview.Primitive {
- flex := tview.NewFlex().SetDirection(tview.FlexRow)
- flex.SetBorder(true).
- SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
- SetTitleColor(tcell.NewHexColor(0x00f0ff)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- statusTV := tview.NewTextView().
- SetDynamicColors(true).
- SetTextAlign(tview.AlignCenter).
- SetText("Checking status...")
- statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- var updateStatus func()
-
- // 使用List作为按钮,保证显示和交互正常
- buttons := tview.NewList()
- buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
- buttons.SetMainTextColor(tcell.ColorWhite)
- buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
- buttons.SetSelectedTextColor(tcell.ColorBlack)
-
- buttons.AddItem(" [lime]START[white] ", "", 0, func() {
- if !getGatewayStatus().running {
- err := startGateway()
- if err != nil {
- a.showError(err.Error())
- }
- updateStatus()
- }
- })
- buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
- if getGatewayStatus().running {
- err := stopGateway()
- if err != nil {
- a.showError(err.Error())
- }
- updateStatus()
- }
- })
-
- buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
- buttonFlex.
- AddItem(tview.NewBox(), 0, 1, false).
- AddItem(buttons, 20, 1, true).
- AddItem(tview.NewBox(), 0, 1, false)
-
- flex.
- AddItem(tview.NewBox(), 0, 1, false).
- AddItem(statusTV, 3, 1, false).
- AddItem(tview.NewBox(), 0, 1, false).
- AddItem(buttonFlex, 4, 1, true).
- AddItem(tview.NewBox(), 0, 1, false)
-
- updateStatus = func() {
- status := getGatewayStatus()
- if status.running {
- versionInfo := ""
- if status.version != "" {
- versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
- }
- statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
- buttons.SetItemText(0, " [gray]START[white] ", "")
- buttons.SetItemText(1, " [red]STOP[white] ", "")
- } else {
- statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
- buttons.SetItemText(0, " [lime]START[white] ", "")
- buttons.SetItemText(1, " [gray]STOP[white] ", "")
- }
- }
-
- updateStatus()
-
- done := make(chan struct{})
- go func() {
- ticker := time.NewTicker(2 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- a.tapp.QueueUpdateDraw(updateStatus)
- case <-done:
- return
- }
- }
- }()
-
- originalInputCapture := flex.GetInputCapture()
- flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape {
- close(done)
- return a.goBack()
- }
- if originalInputCapture != nil {
- return originalInputCapture(event)
- }
- return event
- })
-
- a.pageRefreshFns["gateway"] = updateStatus
-
- return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
deleted file mode 100644
index 74a7769cf..000000000
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "os"
- "os/exec"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-)
-
-func (a *App) newHomePage() tview.Primitive {
- list := tview.NewList()
- list.SetBorder(true).
- SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
- SetTitleColor(tcell.NewHexColor(0x00f0ff)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
- list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
- list.SetSelectedStyle(
- tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
- )
- list.SetHighlightFullLine(true)
- list.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- rebuildList := func() {
- sel := list.GetCurrentItem()
- list.Clear()
- list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
- a.navigateTo("schemes", a.newSchemesPage())
- })
- list.AddItem(
- "CHANNELS: Configure communication channels",
- "Manage Telegram/Discord/WeChat channels",
- 'n',
- func() {
- a.navigateTo("channels", a.newChannelsPage())
- },
- )
- list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
- a.navigateTo("gateway", a.newGatewayPage())
- })
- list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
- a.tapp.Suspend(func() {
- cmd := exec.Command("picoclaw", "agent")
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- _ = cmd.Run()
- })
- })
- list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
- if sel >= 0 && sel < list.GetItemCount() {
- list.SetCurrentItem(sel)
- }
- }
- rebuildList()
-
- a.pageRefreshFns["home"] = rebuildList
-
- return a.buildShell(
- "home",
- list,
- " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
- )
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go
deleted file mode 100644
index 20e5f0182..000000000
--- a/cmd/picoclaw-launcher-tui/ui/models.go
+++ /dev/null
@@ -1,200 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
-)
-
-type modelsAPIResponse struct {
- Data []modelEntry `json:"data"`
-}
-
-type modelEntry struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
-}
-
-func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
- table := tview.NewTable().
- SetBorders(false).
- SetSelectable(true, false).
- SetFixed(0, 0)
- table.SetBorder(true).
- SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
- SetTitleColor(tcell.NewHexColor(0x00f0ff)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- table.SetSelectedStyle(
- tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
- )
- table.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- var modelIDs []string
-
- status := tview.NewTextView().
- SetTextAlign(tview.AlignCenter).
- SetDynamicColors(true).
- SetText("[#ffff00]FETCHING MODELS...[-]")
- status.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- flex := tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(status, 1, 0, false).
- AddItem(table, 0, 1, false)
-
- apiKey := a.resolveKey(schemeName, userName)
-
- go func() {
- var entries []modelEntry
- var err error
- if apiKey == "" {
- err = fmt.Errorf("key is required")
- } else {
- entries, err = fetchModels(baseURL, apiKey)
- }
-
- a.modelCacheMu.Lock()
- if a.modelCache == nil {
- a.modelCache = make(map[string][]modelEntry)
- }
- if err == nil && len(entries) > 0 {
- a.modelCache[cacheKey(schemeName, userName)] = entries
- } else {
- a.modelCache[cacheKey(schemeName, userName)] = nil
- }
- a.modelCacheMu.Unlock()
-
- a.tapp.QueueUpdateDraw(func() {
- if err != nil {
- status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
- table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
- a.tapp.SetFocus(table)
- return
- }
- if len(entries) == 0 {
- status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
- table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
- a.tapp.SetFocus(table)
- return
- }
-
- status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
- for i, m := range entries {
- modelIDs = append(modelIDs, m.ID)
- table.SetCell(i, 0,
- tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
- SetAlign(tview.AlignRight).
- SetTextColor(tcell.NewHexColor(0x808080)).
- SetSelectable(false),
- )
- table.SetCell(i, 1,
- tview.NewTableCell(" "+m.ID).
- SetAlign(tview.AlignLeft).
- SetExpansion(1).
- SetTextColor(tcell.NewHexColor(0xe0e0e0)),
- )
- }
- a.tapp.SetFocus(table)
- })
- }()
-
- table.SetSelectedFunc(func(row, _ int) {
- if row < 0 || row >= len(modelIDs) {
- return
- }
- a.cfg.Provider.Current = tuicfg.ProviderCurrent{
- Scheme: schemeName,
- User: userName,
- Model: modelIDs[row],
- }
- a.save()
-
- // Trigger model selected callback if set
- if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
- scheme := a.cfg.Provider.SchemeByName(schemeName)
- if scheme == nil {
- a.goBack()
- return
- }
- var user tuicfg.User
- for _, u := range a.cfg.Provider.Users {
- if u.Scheme == schemeName && u.Name == userName {
- user = u
- break
- }
- }
- a.OnModelSelected(*scheme, user, modelIDs[row])
- }
-
- a.goBack()
- })
-
- return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
-}
-
-func (a *App) resolveKey(schemeName, userName string) string {
- for _, u := range a.cfg.Provider.Users {
- if u.Scheme == schemeName && u.Name == userName {
- return u.Key
- }
- }
- return ""
-}
-
-func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
- url := strings.TrimRight(baseURL, "/") + "/models"
-
- client := &http.Client{Timeout: 15 * time.Second}
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, fmt.Errorf("build request: %w", err)
- }
- if apiKey != "" {
- req.Header.Set("Authorization", "Bearer "+apiKey)
- }
-
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("request failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
- return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("read response: %w", err)
- }
-
- var result modelsAPIResponse
- if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
- return result.Data, nil
- }
-
- var arr []modelEntry
- if err := json.Unmarshal(body, &arr); err == nil {
- return arr, nil
- }
-
- return nil, fmt.Errorf(
- "decode response: unrecognized shape: %s",
- strings.TrimSpace(string(body[:min(len(body), 256)])),
- )
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go
deleted file mode 100644
index e38d7fa86..000000000
--- a/cmd/picoclaw-launcher-tui/ui/schemes.go
+++ /dev/null
@@ -1,252 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "fmt"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
-)
-
-func (a *App) newSchemesPage() tview.Primitive {
- table := tview.NewTable().
- SetBorders(false).
- SetSelectable(true, false)
- table.SetBorder(true).
- SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
- SetTitleColor(tcell.NewHexColor(0x00f0ff)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- table.SetSelectedStyle(
- tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
- )
- table.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- rowToIdx := func(row int) int { return row / 2 }
-
- selectedSchemeName := func() string {
- row, _ := table.GetSelection()
- idx := rowToIdx(row)
- schemes := a.cfg.Provider.Schemes
- if idx >= 0 && idx < len(schemes) {
- return schemes[idx].Name
- }
- return ""
- }
-
- rebuild := func() {
- selName := selectedSchemeName()
- table.Clear()
- schemes := a.cfg.Provider.Schemes
- for i, s := range schemes {
- nameRow := i * 2
- detailRow := nameRow + 1
-
- table.SetCell(nameRow, 0,
- tview.NewTableCell(" "+s.Name).
- SetTextColor(tcell.NewHexColor(0xe0e0e0)).
- SetExpansion(1).
- SetSelectable(true),
- )
-
- users := a.cfg.Provider.UsersForScheme(s.Name)
- n := len(users)
- m := 0
- for _, u := range users {
- if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
- m++
- }
- }
- table.SetCell(detailRow, 0,
- tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
- SetTextColor(tcell.NewHexColor(0x808080)).
- SetExpansion(1).
- SetSelectable(false),
- )
- table.SetCell(detailRow, 1,
- tview.NewTableCell("[#00f0ff]"+s.Type+" ").
- SetAlign(tview.AlignRight).
- SetSelectable(false),
- )
- }
- if selName != "" {
- for i, s := range schemes {
- if s.Name == selName {
- table.Select(i*2, 0)
- return
- }
- }
- }
- if table.GetRowCount() > 0 {
- table.Select(0, 0)
- }
- }
- rebuild()
-
- a.refreshModelCache(rebuild)
- a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
-
- table.SetSelectedFunc(func(row, _ int) {
- idx := rowToIdx(row)
- schemes := a.cfg.Provider.Schemes
- if idx < 0 || idx >= len(schemes) {
- return
- }
- name := schemes[idx].Name
- a.navigateTo("users", a.newUsersPage(name))
- })
-
- table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- row, _ := table.GetSelection()
- idx := rowToIdx(row)
- schemes := a.cfg.Provider.Schemes
- switch event.Rune() {
- case 'a':
- a.showSchemeForm(nil, func(s tuicfg.Scheme) {
- a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
- a.save()
- a.refreshModelCache(rebuild)
- })
- return nil
- case 'e':
- if idx < 0 || idx >= len(schemes) {
- return nil
- }
- origName := schemes[idx].Name
- orig := schemes[idx]
- a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
- current := a.cfg.Provider.Schemes
- for i, sc := range current {
- if sc.Name == origName {
- a.cfg.Provider.Schemes[i] = s
- break
- }
- }
- a.save()
- a.refreshModelCache(func() {
- rebuild()
- for i, sc := range a.cfg.Provider.Schemes {
- if sc.Name == s.Name {
- table.Select(i*2, 0)
- break
- }
- }
- })
- })
- return nil
- case 'd':
- if idx < 0 || idx >= len(schemes) {
- return nil
- }
- name := schemes[idx].Name
- a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
- current := a.cfg.Provider.Schemes
- newSchemes := make([]tuicfg.Scheme, 0, len(current))
- for _, sc := range current {
- if sc.Name != name {
- newSchemes = append(newSchemes, sc)
- }
- }
- a.cfg.Provider.Schemes = newSchemes
-
- existing := a.cfg.Provider.Users
- filtered := make([]tuicfg.User, 0, len(existing))
- for _, u := range existing {
- if u.Scheme != name {
- filtered = append(filtered, u)
- }
- }
- a.cfg.Provider.Users = filtered
-
- a.save()
- a.refreshModelCache(rebuild)
- })
- return nil
- }
- return event
- })
-
- return a.buildShell(
- "schemes",
- table,
- " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
- )
-}
-
-func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
- name := ""
- baseURL := ""
- schemeType := "openai-compatible"
- title := " ADD SCHEME "
-
- if existing != nil {
- name = existing.Name
- baseURL = existing.BaseURL
- schemeType = existing.Type
- title = " EDIT SCHEME "
- }
-
- typeOptions := []string{"openai-compatible", "anthropic"}
- typeIdx := 0
- for i, t := range typeOptions {
- if t == schemeType {
- typeIdx = i
- break
- }
- }
-
- form := tview.NewForm()
-
- form.
- AddInputField("Name", name, 20, nil, func(text string) { name = text }).
- AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
- AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
- AddButton("SAVE", func() {
- if name == "" {
- a.showError("Name is required")
- return
- }
- if baseURL == "" {
- a.showError("Base URL is required")
- return
- }
- if existing == nil {
- for _, s := range a.cfg.Provider.Schemes {
- if s.Name == name {
- a.showError(fmt.Sprintf("Scheme name %q already exists", name))
- return
- }
- }
- }
- a.hideModal("scheme-form")
- onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
- }).
- AddButton("CANCEL", func() {
- a.hideModal("scheme-form")
- })
-
- form.SetBorder(true).
- SetTitle(" [::b]" + title + " ").
- SetTitleColor(tcell.NewHexColor(0x39ff14)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
- form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
- form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
- form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
- form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
- form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
- form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape {
- a.hideModal("scheme-form")
- return nil
- }
- return event
- })
-
- a.showModal("scheme-form", centeredForm(form, 4, 12))
-}
diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go
deleted file mode 100644
index b00fc8982..000000000
--- a/cmd/picoclaw-launcher-tui/ui/users.go
+++ /dev/null
@@ -1,261 +0,0 @@
-// PicoClaw - Ultra-lightweight personal AI agent
-// License: MIT
-//
-// Copyright (c) 2026 PicoClaw contributors
-
-package ui
-
-import (
- "fmt"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
-)
-
-func (a *App) newUsersPage(schemeName string) tview.Primitive {
- table := tview.NewTable().
- SetBorders(false).
- SetSelectable(true, false)
- table.SetBorder(true).
- SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
- SetTitleColor(tcell.NewHexColor(0x00f0ff)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- table.SetSelectedStyle(
- tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
- )
- table.SetBackgroundColor(tcell.NewHexColor(0x050510))
-
- visibleUsers := func() []tuicfg.User {
- var out []tuicfg.User
- for _, u := range a.cfg.Provider.Users {
- if u.Scheme == schemeName {
- out = append(out, u)
- }
- }
- return out
- }
-
- findUserGlobalIdx := func(userName string) int {
- for i, u := range a.cfg.Provider.Users {
- if u.Scheme == schemeName && u.Name == userName {
- return i
- }
- }
- return -1
- }
-
- rowToVisIdx := func(row int) int { return row / 2 }
-
- selectedUserName := func() string {
- row, _ := table.GetSelection()
- users := visibleUsers()
- visIdx := rowToVisIdx(row)
- if visIdx >= 0 && visIdx < len(users) {
- return users[visIdx].Name
- }
- return ""
- }
-
- rebuild := func() {
- selName := selectedUserName()
- table.Clear()
- users := visibleUsers()
- for i, u := range users {
- nameRow := i * 2
- detailRow := nameRow + 1
-
- table.SetCell(nameRow, 0,
- tview.NewTableCell(" "+u.Name).
- SetTextColor(tcell.NewHexColor(0xe0e0e0)).
- SetExpansion(1).
- SetSelectable(true),
- )
- table.SetCell(nameRow, 1,
- tview.NewTableCell("").
- SetSelectable(false),
- )
-
- models := a.cachedModels(schemeName, u.Name)
- var detailText string
- if len(models) > 0 {
- detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
- } else {
- detailText = " [#ff2a2a]Inactive / No Access[-]"
- }
- table.SetCell(detailRow, 0,
- tview.NewTableCell(detailText).
- SetTextColor(tcell.NewHexColor(0x808080)).
- SetExpansion(1).
- SetSelectable(false),
- )
- table.SetCell(detailRow, 1,
- tview.NewTableCell("[#00f0ff]"+u.Type+" ").
- SetAlign(tview.AlignRight).
- SetSelectable(false),
- )
- }
- if selName != "" {
- for i, u := range users {
- if u.Name == selName {
- table.Select(i*2, 0)
- return
- }
- }
- }
- if table.GetRowCount() > 0 {
- table.Select(0, 0)
- }
- }
- rebuild()
-
- a.refreshModelCache(rebuild)
- a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
-
- table.SetSelectedFunc(func(row, _ int) {
- visIdx := rowToVisIdx(row)
- users := visibleUsers()
- if visIdx < 0 || visIdx >= len(users) {
- return
- }
- uName := users[visIdx].Name
- scheme := a.cfg.Provider.SchemeByName(schemeName)
- if scheme == nil {
- a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
- return
- }
- a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
- })
-
- table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- row, _ := table.GetSelection()
- visIdx := rowToVisIdx(row)
- users := visibleUsers()
- switch event.Rune() {
- case 'a':
- a.showUserForm(schemeName, nil, func(u tuicfg.User) {
- a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
- a.save()
- a.refreshModelCache(rebuild)
- })
- return nil
- case 'e':
- if visIdx < 0 || visIdx >= len(users) {
- return nil
- }
- origName := users[visIdx].Name
- orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
- a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
- cfgIdx := findUserGlobalIdx(origName)
- if cfgIdx < 0 {
- a.showError(fmt.Sprintf("User %q no longer exists", origName))
- return
- }
- a.cfg.Provider.Users[cfgIdx] = u
- a.save()
- a.refreshModelCache(func() {
- rebuild()
- for i, usr := range visibleUsers() {
- if usr.Name == u.Name {
- table.Select(i*2, 0)
- break
- }
- }
- })
- })
- return nil
- case 'd':
- if visIdx < 0 || visIdx >= len(users) {
- return nil
- }
- uName := users[visIdx].Name
- a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
- cfgIdx := findUserGlobalIdx(uName)
- if cfgIdx < 0 {
- return
- }
- all := a.cfg.Provider.Users
- a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
- a.save()
- a.refreshModelCache(rebuild)
- })
- return nil
- }
- return event
- })
-
- return a.buildShell(
- "users",
- table,
- " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
- )
-}
-
-func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
- name := ""
- userType := "key"
- key := ""
- title := " ADD USER "
-
- if existing != nil {
- name = existing.Name
- userType = existing.Type
- key = existing.Key
- title = " EDIT USER "
- }
-
- typeOptions := []string{"key", "OAuth"}
- typeIdx := 0
- for i, t := range typeOptions {
- if t == userType {
- typeIdx = i
- break
- }
- }
-
- form := tview.NewForm()
- form.
- AddInputField("Name", name, 20, nil, func(text string) { name = text }).
- AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
- AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
- AddButton("SAVE", func() {
- if name == "" {
- a.showError("Name is required")
- return
- }
- if existing == nil {
- for _, u := range a.cfg.Provider.Users {
- if u.Scheme == schemeName && u.Name == name {
- a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
- return
- }
- }
- }
- a.hideModal("user-form")
- onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
- }).
- AddButton("CANCEL", func() {
- a.hideModal("user-form")
- })
-
- form.SetBorder(true).
- SetTitle(" [::b]" + title + " ").
- SetTitleColor(tcell.NewHexColor(0x39ff14)).
- SetBorderColor(tcell.NewHexColor(0x00f0ff))
- form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
- form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
- form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
- form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
- form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
- form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
- form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEscape {
- a.hideModal("user-form")
- return nil
- }
- return event
- })
-
- a.showModal("user-form", centeredForm(form, 4, 13))
-}
diff --git a/cmd/picoclaw/internal/model/add.go b/cmd/picoclaw/internal/model/add.go
new file mode 100644
index 000000000..b3ebba340
--- /dev/null
+++ b/cmd/picoclaw/internal/model/add.go
@@ -0,0 +1,200 @@
+package model
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+const defaultAliasName = "custom-prefer"
+
+func newAddCommand() *cobra.Command {
+ var (
+ apiBase string
+ apiKey string
+ modelID string
+ alias string
+ modelType string
+ )
+
+ cmd := &cobra.Command{
+ Use: "add",
+ Short: "Add a model from an OpenAI-compatible endpoint",
+ Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing
+GET /models, then setting it as the default model.
+
+If --model is omitted, the available models are listed and you can pick one
+interactively. If --model is provided, the entry is written without contacting
+the server.
+
+Sample interactive session (key shown masked):
+
+ $ picoclaw model add \
+ -b https://ark.cn-beijing.volces.com/api/v3 \
+ -k 7dff****-****-****-****-********e829
+
+ 115 model(s) available:
+ 1) doubao-lite-128k-240428 (doubao-lite-128k)
+ 2) doubao-pro-128k-240515 (doubao-pro-128k)
+ ...
+ 48) deepseek-r1-250120 (deepseek-r1)
+ 78) kimi-k2-250711 (kimi-k2)
+ ...
+ 115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0)
+ Pick a model (number or id): 48
+ ✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`,
+ Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-...
+ picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ return runAdd(addOptions{
+ apiBase: strings.TrimSpace(apiBase),
+ apiKey: strings.TrimSpace(apiKey),
+ modelID: strings.TrimSpace(modelID),
+ alias: strings.TrimSpace(alias),
+ modelType: strings.TrimSpace(modelType),
+ stdin: cmd.InOrStdin(),
+ stdout: cmd.OutOrStdout(),
+ })
+ },
+ }
+
+ cmd.Flags().StringVarP(&apiBase, "api-base", "b", "",
+ "API base URL (required), e.g. https://api.openai.com/v1")
+ cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)")
+ cmd.Flags().StringVarP(&modelID, "model", "m", "",
+ "Model id; when set, skips the interactive picker and the network call")
+ cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName,
+ "Local alias written to model_list and used as the default model name")
+ cmd.Flags().StringVar(&modelType, "type", "openai-compatible",
+ "Endpoint type (only 'openai-compatible' is supported today)")
+ _ = cmd.MarkFlagRequired("api-base")
+ _ = cmd.MarkFlagRequired("api-key")
+
+ return cmd
+}
+
+type addOptions struct {
+ apiBase string
+ apiKey string
+ modelID string
+ alias string
+ modelType string
+ stdin io.Reader
+ stdout io.Writer
+}
+
+func runAdd(opt addOptions) error {
+ if opt.modelType != "" && opt.modelType != "openai-compatible" {
+ return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType)
+ }
+ if opt.alias == "" {
+ opt.alias = defaultAliasName
+ }
+
+ selected := opt.modelID
+ if selected == "" {
+ entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey)
+ if err != nil {
+ return fmt.Errorf("fetch models: %w", err)
+ }
+ if len(entries) == 0 {
+ return fmt.Errorf("no models returned by %s", opt.apiBase)
+ }
+ selected, err = pickModel(opt.stdin, opt.stdout, entries)
+ if err != nil {
+ return err
+ }
+ }
+
+ return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout)
+}
+
+func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) {
+ fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries))
+ for i, m := range entries {
+ line := m.ID
+ if m.Name != "" && m.Name != m.ID {
+ line = fmt.Sprintf("%s (%s)", m.ID, m.Name)
+ }
+ fmt.Fprintf(stdout, " %3d) %s\n", i+1, line)
+ }
+
+ scanner := bufio.NewScanner(stdin)
+ for {
+ fmt.Fprint(stdout, "Pick a model (number or id): ")
+ if !scanner.Scan() {
+ if err := scanner.Err(); err != nil {
+ return "", fmt.Errorf("read input: %w", err)
+ }
+ return "", fmt.Errorf("no selection provided")
+ }
+ text := strings.TrimSpace(scanner.Text())
+ if text == "" {
+ continue
+ }
+ if idx, err := strconv.Atoi(text); err == nil {
+ if idx < 1 || idx > len(entries) {
+ fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries))
+ continue
+ }
+ return entries[idx-1].ID, nil
+ }
+ for _, m := range entries {
+ if m.ID == text {
+ return m.ID, nil
+ }
+ }
+ fmt.Fprintln(stdout, "Not a valid number or model id; try again.")
+ }
+}
+
+func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error {
+ configPath := internal.GetConfigPath()
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
+
+ secureKeys := config.SimpleSecureStrings(apiKey)
+
+ found := false
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if m.ModelName == alias {
+ m.Model = modelID
+ m.APIBase = apiBase
+ m.APIKeys = secureKeys
+ m.Enabled = true
+ found = true
+ break
+ }
+ }
+ if !found {
+ cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
+ ModelName: alias,
+ Model: modelID,
+ APIBase: apiBase,
+ APIKeys: secureKeys,
+ Enabled: true,
+ })
+ }
+
+ cfg.Agents.Defaults.ModelName = alias
+
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+
+ fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID)
+ return nil
+}
diff --git a/cmd/picoclaw/internal/model/add_test.go b/cmd/picoclaw/internal/model/add_test.go
new file mode 100644
index 000000000..5da4d5e7f
--- /dev/null
+++ b/cmd/picoclaw/internal/model/add_test.go
@@ -0,0 +1,257 @@
+package model
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/models", r.URL.Path)
+ assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
+ }))
+ defer srv.Close()
+
+ entries, err := fetchOpenAIModels(srv.URL, "secret")
+ require.NoError(t, err)
+ require.Len(t, entries, 2)
+ assert.Equal(t, "gpt-foo", entries[0].ID)
+ assert.Equal(t, "Foo", entries[0].Name)
+ assert.Equal(t, "gpt-bar", entries[1].ID)
+}
+
+func TestFetchOpenAIModels_BareArray(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
+ }))
+ defer srv.Close()
+
+ entries, err := fetchOpenAIModels(srv.URL, "secret")
+ require.NoError(t, err)
+ require.Len(t, entries, 2)
+ assert.Equal(t, "a", entries[0].ID)
+ assert.Equal(t, "b", entries[1].ID)
+}
+
+func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ _, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
+ }))
+ defer srv.Close()
+
+ _, err := fetchOpenAIModels(srv.URL+"/", "k")
+ require.NoError(t, err)
+ assert.Equal(t, "/models", gotPath)
+}
+
+func TestFetchOpenAIModels_HTTPError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "nope", http.StatusUnauthorized)
+ }))
+ defer srv.Close()
+
+ _, err := fetchOpenAIModels(srv.URL, "bad")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "HTTP 401")
+}
+
+func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"data":[]}`))
+ }))
+ defer srv.Close()
+
+ entries, err := fetchOpenAIModels(srv.URL, "k")
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`[]`))
+ }))
+ defer srv.Close()
+
+ entries, err := fetchOpenAIModels(srv.URL, "k")
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"models":"not-supported"}`))
+ }))
+ defer srv.Close()
+
+ _, err := fetchOpenAIModels(srv.URL, "k")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unrecognized shape")
+}
+
+func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
+ _, err := fetchOpenAIModels("", "k")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "api base")
+
+ _, err = fetchOpenAIModels("https://example.com", "")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "api key")
+}
+
+func TestPickModel_ByIndex(t *testing.T) {
+ entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
+ out := &bytes.Buffer{}
+ got, err := pickModel(strings.NewReader("2\n"), out, entries)
+ require.NoError(t, err)
+ assert.Equal(t, "b", got)
+ assert.Contains(t, out.String(), "3 model(s) available")
+}
+
+func TestPickModel_ByID(t *testing.T) {
+ entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
+ out := &bytes.Buffer{}
+ got, err := pickModel(strings.NewReader("beta\n"), out, entries)
+ require.NoError(t, err)
+ assert.Equal(t, "beta", got)
+}
+
+func TestPickModel_RetriesOnInvalid(t *testing.T) {
+ entries := []modelEntry{{ID: "x"}}
+ out := &bytes.Buffer{}
+ got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
+ require.NoError(t, err)
+ assert.Equal(t, "x", got)
+ rendered := out.String()
+ assert.Contains(t, rendered, "Out of range")
+ assert.Contains(t, rendered, "Not a valid number")
+}
+
+func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
+ initTest(t)
+
+ out := &bytes.Buffer{}
+ err := runAdd(addOptions{
+ apiBase: "https://invalid.invalid/v1",
+ apiKey: "k",
+ modelID: "explicit-model",
+ alias: "myalias",
+ modelType: "openai-compatible",
+ stdout: out,
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
+
+ cfg, err := config.LoadConfig(configPath)
+ require.NoError(t, err)
+ assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
+ added := findModelByName(cfg, "myalias")
+ require.NotNil(t, added, "expected model 'myalias' in model_list")
+ assert.Equal(t, "explicit-model", added.Model)
+ assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
+ assert.True(t, added.Enabled)
+ require.Len(t, added.APIKeys, 1)
+ assert.Equal(t, "k", added.APIKeys[0].String())
+}
+
+func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
+ for _, m := range cfg.ModelList {
+ if m != nil && m.ModelName == name {
+ return m
+ }
+ }
+ return nil
+}
+
+func TestRunAdd_FetchAndPick(t *testing.T) {
+ initTest(t)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
+ }))
+ defer srv.Close()
+
+ out := &bytes.Buffer{}
+ err := runAdd(addOptions{
+ apiBase: srv.URL,
+ apiKey: "my-key",
+ alias: defaultAliasName,
+ modelType: "openai-compatible",
+ stdin: strings.NewReader("2\n"),
+ stdout: out,
+ })
+ require.NoError(t, err)
+
+ cfg, err := config.LoadConfig(configPath)
+ require.NoError(t, err)
+ assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
+ added := findModelByName(cfg, defaultAliasName)
+ require.NotNil(t, added)
+ assert.Equal(t, "m2", added.Model)
+}
+
+func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
+ initTest(t)
+
+ first := &bytes.Buffer{}
+ require.NoError(t, runAdd(addOptions{
+ apiBase: "https://a.example/v1",
+ apiKey: "k1",
+ modelID: "m1",
+ alias: "shared",
+ stdout: first,
+ }))
+
+ second := &bytes.Buffer{}
+ require.NoError(t, runAdd(addOptions{
+ apiBase: "https://b.example/v1",
+ apiKey: "k2",
+ modelID: "m2",
+ alias: "shared",
+ stdout: second,
+ }))
+
+ cfg, err := config.LoadConfig(configPath)
+ require.NoError(t, err)
+ matches := 0
+ for _, m := range cfg.ModelList {
+ if m != nil && m.ModelName == "shared" {
+ matches++
+ }
+ }
+ assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
+
+ updated := findModelByName(cfg, "shared")
+ require.NotNil(t, updated)
+ assert.Equal(t, "m2", updated.Model)
+ assert.Equal(t, "https://b.example/v1", updated.APIBase)
+ assert.Equal(t, "k2", updated.APIKeys[0].String())
+}
+
+func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
+ initTest(t)
+
+ err := runAdd(addOptions{
+ apiBase: "https://x/v1",
+ apiKey: "k",
+ modelID: "m",
+ alias: "a",
+ modelType: "anthropic",
+ stdout: &bytes.Buffer{},
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported --type")
+}
diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go
index 330734b82..c412993a0 100644
--- a/cmd/picoclaw/internal/model/command.go
+++ b/cmd/picoclaw/internal/model/command.go
@@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command {
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
+To onboard a model from a custom OpenAI-compatible endpoint (fetch the
+available list online and pick one), use the 'add' subcommand:
+
+ picoclaw model add --help
+
Examples:
picoclaw model # Show current default model
picoclaw model gpt-5.2 # Set gpt-5.2 as default
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
picoclaw model local-model # Set local VLLM server as default
+ picoclaw model add -b URL -k KEY # Add a model from a custom endpoint
Note: 'local-model' is a special value for using a local VLLM server
(running at localhost:8000 by default) which does not require an API key.`,
@@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server
},
}
+ cmd.AddCommand(newAddCommand())
+
return cmd
}
@@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) {
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
+
+ fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom")
+ fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').")
}
func listAvailableModels(cfg *config.Config) {
diff --git a/cmd/picoclaw/internal/model/online.go b/cmd/picoclaw/internal/model/online.go
new file mode 100644
index 000000000..9b8f7811d
--- /dev/null
+++ b/cmd/picoclaw/internal/model/online.go
@@ -0,0 +1,77 @@
+package model
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type modelEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+}
+
+type modelsAPIResponse struct {
+ Data []modelEntry `json:"data"`
+}
+
+// fetchOpenAIModels GETs /models with Bearer auth and accepts both the
+// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
+func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
+ if strings.TrimSpace(baseURL) == "" {
+ return nil, fmt.Errorf("api base is required")
+ }
+ if strings.TrimSpace(apiKey) == "" {
+ return nil, fmt.Errorf("api key is required")
+ }
+
+ url := strings.TrimRight(baseURL, "/") + "/models"
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ // {"data": [...]} envelope. Distinguish "envelope shape with empty list"
+ // from "object without a data key" via Data being non-nil after unmarshal:
+ // json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
+ // it as nil when "data" is absent or null.
+ var envelope modelsAPIResponse
+ if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
+ return envelope.Data, nil
+ }
+
+ // Bare-array shape, including `[]`.
+ var arr []modelEntry
+ if err := json.Unmarshal(body, &arr); err == nil {
+ return arr, nil
+ }
+
+ preview := body
+ if len(preview) > 256 {
+ preview = preview[:256]
+ }
+ return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
+}
diff --git a/config/config.example.json b/config/config.example.json
index 4205b8e8a..910c4fbd3 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -11,6 +11,8 @@
"summarize_message_threshold": 20,
"summarize_token_percent": 75,
"split_on_marker": false,
+ "max_llm_retries": 2,
+ "llm_retry_backoff_secs": 2,
"tool_feedback": {
"enabled": false,
"max_args_length": 300,
@@ -41,6 +43,7 @@
},
{
"model_name": "gemini",
+ "_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.",
"model": "antigravity/gemini-2.0-flash",
"auth_method": "oauth"
},
@@ -479,6 +482,15 @@
"approval_timeout_ms": 60000
}
},
+ "events": {
+ "logging": {
+ "enabled": true,
+ "include": ["agent.*"],
+ "exclude": [],
+ "min_severity": "info",
+ "include_payload": false
+ }
+ },
"gateway": {
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
"host": "localhost",
diff --git a/docker/Dockerfile.full b/docker/Dockerfile.full
index 30e1680d5..1dc0679c9 100644
--- a/docker/Dockerfile.full
+++ b/docker/Dockerfile.full
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.26.0-alpine AS builder
+FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
diff --git a/docker/Dockerfile.goreleaser.launcher b/docker/Dockerfile.goreleaser.launcher
index 0a20a90b3..97944afc1 100644
--- a/docker/Dockerfile.goreleaser.launcher
+++ b/docker/Dockerfile.goreleaser.launcher
@@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
-COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
ENTRYPOINT ["picoclaw-launcher"]
CMD ["-console", "-public", "-no-browser"]
diff --git a/docker/Dockerfile.heavy b/docker/Dockerfile.heavy
index 2a9fc742d..81f6976a2 100644
--- a/docker/Dockerfile.heavy
+++ b/docker/Dockerfile.heavy
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.26.0-alpine AS builder
+FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
diff --git a/docker/Dockerfile.launcher b/docker/Dockerfile.launcher
new file mode 100644
index 000000000..33fdc6d6e
--- /dev/null
+++ b/docker/Dockerfile.launcher
@@ -0,0 +1,65 @@
+# ============================================================
+# Stage 1: Build frontend assets (Node.js + pnpm)
+# ============================================================
+FROM node:24-alpine3.23 AS frontend
+
+RUN corepack enable && corepack prepare pnpm@latest --activate
+
+WORKDIR /src/web/frontend
+
+# Cache frontend dependencies
+COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./
+RUN CI=true pnpm install --frozen-lockfile
+
+# Build frontend
+COPY web/frontend/ ./
+RUN pnpm build:backend
+
+# ============================================================
+# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher)
+# ============================================================
+FROM golang:1.25-alpine AS builder
+
+RUN apk add --no-cache git make
+
+WORKDIR /src
+
+# Cache Go dependencies
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source
+COPY . .
+
+# Copy pre-built frontend assets into the backend embed directory
+COPY --from=frontend /src/web/backend/dist web/backend/dist
+
+# Build picoclaw binary (includes go generate)
+RUN make build
+
+# Build picoclaw-launcher binary (frontend already built in stage 1)
+# Mirror ldflags from web/Makefile to inject version metadata
+RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \
+ VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \
+ GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \
+ BUILD_TIME=$(date +%FT%T%z) && \
+ GO_VERSION=$(go env GOVERSION) && \
+ CGO_ENABLED=0 go build -v -tags goolm,stdjson \
+ -ldflags "-X ${CONFIG_PKG}.Version=${VERSION} -X ${CONFIG_PKG}.GitCommit=${GIT_COMMIT} -X ${CONFIG_PKG}.BuildTime=${BUILD_TIME} -X ${CONFIG_PKG}.GoVersion=${GO_VERSION} -s -w" \
+ -o build/picoclaw-launcher ./web/backend/
+
+# ============================================================
+# Stage 3: Minimal runtime image
+# ============================================================
+FROM alpine:3.23
+
+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 --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
+COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher
+
+ENTRYPOINT ["picoclaw-launcher"]
+CMD ["-console", "-public", "-no-browser"]
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 7c940621f..b12959fc7 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -4,6 +4,9 @@ services:
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
# ─────────────────────────────────────────────
picoclaw-agent:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-agent
profiles:
@@ -22,6 +25,9 @@ services:
# docker compose -f docker/docker-compose.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-gateway
restart: unless-stopped
@@ -38,6 +44,9 @@ services:
# docker compose -f docker/docker-compose.yml --profile launcher up
# ─────────────────────────────────────────────
picoclaw-launcher:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile.launcher
image: docker.io/sipeed/picoclaw:launcher
container_name: picoclaw-launcher
restart: unless-stopped
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 6df7447a7..e5fc3b540 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -6,6 +6,7 @@ Internal architecture notes for major runtime mechanisms and subsystem design.
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
+- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
diff --git a/docs/architecture/hooks/README.md b/docs/architecture/hooks/README.md
index 5be0f30b5..06f1a2c07 100644
--- a/docs/architecture/hooks/README.md
+++ b/docs/architecture/hooks/README.md
@@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho
| Type | Interface | Stage | Can modify data |
| --- | --- | --- | --- |
-| Observer | `EventObserver` | EventBus broadcast | No |
+| Observer | `RuntimeEventObserver` | Runtime event bus broadcast | No |
| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes |
| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes |
| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny |
@@ -136,9 +136,9 @@ Example:
"/tmp/review_gate.py"
],
"observe": [
- "tool_exec_start",
- "tool_exec_end",
- "tool_exec_skipped"
+ "agent.tool.exec_start",
+ "agent.tool.exec_end",
+ "agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -174,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de
The following is a minimal logging hook for in-process use. It implements:
-1. `EventObserver`
+1. `RuntimeEventObserver`
2. `LLMInterceptor`
3. `ToolInterceptor`
4. `ToolApprover`
@@ -196,6 +196,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/agent"
+ runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
@@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
}
}
-func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
+func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
_ = ctx
if h == nil || !h.logEvents {
return nil
}
- h.record("event", evt.Meta, map[string]any{
+ h.record("event", evt.Scope, map[string]any{
"event": evt.Kind.String(),
"payload": evt.Payload,
}, nil)
@@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
return decision, nil
}
-func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
+func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
logger.InfoCF("hooks", "Example hook observed", map[string]any{
"stage": stage,
})
@@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
entry := map[string]any{
"ts": time.Now().UTC(),
"stage": stage,
- "meta": meta,
+ "refs": refs,
"payload": payload,
"decision": decision,
}
@@ -428,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did
The following script is a minimal process-hook example. It uses only the Python standard library and supports:
1. `hook.hello`
-2. `hook.event`
+2. `hook.runtime_event`
3. `hook.before_tool`
4. `hook.approve_tool`
@@ -564,8 +565,8 @@ def main() -> int:
})
if not message_id:
- if method == "hook.event" and LOG_EVENTS:
- log_stderr(f"observed event: {params.get('Kind')}")
+ if method == "hook.runtime_event" and LOG_EVENTS:
+ log_stderr(f"observed event: {params.get('kind')}")
continue
try:
@@ -606,9 +607,9 @@ if __name__ == "__main__":
"/abs/path/to/review_gate.py"
],
"observe": [
- "tool_exec_start",
- "tool_exec_end",
- "tool_exec_skipped"
+ "agent.tool.exec_start",
+ "agent.tool.exec_end",
+ "agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -626,7 +627,7 @@ if __name__ == "__main__":
### Environment Variables
- `PICOCLAW_HOOK_LOG_EVENTS`
- Whether to write `hook.event` summaries to `stderr`, enabled by default
+ Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default
- `PICOCLAW_HOOK_LOG_FILE`
Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines
@@ -645,7 +646,7 @@ Typical interpretation:
- Only `hook.hello`
The process started and completed the handshake, but no business hook request has arrived yet
-- `hook.event`
+- `hook.runtime_event`
The `observe` configuration is working
- `hook.before_tool`
The `intercept: ["before_tool", ...]` configuration is working
@@ -664,7 +665,7 @@ A complete sample:
```json
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
-{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
+{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
```
@@ -672,7 +673,7 @@ A complete sample:
Additional notes:
- Timestamps are UTC
-- `notification=true` means it was a notification such as `hook.event`, which does not expect a response
+- `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response
- `id` increases within a single hook process; if the process restarts, the counter starts over
## Process-Hook Protocol
@@ -681,7 +682,7 @@ Current process hooks use `JSON-RPC over stdio`:
- PicoClaw starts the external process
- Requests and responses are exchanged as one JSON message per line
-- `hook.event` is a notification and does not need a response
+- `hook.runtime_event` is a notification and does not need a response
- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls
The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages.
diff --git a/docs/architecture/hooks/README.zh.md b/docs/architecture/hooks/README.zh.md
index 2170d45c8..1fff40832 100644
--- a/docs/architecture/hooks/README.zh.md
+++ b/docs/architecture/hooks/README.zh.md
@@ -13,7 +13,7 @@
| 类型 | 接口 | 作用阶段 | 能否改写 |
| --- | --- | --- | --- |
-| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 |
+| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 |
| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 |
| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 |
| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 |
@@ -136,9 +136,9 @@ HookManager 的排序规则是:
"/tmp/review_gate.py"
],
"observe": [
- "tool_exec_start",
- "tool_exec_end",
- "tool_exec_skipped"
+ "agent.tool.exec_start",
+ "agent.tool.exec_end",
+ "agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -174,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log
下面这段代码是一个最小的“记录型” in-process hook。它实现了:
-1. `EventObserver`
+1. `RuntimeEventObserver`
2. `LLMInterceptor`
3. `ToolInterceptor`
4. `ToolApprover`
@@ -196,6 +196,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/agent"
+ runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
@@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
}
}
-func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
+func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
_ = ctx
if h == nil || !h.logEvents {
return nil
}
- h.record("event", evt.Meta, map[string]any{
+ h.record("event", evt.Scope, map[string]any{
"event": evt.Kind.String(),
"payload": evt.Payload,
}, nil)
@@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
return decision, nil
}
-func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
+func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
logger.InfoCF("hooks", "Example hook observed", map[string]any{
"stage": stage,
})
@@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
entry := map[string]any{
"ts": time.Now().UTC(),
"stage": stage,
- "meta": meta,
+ "refs": refs,
"payload": payload,
"decision": decision,
}
@@ -428,7 +429,7 @@ func init() {
下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持:
1. `hook.hello`
-2. `hook.event`
+2. `hook.runtime_event`
3. `hook.before_tool`
4. `hook.approve_tool`
@@ -564,8 +565,8 @@ def main() -> int:
})
if not message_id:
- if method == "hook.event" and LOG_EVENTS:
- log_stderr(f"observed event: {params.get('Kind')}")
+ if method == "hook.runtime_event" and LOG_EVENTS:
+ log_stderr(f"observed event: {params.get('kind')}")
continue
try:
@@ -606,9 +607,9 @@ if __name__ == "__main__":
"/abs/path/to/review_gate.py"
],
"observe": [
- "tool_exec_start",
- "tool_exec_end",
- "tool_exec_skipped"
+ "agent.tool.exec_start",
+ "agent.tool.exec_end",
+ "agent.tool.exec_skipped"
],
"intercept": [
"before_tool",
@@ -626,7 +627,7 @@ if __name__ == "__main__":
### 环境变量
- `PICOCLAW_HOOK_LOG_EVENTS`
- 是否把 `hook.event` 写到 `stderr`,默认开启
+ 是否把 `hook.runtime_event` 写到 `stderr`,默认开启
- `PICOCLAW_HOOK_LOG_FILE`
外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件
@@ -645,7 +646,7 @@ if __name__ == "__main__":
- 只看到 `hook.hello`
说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来
-- 看到 `hook.event`
+- 看到 `hook.runtime_event`
说明 `observe` 配置生效了
- 看到 `hook.before_tool`
说明 `intercept: ["before_tool", ...]` 生效了
@@ -664,7 +665,7 @@ if __name__ == "__main__":
```json
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
-{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
+{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
```
@@ -672,7 +673,7 @@ if __name__ == "__main__":
补充说明:
- 时间戳是 UTC,不是本地时区
-- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知
+- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知
- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始
## Process Hook 协议约定
@@ -681,7 +682,7 @@ if __name__ == "__main__":
- PicoClaw 启动外部进程
- 请求和响应都按“一行一个 JSON 消息”传输
-- `hook.event` 是 notification,不需要响应
+- `hook.runtime_event` 是 notification,不需要响应
- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response
当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。
diff --git a/docs/architecture/hooks/hook-json-protocol.md b/docs/architecture/hooks/hook-json-protocol.md
index 58b6e323b..725869a02 100644
--- a/docs/architecture/hooks/hook-json-protocol.md
+++ b/docs/architecture/hooks/hook-json-protocol.md
@@ -437,21 +437,28 @@ Approval hook for deciding whether to allow execution of sensitive tools.
---
-## 7. `hook.event` (notification)
+## 7. `hook.runtime_event` (notification)
-Observer event, broadcast only, no response required. `id` is `0` or absent.
+Runtime observer event, broadcast only, no response required. `id` is `0` or absent.
```json
{
"jsonrpc": "2.0",
- "method": "hook.event",
+ "method": "hook.runtime_event",
"params": {
- "Kind": "tool_exec_start",
- "Meta": {
- "AgentID": "agent-1",
- "TurnID": "turn-1"
+ "kind": "agent.tool.exec_start",
+ "source": {
+ "component": "agent",
+ "name": "agent-1"
},
- "Payload": {
+ "scope": {
+ "agent_id": "agent-1",
+ "session_key": "session-1",
+ "turn_id": "turn-1",
+ "channel": "cli",
+ "chat_id": "chat-1"
+ },
+ "payload": {
"Tool": "echo_text",
"Arguments": {"text": "hello"}
}
@@ -460,12 +467,14 @@ Observer event, broadcast only, no response required. `id` is `0` or absent.
```
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`
+- `agent.turn.start` / `agent.turn.end`
+- `agent.llm.request` / `agent.llm.response`
+- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
+- `agent.steering.injected`
+- `agent.interrupt.received`
+- `agent.error`
+
+Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`.
---
@@ -565,4 +574,4 @@ def handle_before_tool(params: dict) -> dict:
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
+This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
diff --git a/docs/architecture/hooks/hook-json-protocol.zh.md b/docs/architecture/hooks/hook-json-protocol.zh.md
index 675e0a429..9c11c6270 100644
--- a/docs/architecture/hooks/hook-json-protocol.zh.md
+++ b/docs/architecture/hooks/hook-json-protocol.zh.md
@@ -437,21 +437,28 @@
---
-## 7. `hook.event`(notification)
+## 7. `hook.runtime_event`(notification)
-观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
+runtime 观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
```json
{
"jsonrpc": "2.0",
- "method": "hook.event",
+ "method": "hook.runtime_event",
"params": {
- "Kind": "tool_exec_start",
- "Meta": {
- "AgentID": "agent-1",
- "TurnID": "turn-1"
+ "kind": "agent.tool.exec_start",
+ "source": {
+ "component": "agent",
+ "name": "agent-1"
},
- "Payload": {
+ "scope": {
+ "agent_id": "agent-1",
+ "session_key": "session-1",
+ "turn_id": "turn-1",
+ "channel": "cli",
+ "chat_id": "chat-1"
+ },
+ "payload": {
"Tool": "echo_text",
"Arguments": {"text": "hello"}
}
@@ -460,12 +467,14 @@
```
常见 `Kind` 值:
-- `turn_start` / `turn_end`
-- `llm_request` / `llm_response`
-- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
-- `steering_injected`
-- `interrupt_received`
-- `error`
+- `agent.turn.start` / `agent.turn.end`
+- `agent.llm.request` / `agent.llm.response`
+- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
+- `agent.steering.injected`
+- `agent.interrupt.received`
+- `agent.error`
+
+旧 observe 配置名如 `turn_end`、`tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`。
---
@@ -565,4 +574,4 @@ def handle_before_tool(params: dict) -> dict:
return {"action": "continue"}
```
-通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
\ No newline at end of file
+通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
diff --git a/docs/architecture/runtime-events.md b/docs/architecture/runtime-events.md
new file mode 100644
index 000000000..5d625a34b
--- /dev/null
+++ b/docs/architecture/runtime-events.md
@@ -0,0 +1,216 @@
+# Runtime Events And Event Logging
+
+PicoClaw runtime events are the read-only observation surface for agent, channel, gateway, message bus, and MCP activity. Publishing events and printing logs are separate responsibilities:
+
+- Event publishing: components publish `pkg/events.Event` values to the runtime event bus for hooks, tests, diagnostics, and future UI consumers.
+- Event logging: the built-in runtime event logger subscribes to the same bus and prints only the events selected by configuration.
+
+This keeps runtime code focused on publishing events while log policy stays centralized.
+
+## Default Behavior
+
+By default, only `agent.*` events are printed:
+
+```json
+{
+ "events": {
+ "logging": {
+ "enabled": true,
+ "include": ["agent.*"],
+ "min_severity": "info",
+ "include_payload": false
+ }
+ }
+}
+```
+
+This preserves the previous behavior: agent turn, LLM, tool, steering, subturn, and error events appear in logs. Channel, gateway, bus, and MCP events are still published to the runtime event bus, but they are not printed unless configured.
+
+## Configuration
+
+The configuration lives under `events.logging` in `config.json`:
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `enabled` | bool | `true` | Enables the built-in event logger subscription |
+| `include` | string[] | `["agent.*"]` | Event kinds to print; supports exact matches, `*`, and patterns such as `agent.*` |
+| `exclude` | string[] | `[]` | Event kinds to suppress after include matching |
+| `min_severity` | string | `info` | Minimum severity: `debug`, `info`, `warn`, or `error` |
+| `include_payload` | bool | `false` | Adds raw event payloads to log fields |
+
+`include_payload` is disabled by default. Agent events print safe summary fields such as `user_len`, `args_count`, and `content_len` instead of full user messages or tool arguments. Enable raw payload logging only for short-lived diagnostics in a trusted log environment.
+
+## Matching Rules
+
+`include` and `exclude` match the `Event.Kind` string:
+
+```json
+{
+ "events": {
+ "logging": {
+ "include": ["gateway.*", "channel.lifecycle.*", "agent.error"],
+ "exclude": ["gateway.ready"],
+ "min_severity": "info"
+ }
+ }
+}
+```
+
+Common patterns:
+
+- `["agent.*"]`: print agent events only.
+- `["*"]`: print all runtime events.
+- `["gateway.*", "channel.*"]`: print gateway and channel events only.
+- `exclude: ["agent.llm.delta"]`: suppress high-volume streaming delta events.
+- `min_severity: "warn"`: print warn and error events only.
+
+## Environment Variables
+
+The same settings can be overridden with environment variables:
+
+```bash
+PICOCLAW_EVENTS_LOGGING_ENABLED=true
+PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*"
+PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready"
+PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info
+PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false
+```
+
+`include` and `exclude` use comma-separated values.
+
+## Event Names And Triggers
+
+The table below lists the current runtime event kinds, when they are emitted, and the most useful event details. `Source`, `Scope`, and `Correlation` are shared envelope fields that may appear on every event. The "Details" column refers to useful payload fields or log summary fields.
+
+### Agent
+
+| Event | Trigger | Details |
+| ----- | ------- | ------- |
+| `agent.turn.start` | An agent starts processing one user or system input after the turn scope has been created. | `user_len`, `media_count`; scope usually includes `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` |
+| `agent.turn.end` | A turn exits, whether it completed, errored, or was hard-aborted. | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` |
+| `agent.llm.request` | Before each LLM provider request. | `model`, `messages`, `tools`, `max_tokens` |
+| `agent.llm.delta` | Reserved for streaming LLM deltas; the kind is defined, but the current implementation has no natural emit site. | `content_delta_len`, `reasoning_delta_len` |
+| `agent.llm.response` | After the LLM provider returns a complete response. | `content_len`, `tool_calls`, `has_reasoning` |
+| `agent.llm.retry` | Before retrying an LLM request after context, rate-limit, transient provider, or fallback handling. | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
+| `agent.context.compress` | Agent context history is compressed, for example during proactive budget checks or LLM retry handling. | `reason`, `dropped_messages`, `remaining_messages` |
+| `agent.session.summarize` | Async session history summarization completes. | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
+| `agent.tool.exec_start` | Before the agent executes a tool call. | `tool`, `args_count`; full arguments are not logged by default |
+| `agent.tool.exec_end` | After a tool call completes, including successful results, tool errors, and async results. | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
+| `agent.tool.exec_skipped` | A tool call is skipped because the tool is unavailable, arguments are invalid, or turn control logic requires skipping it. | `tool`, `reason` |
+| `agent.steering.injected` | Queued steering messages are injected into the next LLM context. | `count`, `total_content_len` |
+| `agent.follow_up.queued` | An async tool result is queued back into the inbound/follow-up flow. | `source_tool`, `content_len` |
+| `agent.interrupt.received` | A turn accepts steering, graceful interrupt, or hard-abort input. | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
+| `agent.subturn.spawn` | A parent turn creates a child turn/subagent. | `child_agent_id`, `label`, `parent_turn_id` |
+| `agent.subturn.end` | A child turn ends. | `child_agent_id`, `status` |
+| `agent.subturn.result_delivered` | A child turn result is delivered to the target channel/chat. | `target_channel`, `target_chat_id`, `content_len` |
+| `agent.subturn.orphan` | A child turn result cannot be delivered or cannot be associated back to its parent turn. | `parent_turn_id`, `child_turn_id`, `reason` |
+| `agent.error` | Agent execution reports an error. | `stage`, `error` |
+
+### Channel
+
+| Event | Trigger | Details |
+| ----- | ------- | ------- |
+| `channel.lifecycle.initialized` | The channel manager creates and registers a channel instance from config. | `type`; scope includes `channel` |
+| `channel.lifecycle.started` | Channel `Start()` succeeds and worker goroutines have been started; added channels during hot reload also emit it. | `type` |
+| `channel.lifecycle.start_failed` | Channel `Start()` fails. | `type`, `error`; severity is `error` |
+| `channel.lifecycle.stopped` | Channel `Stop()` succeeds. | `type` |
+| `channel.webhook.registered` | A channel webhook handler is registered on the shared HTTP mux. | `type`; scope includes `channel` |
+| `channel.webhook.unregistered` | A channel webhook handler is removed from the shared HTTP mux. | `type`; scope includes `channel` |
+| `channel.message.outbound_queued` | An outbound text or media message is queued into its channel worker. | `media`, `content_len`, `reply_to_message_id`; scope comes from the original inbound context |
+| `channel.message.outbound_sent` | An outbound text or media message is sent successfully, or a placeholder edit handled the response. | `media`, `content_len`, `message_ids`, `reply_to_message_id` |
+| `channel.message.outbound_failed` | An outbound text or media message exhausts retries or hits a permanent failure. | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity is `error` |
+| `channel.rate_limited` | A channel worker is waiting for a rate-limit token and the context is canceled, interrupting this delivery. | `media`, `content_len`, `error`, `reply_to_message_id`; severity is `warn` |
+
+### Message Bus
+
+| Event | Trigger | Details |
+| ----- | ------- | ------- |
+| `bus.publish.failed` | Publishing inbound, outbound, media, audio, or voice-control data fails, or required context is missing. | `stream`, `error`; scope is derived from message context when possible |
+| `bus.close.started` | Message bus shutdown begins. | `drained` is usually `0` |
+| `bus.close.drained` | Shutdown waits for buffered messages to drain and at least one buffered message was drained. | `drained` |
+| `bus.close.completed` | Message bus shutdown completes. | `drained` |
+
+### Gateway
+
+| Event | Trigger | Details |
+| ----- | ------- | ------- |
+| `gateway.start` | Gateway startup reaches the agent/runtime event bus/bootstrap binding point. | `duration_ms` |
+| `gateway.ready` | Gateway services, channel manager, HTTP server, and other core services are ready. | `duration_ms` |
+| `gateway.shutdown` | Gateway shutdown begins. | No fixed payload; envelope fields may be the only fields |
+| `gateway.reload.started` | Hot reload execution starts. | `duration_ms` |
+| `gateway.reload.completed` | Hot reload completes successfully. | `duration_ms` |
+| `gateway.reload.failed` | Hot reload fails. | `duration_ms`, `error`; severity is `error` |
+
+### MCP
+
+| Event | Trigger | Details |
+| ----- | ------- | ------- |
+| `mcp.server.connecting` | The MCP manager is about to connect to a server. | `server`, `type`, `url`, `command` |
+| `mcp.server.connected` | An MCP server connects and its tool list has been initialized. | `server`, `type`, `url`, `command`, `tool_count` |
+| `mcp.server.failed` | An MCP server connection fails, or the manager is closed before connecting. | `server`, `type`, `url`, `command`, `error`; severity is `error` |
+| `mcp.tool.discovered` | A tool from an MCP server is discovered and registered. | `server`, `type`, `url`, `command`, `tool` |
+| `mcp.tool.call.start` | The MCP tool wrapper starts a remote tool call. | `server`, `tool`; when emitted inside an agent turn, scope includes turn/chat information |
+| `mcp.tool.call.end` | The MCP tool wrapper finishes a remote tool call, including failures. | `server`, `tool`, `duration_ms`, `is_error`, `error` |
+
+## Log Fields
+
+Runtime event logs include stable envelope fields when available:
+
+- `event_id`
+- `event_kind`
+- `severity`
+- `event_time`
+- `source_component`
+- `source_name`
+- `agent_id`
+- `session_key`
+- `turn_id`
+- `channel`
+- `account`
+- `chat_id`
+- `topic_id`
+- `space_id`
+- `space_type`
+- `chat_type`
+- `sender_id`
+- `message_id`
+- `trace_id`
+- `parent_turn_id`
+- `request_id`
+- `reply_to_id`
+
+Agent events add safe payload summaries:
+
+| Event | Summary fields |
+| ----- | -------------- |
+| `agent.turn.start` | `user_len`, `media_count` |
+| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` |
+| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` |
+| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` |
+| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` |
+| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
+| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` |
+| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
+| `agent.tool.exec_start` | `tool`, `args_count` |
+| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
+| `agent.tool.exec_skipped` | `tool`, `reason` |
+| `agent.steering.injected` | `count`, `total_content_len` |
+| `agent.follow_up.queued` | `source_tool`, `content_len` |
+| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
+| `agent.subturn.spawn` | `child_agent_id`, `label` |
+| `agent.subturn.end` | `child_agent_id`, `status` |
+| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` |
+| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` |
+| `agent.error` | `stage`, `error` |
+
+## Event Domains
+
+Runtime event kinds are defined in `pkg/events/kind.go`. Event logging can select these domains:
+
+- `agent.*`: agent turn, LLM, tool, context, steering, interrupt, subturn, and error events.
+- `channel.*`: channel lifecycle, webhook registration, outbound queued/sent/failed, and rate limiting.
+- `bus.*`: publish failures and close lifecycle.
+- `gateway.*`: start, ready, shutdown, and reload lifecycle.
+- `mcp.*`: MCP server connection, tool discovery, and tool call events.
+
+See [`../../config/config.example.json`](../../config/config.example.json) for the default event logging example.
diff --git a/docs/architecture/runtime-events.zh.md b/docs/architecture/runtime-events.zh.md
new file mode 100644
index 000000000..3ed384537
--- /dev/null
+++ b/docs/architecture/runtime-events.zh.md
@@ -0,0 +1,216 @@
+# Runtime Events 与事件日志
+
+PicoClaw 的 runtime event 是运行时观察面,用来描述 agent、channel、gateway、message bus、MCP 等组件发生了什么。事件发布和日志打印是两件事:
+
+- 事件发布:组件把 `pkg/events.Event` 发布到 runtime event bus,供 hook、测试、调试工具或后续 UI 消费。
+- 事件日志:内置 runtime event logger 订阅同一个 bus,并按配置把匹配的事件打印到日志。
+
+这样可以让业务流程继续只负责发布事件,日志策略统一收口到一个地方。
+
+## 默认行为
+
+默认配置只打印 `agent.*` 事件:
+
+```json
+{
+ "events": {
+ "logging": {
+ "enabled": true,
+ "include": ["agent.*"],
+ "min_severity": "info",
+ "include_payload": false
+ }
+ }
+}
+```
+
+这个默认值保持了旧行为:agent turn、LLM、tool、steering、subturn、error 等事件会出现在日志中;channel、gateway、bus、MCP 事件仍会发布到 runtime event bus,但默认不打印,避免网关启动和消息投递日志过于嘈杂。
+
+## 配置项
+
+配置位于 `config.json` 的 `events.logging`:
+
+| 字段 | 类型 | 默认值 | 说明 |
+| ---- | ---- | ------ | ---- |
+| `enabled` | bool | `true` | 是否启用内置事件日志订阅器 |
+| `include` | string[] | `["agent.*"]` | 允许打印的事件 kind,支持精确匹配、`*`、`agent.*` 这类 glob/prefix |
+| `exclude` | string[] | `[]` | 在 include 命中后排除的事件 kind,匹配规则同 include |
+| `min_severity` | string | `info` | 最低打印级别:`debug`、`info`、`warn`、`error` |
+| `include_payload` | bool | `false` | 是否把原始 payload 放进日志字段 |
+
+`include_payload` 默认关闭。agent 事件日志会输出安全摘要字段,例如 `user_len`、`args_count`、`content_len`,不会默认输出完整用户消息或工具参数。只有在排查问题、并且确认日志存储环境可信时,才建议临时打开 `include_payload`。
+
+## 匹配规则
+
+`include` 和 `exclude` 都匹配 `Event.Kind` 字符串:
+
+```json
+{
+ "events": {
+ "logging": {
+ "include": ["gateway.*", "channel.lifecycle.*", "agent.error"],
+ "exclude": ["gateway.ready"],
+ "min_severity": "info"
+ }
+ }
+}
+```
+
+常用写法:
+
+- `["agent.*"]`:只打印 agent 事件。
+- `["*"]`:打印所有 runtime events。
+- `["gateway.*", "channel.*"]`:只打印 gateway 和 channel 事件。
+- `exclude: ["agent.llm.delta"]`:排除高频流式 delta 事件。
+- `min_severity: "warn"`:只打印 warn/error 事件。
+
+## 环境变量
+
+同一组配置也可以通过环境变量覆盖,适合临时调试:
+
+```bash
+PICOCLAW_EVENTS_LOGGING_ENABLED=true
+PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*"
+PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready"
+PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info
+PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false
+```
+
+`include` 和 `exclude` 的环境变量使用逗号分隔。
+
+## 事件名称与触发时机
+
+下面列出当前 runtime event kind、触发时机和主要事件详情。`Source`、`Scope`、`Correlation` 是所有事件都可能携带的 envelope 字段;表里的“主要详情”指 payload 或日志摘要中最有用的字段。
+
+### Agent
+
+| 事件名 | 触发时机 | 主要详情 |
+| ------ | -------- | -------- |
+| `agent.turn.start` | agent 开始处理一次用户输入或系统输入,turn scope 已创建时 | `user_len`, `media_count`; scope 通常包含 `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` |
+| `agent.turn.end` | 一次 turn 退出时,无论完成、报错还是 hard abort | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` |
+| `agent.llm.request` | 每次调用 LLM provider 前 | `model`, `messages`, `tools`, `max_tokens` |
+| `agent.llm.delta` | 预留给流式 LLM delta;当前实现已定义但没有自然发送点 | `content_delta_len`, `reasoning_delta_len` |
+| `agent.llm.response` | LLM provider 返回完整响应后 | `content_len`, `tool_calls`, `has_reasoning` |
+| `agent.llm.retry` | LLM 请求因上下文、限流、临时错误等原因准备重试前 | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
+| `agent.context.compress` | 上下文历史被压缩时,例如主动预算检查或 LLM retry 处理 | `reason`, `dropped_messages`, `remaining_messages` |
+| `agent.session.summarize` | 会话历史异步摘要完成时 | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
+| `agent.tool.exec_start` | agent 准备执行一个工具调用前 | `tool`, `args_count`; 默认不打印完整参数 |
+| `agent.tool.exec_end` | 工具调用完成后,包括成功、工具错误和 async 结果 | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
+| `agent.tool.exec_skipped` | 工具调用被跳过时,例如工具不可用、参数无效或 turn 控制逻辑要求跳过 | `tool`, `reason` |
+| `agent.steering.injected` | queued steering message 被注入下一轮 LLM 上下文时 | `count`, `total_content_len` |
+| `agent.follow_up.queued` | async 工具结果被重新排入 inbound/follow-up 流程时 | `source_tool`, `content_len` |
+| `agent.interrupt.received` | turn 接受 steering、graceful interrupt 或 hard abort 指令时 | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
+| `agent.subturn.spawn` | 父 turn 创建子 turn/subagent 时 | `child_agent_id`, `label`, `parent_turn_id` |
+| `agent.subturn.end` | 子 turn 结束时 | `child_agent_id`, `status` |
+| `agent.subturn.result_delivered` | 子 turn 结果成功投递到目标 channel/chat 时 | `target_channel`, `target_chat_id`, `content_len` |
+| `agent.subturn.orphan` | 子 turn 结果无法投递或无法关联回父 turn 时 | `parent_turn_id`, `child_turn_id`, `reason` |
+| `agent.error` | agent 执行流程报告错误时 | `stage`, `error` |
+
+### Channel
+
+| 事件名 | 触发时机 | 主要详情 |
+| ------ | -------- | -------- |
+| `channel.lifecycle.initialized` | channel manager 根据配置创建并注册 channel 实例后 | `type`; scope 包含 `channel` |
+| `channel.lifecycle.started` | channel `Start()` 成功,worker 已启动时;热重载新增 channel 也会触发 | `type` |
+| `channel.lifecycle.start_failed` | channel `Start()` 失败时 | `type`, `error`; severity 为 `error` |
+| `channel.lifecycle.stopped` | channel `Stop()` 成功后 | `type` |
+| `channel.webhook.registered` | channel 的 webhook handler 被注册到共享 HTTP mux 时 | `type`; scope 包含 `channel` |
+| `channel.webhook.unregistered` | channel 的 webhook handler 从共享 HTTP mux 移除时 | `type`; scope 包含 `channel` |
+| `channel.message.outbound_queued` | outbound 文本或媒体消息被放入对应 channel worker 队列时 | `media`, `content_len`, `reply_to_message_id`; scope 来自原 inbound context |
+| `channel.message.outbound_sent` | outbound 文本或媒体消息成功发送,或 placeholder edit 已处理响应时 | `media`, `content_len`, `message_ids`, `reply_to_message_id` |
+| `channel.message.outbound_failed` | outbound 文本或媒体消息重试耗尽或遇到永久失败时 | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity 为 `error` |
+| `channel.rate_limited` | channel worker 等待 rate limiter token 时被 context 取消,导致本次发送被限流/中断 | `media`, `content_len`, `error`, `reply_to_message_id`; severity 为 `warn` |
+
+### Message Bus
+
+| 事件名 | 触发时机 | 主要详情 |
+| ------ | -------- | -------- |
+| `bus.publish.failed` | inbound、outbound、media、audio 或 voice control 发布失败,或缺少必要 context 时 | `stream`, `error`; scope 尽量来自消息 context |
+| `bus.close.started` | message bus 开始关闭时 | `drained` 通常为 `0` |
+| `bus.close.drained` | close 期间等待队列 drain,并且 drain 到至少一条 buffered message 时 | `drained` |
+| `bus.close.completed` | message bus 完成关闭时 | `drained` |
+
+### Gateway
+
+| 事件名 | 触发时机 | 主要详情 |
+| ------ | -------- | -------- |
+| `gateway.start` | gateway 完成 agent/runtime event bus/bootstrap 绑定后 | `duration_ms` |
+| `gateway.ready` | gateway 服务、channel manager、HTTP 等关键服务启动完成后 | `duration_ms` |
+| `gateway.shutdown` | gateway 开始关闭流程时 | 无固定 payload,可能只有 envelope 字段 |
+| `gateway.reload.started` | 热重载开始执行时 | `duration_ms` |
+| `gateway.reload.completed` | 热重载成功完成时 | `duration_ms` |
+| `gateway.reload.failed` | 热重载失败时 | `duration_ms`, `error`; severity 为 `error` |
+
+### MCP
+
+| 事件名 | 触发时机 | 主要详情 |
+| ------ | -------- | -------- |
+| `mcp.server.connecting` | MCP manager 准备连接某个 server 前 | `server`, `type`, `url`, `command` |
+| `mcp.server.connected` | MCP server 连接成功并完成工具列表初始化后 | `server`, `type`, `url`, `command`, `tool_count` |
+| `mcp.server.failed` | MCP server 连接失败,或 manager 已关闭导致无法连接时 | `server`, `type`, `url`, `command`, `error`; severity 为 `error` |
+| `mcp.tool.discovered` | MCP server 的某个工具被发现并注册时 | `server`, `type`, `url`, `command`, `tool` |
+| `mcp.tool.call.start` | MCP tool wrapper 开始执行一次远端工具调用前 | `server`, `tool`; 如果在 agent turn 内触发,scope 会带上对应 turn/chat 信息 |
+| `mcp.tool.call.end` | MCP tool wrapper 完成一次远端工具调用后,包括失败结果 | `server`, `tool`, `duration_ms`, `is_error`, `error` |
+
+## 日志字段
+
+所有事件日志都会尽量包含稳定 envelope 字段:
+
+- `event_id`
+- `event_kind`
+- `severity`
+- `event_time`
+- `source_component`
+- `source_name`
+- `agent_id`
+- `session_key`
+- `turn_id`
+- `channel`
+- `account`
+- `chat_id`
+- `topic_id`
+- `space_id`
+- `space_type`
+- `chat_type`
+- `sender_id`
+- `message_id`
+- `trace_id`
+- `parent_turn_id`
+- `request_id`
+- `reply_to_id`
+
+agent 事件还会追加 payload 摘要字段:
+
+| 事件 | 摘要字段 |
+| ---- | -------- |
+| `agent.turn.start` | `user_len`, `media_count` |
+| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` |
+| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` |
+| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` |
+| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` |
+| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` |
+| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` |
+| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` |
+| `agent.tool.exec_start` | `tool`, `args_count` |
+| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` |
+| `agent.tool.exec_skipped` | `tool`, `reason` |
+| `agent.steering.injected` | `count`, `total_content_len` |
+| `agent.follow_up.queued` | `source_tool`, `content_len` |
+| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` |
+| `agent.subturn.spawn` | `child_agent_id`, `label` |
+| `agent.subturn.end` | `child_agent_id`, `status` |
+| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` |
+| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` |
+| `agent.error` | `stage`, `error` |
+
+## 可打印的事件域
+
+当前 runtime event kind 定义在 `pkg/events/kind.go`。事件日志配置可以选择这些域:
+
+- `agent.*`:agent turn、LLM、tool、context、steering、interrupt、subturn、error。
+- `channel.*`:channel lifecycle、webhook 注册、outbound queued/sent/failed、rate limited。
+- `bus.*`:publish failed、close started/drained/completed。
+- `gateway.*`:start、ready、shutdown、reload started/completed/failed。
+- `mcp.*`:server connecting/connected/failed、tool discovered、tool call start/end。
+
+默认事件日志示例见 [`../../config/config.example.json`](../../config/config.example.json)。
diff --git a/docs/architecture/subturn.md b/docs/architecture/subturn.md
index 0a927b56d..31a56902c 100644
--- a/docs/architecture/subturn.md
+++ b/docs/architecture/subturn.md
@@ -135,16 +135,16 @@ The agent loop polls for async SubTurn results at two points per iteration:
All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
-## Event Bus Integration
+## Runtime Event Integration
-SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging:
+SubTurns emit runtime events through `pkg/events` for observability and debugging:
| Event Kind | When Emitted | Payload |
|:------|:-------------|:--------|
-| `subturn_spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
-| `subturn_end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
-| `subturn_result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
-| `subturn_orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
+| `agent.subturn.spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` |
+| `agent.subturn.end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` |
+| `agent.subturn.result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` |
+| `agent.subturn.orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` |
## API Reference
@@ -240,13 +240,13 @@ An orphan result occurs when:
2. The `pendingResults` channel is full (buffer size: 16)
When a result becomes orphan:
-- `SubTurnOrphanResultEvent` is emitted to EventBus
+- `agent.subturn.orphan` is emitted to the runtime event bus
- The result is **NOT** delivered to the LLM context
- External systems can listen to this event for custom handling
### Preventing Orphan Results
- Use `Critical: true` for important SubTurns that must complete
-- Monitor `SubTurnOrphanResultEvent` for observability
+- Monitor `agent.subturn.orphan` for observability
- Consider the 16-buffer limit when spawning many async SubTurns
## Tool Inheritance
diff --git a/docs/design/hook-system-design.zh.md b/docs/design/hook-system-design.zh.md
index ab5566bec..090437c20 100644
--- a/docs/design/hook-system-design.zh.md
+++ b/docs/design/hook-system-design.zh.md
@@ -1,11 +1,15 @@
# PicoClaw Hook 系统设计(基于 `refactor/agent`)
+> 当前状态:本文是 hook 系统的早期设计记录。事件系统升级后,观察型 hook 的主路径已经切到
+> `pkg/events.Event`、`RuntimeEventObserver` 和进程 hook 的 `hook.runtime_event`。
+> 旧 `agent.Event`、`EventKind`、`hook.event` 兼容层已经删除。
+
## 背景
本设计围绕两个议题展开:
- `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测
-- `#1796`:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型
+- `#1796`:在 runtime event bus 稳定后,把 hooks 设计为事件 consumer,而不是重新发明一套事件模型
当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。
@@ -52,20 +56,18 @@ pi-mono 的核心思路更接近当前分支:
当前分支已经具备 hook 系统的地基:
-- `pkg/agent/events.go` 定义了稳定的 `EventKind`、`EventMeta` 和 payload
-- `pkg/agent/eventbus.go` 提供了非阻塞 fan-out 的 `EventBus`
+- `pkg/events` 定义 runtime event envelope、kind、scope、source、severity 和 fan-out bus
+- `pkg/agent/event_payloads.go` 保留 agent domain payload
+- agent domain payload 保留在 `pkg/agent/event_payloads.go`
- `pkg/agent/loop.go` 中的 `runTurn()` 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件
- `pkg/agent/steering.go` 已支持 steering、graceful interrupt、hard abort
- `pkg/agent/turn.go` 已维护 turn phase、恢复点、active turn、abort 状态
### 现有缺口
-当前分支还缺四件事:
-
-- 没有 HookManager,只有 EventBus
-- 没有 Before/After LLM、Before/After Tool 这种同步拦截点
-- 没有审批型 hook
-- 子 agent 仍走 `pkg/tools/SubagentManager + RunToolLoop`,没有接入 `pkg/agent` 的 turn tree 和事件流
+早期设计时的缺口包括 HookManager、Before/After LLM、Before/After Tool、审批型 hook
+以及 sub-turn 接入。当前实现已经覆盖主 turn 的 HookManager、LLM/Tool 拦截和审批;
+sub-turn 事件已接入 runtime event bus。
### 一个关键现实
@@ -73,19 +75,19 @@ pi-mono 的核心思路更接近当前分支:
## 设计原则
-- Hook 必须建立在 `pkg/agent` 的 EventBus 和 turn 上下文之上
-- EventBus 负责广播,HookManager 负责拦截,两者职责分离
+- Hook 必须建立在 `pkg/events` runtime event bus 和 turn 上下文之上
+- runtime event bus 负责广播,HookManager 负责拦截,两者职责分离
- 项目内挂载要简单,项目外挂载必须走 IPC
- 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时
- 先覆盖主 turn,不把 sub-turn 一次做满
-- 不新增第二套用户事件命名系统,优先复用 `EventKind.String()`
+- 不新增第二套用户事件命名系统,新观察点统一使用 `pkg/events.Kind`
## 总体架构
分成三层:
-1. `EventBus`
- 负责广播只读事件,现有实现直接复用
+1. `pkg/events` runtime event bus
+ 负责广播只读事件,覆盖 agent、channel、gateway、bus、MCP 等运行时组件
2. `HookManager`
负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截
@@ -97,7 +99,7 @@ pi-mono 的核心思路更接近当前分支:
换句话说:
-- EventBus 是“发生了什么”
+- runtime event bus 是“发生了什么”
- HookManager 是“谁能介入”
- HookMount 是“这些 hook 从哪里来”
@@ -113,11 +115,11 @@ pi-mono 的核心思路更接近当前分支:
```go
type EventObserver interface {
- OnEvent(ctx context.Context, evt agent.Event) error
+ OnRuntimeEvent(ctx context.Context, evt events.Event) error
}
```
-这类 hook 直接订阅 EventBus 即可。
+这类 hook 直接订阅 runtime event bus 即可。
适用场景:
@@ -156,7 +158,7 @@ type ToolApprover interface {
## 对外暴露的最小 hook 面
-V1 不需要把所有 EventKind 都变成可拦截点。
+V1 不需要把所有 runtime event kind 都变成可拦截点。
建议只开放这些同步 hook:
@@ -168,19 +170,19 @@ V1 不需要把所有 EventKind 都变成可拦截点。
其余节点继续作为只读事件暴露:
-- `turn_start`
-- `turn_end`
-- `llm_request`
-- `llm_response`
-- `tool_exec_start`
-- `tool_exec_end`
-- `tool_exec_skipped`
-- `steering_injected`
-- `follow_up_queued`
-- `interrupt_received`
-- `context_compress`
-- `session_summarize`
-- `error`
+- `agent.turn.start`
+- `agent.turn.end`
+- `agent.llm.request`
+- `agent.llm.response`
+- `agent.tool.exec_start`
+- `agent.tool.exec_end`
+- `agent.tool.exec_skipped`
+- `agent.steering.injected`
+- `agent.follow_up.queued`
+- `agent.interrupt.received`
+- `agent.context.compress`
+- `agent.session.summarize`
+- `agent.error`
`subturn_*` 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。
@@ -369,7 +371,7 @@ PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。
### 观察链路
```text
-runTurn() -> emitEvent() -> EventBus -> observers
+runTurn() -> emitEvent() -> runtime event bus -> observers
```
### 拦截链路
@@ -453,7 +455,7 @@ V1 不做复杂自动发现。
### Phase 3
- 把 `SubagentManager` 迁移到 `runTurn/sub-turn`
-- 接通 `subturn_spawn` / `subturn_end` / `subturn_result_delivered`
+- 接通 `agent.subturn.spawn` / `agent.subturn.end` / `agent.subturn.result_delivered`
### Phase 4
@@ -464,13 +466,13 @@ V1 不做复杂自动发现。
最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是:
-- 以现有 `EventBus` 为只读观察面
+- 以 `pkg/events` runtime event bus 为只读观察面
- 以新增 `HookManager` 为同步拦截面
- 项目内通过 Go 对象直接挂载
- 项目外通过 `stdio JSON-RPC` 进程通信挂载
这样做有三个好处:
-- 和 `#1796` 一致,hooks 只是 EventBus 之上的消费层
+- 和 `#1796` 一致,hooks 只是 runtime event bus 之上的消费层
- 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统
- 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求
diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md
index dbc853d98..c41c3dae0 100644
--- a/docs/guides/configuration.zh.md
+++ b/docs/guides/configuration.zh.md
@@ -753,6 +753,42 @@ PicoClaw 按协议族路由提供商:
+### 事件日志
+
+PicoClaw 的 runtime events 会覆盖 agent、channel、gateway、message bus 和 MCP 等运行时组件。默认只打印 `agent.*` 事件,其他事件仍会发布到 runtime event bus,但不会进入日志。
+
+```json
+{
+ "events": {
+ "logging": {
+ "enabled": true,
+ "include": ["agent.*"],
+ "exclude": [],
+ "min_severity": "info",
+ "include_payload": false
+ }
+ }
+}
+```
+
+常用配置:
+
+```json
+{
+ "events": {
+ "logging": {
+ "include": ["*"],
+ "exclude": ["agent.llm.delta"],
+ "min_severity": "warn"
+ }
+ }
+}
+```
+
+`include` / `exclude` 支持精确事件名和 `gateway.*`、`channel.lifecycle.*` 这类模式。`include_payload` 默认关闭,避免把完整用户消息或工具参数写入日志;agent 事件会默认输出长度、计数、状态等摘要字段。
+
+更多字段说明和示例见 [Runtime Events 与事件日志](../architecture/runtime-events.zh.md)。
+
### 定时任务 / 提醒
PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。
@@ -775,6 +811,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设
| 主题 | 说明 |
| ---- | ---- |
| [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 |
+| [Runtime Events 与事件日志](../architecture/runtime-events.zh.md) | 统一运行时事件、日志过滤和调试配置 |
| [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
| [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
| [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
diff --git a/docs/guides/docker.fr.md b/docs/guides/docker.fr.md
index ed0d14cf3..e174298ac 100644
--- a/docs/guides/docker.fr.md
+++ b/docs/guides/docker.fr.md
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Mode Launcher (Console Web)
-L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.
+L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/docker.ja.md b/docs/guides/docker.ja.md
index 8fa5ae60c..19199aaac 100644
--- a/docs/guides/docker.ja.md
+++ b/docs/guides/docker.ja.md
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher モード (Web コンソール)
-`launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
+`launcher` イメージには 2 つのバイナリ(`picoclaw`、`picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/docker.md b/docs/guides/docker.md
index e017538f7..e2e472fbf 100644
--- a/docs/guides/docker.md
+++ b/docs/guides/docker.md
@@ -39,7 +39,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher Mode (Web Console)
-The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
+The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/docker.ms.md b/docs/guides/docker.ms.md
index 7adab6759..5a426cb99 100644
--- a/docs/guides/docker.ms.md
+++ b/docs/guides/docker.ms.md
@@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Mod Launcher (Konsol Web)
-Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
+Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/docker.pt-br.md b/docs/guides/docker.pt-br.md
index d7d55e753..ab71af8e6 100644
--- a/docs/guides/docker.pt-br.md
+++ b/docs/guides/docker.pt-br.md
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Modo Launcher (Console Web)
-A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.
+A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/docker.vi.md b/docs/guides/docker.vi.md
index 05f1b3d68..e91450bb0 100644
--- a/docs/guides/docker.vi.md
+++ b/docs/guides/docker.vi.md
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Chế Độ Launcher (Web Console)
-Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.
+Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/docker.zh.md b/docs/guides/docker.zh.md
index bed445751..855375d9c 100644
--- a/docs/guides/docker.zh.md
+++ b/docs/guides/docker.zh.md
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher 模式 (Web 控制台)
-`launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
+`launcher` 镜像包含两个二进制文件(`picoclaw`、`picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
diff --git a/docs/guides/providers.md b/docs/guides/providers.md
index d99d8c016..7b078373d 100644
--- a/docs/guides/providers.md
+++ b/docs/guides/providers.md
@@ -116,23 +116,47 @@ This design also enables **multi-agent support** with flexible provider selectio
#### `model_list` Entry Fields
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `model_name` | string | Yes | Unique name used to reference this model in agent config |
-| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
-| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
-| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
-| `api_base` | string | No | Override the default API endpoint URL |
-| `proxy` | string | No | HTTP proxy URL for this model entry |
-| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
-| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
-| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
-| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
-| `extra_body` | object | No | Additional fields to inject into every request body |
+| Field | Type | Required | Description |
+|-------|------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `model_name` | string | Yes | Unique name used to reference this model in agent config |
+| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
+| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
+| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
+| `api_base` | string | No | Override the default API endpoint URL |
+| `proxy` | string | No | HTTP proxy URL for this model entry |
+| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
+| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
+| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
+| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
+| `tool_schema_transform` | string | No | Optional compatibility transform for tool parameter schemas. Default: disabled. Supported values: `simple`. |
+| `extra_body` | object | No | Additional fields to inject into every request body |
| `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). |
-| `rpm` | int | No | Per-minute request rate limit |
-| `fallbacks` | string[] | No | Fallback model names for automatic failover |
-| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
+| `rpm` | int | No | Per-minute request rate limit |
+| `fallbacks` | string[] | No | Fallback model names for automatic failover |
+| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
+
+#### Tool Schema Compatibility
+
+By default, PicoClaw now forwards tool JSON Schemas unchanged.
+
+Some providers reject advanced JSON Schema features such as `$ref`, `$defs`, `anyOf`, `oneOf`, `allOf`, `pattern`, or numeric/string constraints inside tool declarations. For those models, you can opt into a compatibility transform per model entry with `tool_schema_transform`.
+
+Use `simple` when the upstream provider expects the conservative style function schema subset:
+
+```json
+{
+ "model_name": "gemini-2.5-flash-safe-tools",
+ "provider": "gemini",
+ "model": "gemini-2.5-flash",
+ "api_keys": ["your-gemini-key"],
+ "tool_schema_transform": "simple"
+}
+```
+
+Notes:
+
+- Default behavior is disabled. If you omit `tool_schema_transform`, PicoClaw sends the original tool schema.
+- The setting is per model entry, so you can enable it only for the providers that need it.
#### Provider / Model Resolution
@@ -393,10 +417,8 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
],
"agents": {
"defaults": {
- "model": {
- "primary": "qwen-main",
- "fallbacks": ["deepseek-backup", "gemini-backup"]
- }
+ "model_name": "qwen-main",
+ "model_fallbacks": ["deepseek-backup", "gemini-backup"]
}
}
}
diff --git a/docs/guides/providers.zh.md b/docs/guides/providers.zh.md
index 1302407a3..4bab65f6b 100644
--- a/docs/guides/providers.zh.md
+++ b/docs/guides/providers.zh.md
@@ -362,10 +362,8 @@ PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID
],
"agents": {
"defaults": {
- "model": {
- "primary": "qwen-main",
- "fallbacks": ["deepseek-backup", "gemini-backup"]
- }
+ "model_name": "qwen-main",
+ "model_fallbacks": ["deepseek-backup", "gemini-backup"]
}
}
}
diff --git a/docs/project/README.fr.md b/docs/project/README.fr.md
index 1e2f59bee..b02067d2a 100644
--- a/docs/project/README.fr.md
+++ b/docs/project/README.fr.md
@@ -292,24 +292,6 @@ Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des l
-### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH)
-
-Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique.
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**Pour commencer :**
-
-Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez !
-
-Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
diff --git a/docs/project/README.id.md b/docs/project/README.id.md
index 244e6e49a..49c64e74c 100644
--- a/docs/project/README.id.md
+++ b/docs/project/README.id.md
@@ -289,24 +289,6 @@ Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pa
-### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
-
-TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**Memulai:**
-
-Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
-
-Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
diff --git a/docs/project/README.it.md b/docs/project/README.it.md
index b3db6fece..0cf6cf8db 100644
--- a/docs/project/README.it.md
+++ b/docs/project/README.it.md
@@ -289,24 +289,6 @@ Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai
-### 💻 TUI Launcher (Consigliato per Headless / SSH)
-
-Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**Per iniziare:**
-
-Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
-
-Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
diff --git a/docs/project/README.ja.md b/docs/project/README.ja.md
index 66d06ba5e..6e3060688 100644
--- a/docs/project/README.ja.md
+++ b/docs/project/README.ja.md
@@ -289,24 +289,6 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
-### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨)
-
-TUI(Terminal UI)Launcher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**始め方:**
-
-TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット!
-
-TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
-
### 📱 Android
diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md
index cfc985688..dfefa67fe 100644
--- a/docs/project/README.ko.md
+++ b/docs/project/README.ko.md
@@ -289,24 +289,6 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을
-### 💻 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 어시스턴트로 바꿀 수 있습니다.
diff --git a/docs/project/README.ms.md b/docs/project/README.ms.md
index f8c9e95e7..73c428f11 100644
--- a/docs/project/README.ms.md
+++ b/docs/project/README.ms.md
@@ -286,24 +286,6 @@ Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada p
-### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH)
-
-Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain.
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**Memulakan:**
-
-Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang!
-
-Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
diff --git a/docs/project/README.pt-br.md b/docs/project/README.pt-br.md
index 56d4ddd63..74cb967de 100644
--- a/docs/project/README.pt-br.md
+++ b/docs/project/README.pt-br.md
@@ -289,24 +289,6 @@ Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamen
-### 💻 TUI Launcher (Recomendado para Headless / SSH)
-
-O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**Primeiros passos:**
-
-Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
-
-Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
diff --git a/docs/project/README.vi.md b/docs/project/README.vi.md
index 52a56796b..743069021 100644
--- a/docs/project/README.vi.md
+++ b/docs/project/README.vi.md
@@ -289,24 +289,6 @@ Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần
-### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
-
-TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
-
-```bash
-picoclaw-launcher-tui
-```
-
-
-
-
-
-**Bắt đầu:**
-
-Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
-
-Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
-
### 📱 Android
diff --git a/docs/project/README.zh.md b/docs/project/README.zh.md
index a4fc892bd..253bb84ed 100644
--- a/docs/project/README.zh.md
+++ b/docs/project/README.zh.md
@@ -289,24 +289,6 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联
-### 💻 TUI Launcher(推荐无头环境 / SSH)
-
-TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。
-
-```bash
-picoclaw-launcher-tui
-```
-
-