Files
clawgo/pkg/api/server.go
2026-03-07 22:09:52 +08:00

3186 lines
93 KiB
Go

package api
import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"time"
cfgpkg "clawgo/pkg/config"
"clawgo/pkg/nodes"
"clawgo/pkg/tools"
)
type Server struct {
addr string
token string
mgr *nodes.Manager
server *http.Server
gatewayVersion string
webuiVersion string
configPath string
workspacePath string
logFilePath string
onChat func(ctx context.Context, sessionKey, content string) (string, error)
onChatHistory func(sessionKey string) []map[string]interface{}
onConfigAfter func()
onCron func(action string, args map[string]interface{}) (interface{}, error)
onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)
onToolsCatalog func() interface{}
webUIDir string
ekgCacheMu sync.Mutex
ekgCachePath string
ekgCacheStamp time.Time
ekgCacheSize int64
ekgCacheRows []map[string]interface{}
}
func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server {
addr := strings.TrimSpace(host)
if addr == "" {
addr = "0.0.0.0"
}
if port <= 0 {
port = 7788
}
return &Server{addr: fmt.Sprintf("%s:%d", addr, port), token: strings.TrimSpace(token), mgr: mgr}
}
func (s *Server) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) }
func (s *Server) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) }
func (s *Server) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) }
func (s *Server) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) {
s.onChat = fn
}
func (s *Server) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) {
s.onChatHistory = fn
}
func (s *Server) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn }
func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) {
s.onCron = fn
}
func (s *Server) SetSubagentHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) {
s.onSubagents = fn
}
func (s *Server) SetToolsCatalogHandler(fn func() interface{}) { s.onToolsCatalog = fn }
func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) }
func (s *Server) SetWebUIVersion(v string) { s.webuiVersion = strings.TrimSpace(v) }
func (s *Server) Start(ctx context.Context) error {
if s.mgr == nil {
return nil
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/nodes/register", s.handleRegister)
mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat)
mux.HandleFunc("/webui", s.handleWebUI)
mux.HandleFunc("/webui/", s.handleWebUIAsset)
mux.HandleFunc("/webui/api/config", s.handleWebUIConfig)
mux.HandleFunc("/webui/api/chat", s.handleWebUIChat)
mux.HandleFunc("/webui/api/chat/history", s.handleWebUIChatHistory)
mux.HandleFunc("/webui/api/chat/stream", s.handleWebUIChatStream)
mux.HandleFunc("/webui/api/version", s.handleWebUIVersion)
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes)
mux.HandleFunc("/webui/api/cron", s.handleWebUICron)
mux.HandleFunc("/webui/api/skills", s.handleWebUISkills)
mux.HandleFunc("/webui/api/sessions", s.handleWebUISessions)
mux.HandleFunc("/webui/api/memory", s.handleWebUIMemory)
mux.HandleFunc("/webui/api/subagent_profiles", s.handleWebUISubagentProfiles)
mux.HandleFunc("/webui/api/subagents_runtime", s.handleWebUISubagentsRuntime)
mux.HandleFunc("/webui/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups)
mux.HandleFunc("/webui/api/tools", s.handleWebUITools)
mux.HandleFunc("/webui/api/mcp/install", s.handleWebUIMCPInstall)
mux.HandleFunc("/webui/api/task_audit", s.handleWebUITaskAudit)
mux.HandleFunc("/webui/api/task_queue", s.handleWebUITaskQueue)
mux.HandleFunc("/webui/api/ekg_stats", s.handleWebUIEKGStats)
mux.HandleFunc("/webui/api/exec_approvals", s.handleWebUIExecApprovals)
mux.HandleFunc("/webui/api/logs/stream", s.handleWebUILogsStream)
mux.HandleFunc("/webui/api/logs/recent", s.handleWebUILogsRecent)
s.server = &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.server.Shutdown(shutdownCtx)
}()
go func() { _ = s.server.ListenAndServe() }()
return nil
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var n nodes.NodeInfo
if err := json.NewDecoder(r.Body).Decode(&n); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if strings.TrimSpace(n.ID) == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
s.mgr.Upsert(n)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": n.ID})
}
func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var body struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
n, ok := s.mgr.Get(body.ID)
if !ok {
http.Error(w, "node not found", http.StatusNotFound)
return
}
n.LastSeenAt = time.Now().UTC()
n.Online = true
s.mgr.Upsert(n)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": body.ID})
}
func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s.token != "" {
http.SetCookie(w, &http.Cookie{
Name: "clawgo_webui_token",
Value: s.token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
}
if s.tryServeWebUIDist(w, r, "/webui/index.html") {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(webUIHTML))
}
func (s *Server) handleWebUIAsset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s.tryServeWebUIDist(w, r, r.URL.Path) {
return
}
// SPA fallback
if s.tryServeWebUIDist(w, r, "/webui/index.html") {
return
}
http.NotFound(w, r)
}
func (s *Server) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPath string) bool {
dir := strings.TrimSpace(s.webUIDir)
if dir == "" {
return false
}
p := strings.TrimPrefix(reqPath, "/webui/")
if reqPath == "/webui" || reqPath == "/webui/" || reqPath == "/webui/index.html" {
p = "index.html"
}
p = filepath.Clean(strings.TrimPrefix(p, "/"))
if strings.HasPrefix(p, "..") {
return false
}
full := filepath.Join(dir, p)
fi, err := os.Stat(full)
if err != nil || fi.IsDir() {
return false
}
http.ServeFile(w, r, full)
return true
}
func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if strings.TrimSpace(s.configPath) == "" {
http.Error(w, "config path not set", http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
b, err := os.ReadFile(s.configPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cfgDefault := cfgpkg.DefaultConfig()
defBytes, _ := json.Marshal(cfgDefault)
var merged map[string]interface{}
_ = json.Unmarshal(defBytes, &merged)
var loaded map[string]interface{}
if err := json.Unmarshal(b, &loaded); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
merged = mergeJSONMap(merged, loaded)
if r.URL.Query().Get("include_hot_reload_fields") == "1" || strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("mode")), "hot") {
w.Header().Set("Content-Type", "application/json")
info := hotReloadFieldInfo()
paths := make([]string, 0, len(info))
for _, it := range info {
if p, ok := it["path"].(string); ok && strings.TrimSpace(p) != "" {
paths = append(paths, p)
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"config": merged,
"hot_reload_fields": paths,
"hot_reload_field_details": info,
})
return
}
w.Header().Set("Content-Type", "application/json")
out, _ := json.MarshalIndent(merged, "", " ")
_, _ = w.Write(out)
case http.MethodPost:
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
confirmRisky, _ := body["confirm_risky"].(bool)
delete(body, "confirm_risky")
oldCfgRaw, _ := os.ReadFile(s.configPath)
var oldMap map[string]interface{}
_ = json.Unmarshal(oldCfgRaw, &oldMap)
riskyPaths := []string{
"channels.telegram.token",
"channels.telegram.allow_from",
"channels.telegram.allow_chats",
"providers.proxy.base_url",
"providers.proxy.api_key",
"gateway.token",
"gateway.port",
}
changedRisky := make([]string, 0)
for _, p := range riskyPaths {
if fmt.Sprintf("%v", getPathValue(oldMap, p)) != fmt.Sprintf("%v", getPathValue(body, p)) {
changedRisky = append(changedRisky, p)
}
}
if len(changedRisky) > 0 && !confirmRisky {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"error": "risky fields changed; confirmation required",
"requires_confirm": true,
"changed_fields": changedRisky,
})
return
}
candidate, err := json.Marshal(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cfg := cfgpkg.DefaultConfig()
dec := json.NewDecoder(bytes.NewReader(candidate))
dec.DisallowUnknownFields()
if err := dec.Decode(cfg); err != nil {
http.Error(w, "config schema validation failed: "+err.Error(), http.StatusBadRequest)
return
}
if errs := cfgpkg.Validate(cfg); len(errs) > 0 {
list := make([]string, 0, len(errs))
for _, e := range errs {
list = append(list, e.Error())
}
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "config validation failed", "details": list})
return
}
b, err := json.MarshalIndent(body, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tmp := s.configPath + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.Rename(tmp, s.configPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if s.onConfigAfter != nil {
s.onConfigAfter()
} else {
_ = requestSelfReloadSignal()
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reloaded": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func mergeJSONMap(base, override map[string]interface{}) map[string]interface{} {
if base == nil {
base = map[string]interface{}{}
}
for k, v := range override {
if bv, ok := base[k]; ok {
bm, ok1 := bv.(map[string]interface{})
om, ok2 := v.(map[string]interface{})
if ok1 && ok2 {
base[k] = mergeJSONMap(bm, om)
continue
}
}
base[k] = v
}
return base
}
func getPathValue(m map[string]interface{}, path string) interface{} {
if m == nil || strings.TrimSpace(path) == "" {
return nil
}
parts := strings.Split(path, ".")
var cur interface{} = m
for _, p := range parts {
node, ok := cur.(map[string]interface{})
if !ok {
return nil
}
cur = node[p]
}
return cur
}
func (s *Server) handleWebUIUpload(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f, h, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer f.Close()
dir := filepath.Join(os.TempDir(), "clawgo_webui_uploads")
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(h.Filename))
path := filepath.Join(dir, name)
out, err := os.Create(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := io.Copy(out, f); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "path": path, "name": h.Filename})
}
func (s *Server) handleWebUIChat(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.onChat == nil {
http.Error(w, "chat handler not configured", http.StatusInternalServerError)
return
}
var body struct {
Session string `json:"session"`
Message string `json:"message"`
Media string `json:"media"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
session := body.Session
if session == "" {
session = r.URL.Query().Get("session")
}
if session == "" {
session = "main"
}
prompt := body.Message
if body.Media != "" {
if prompt != "" {
prompt += "\n"
}
prompt += "[file: " + body.Media + "]"
}
resp, err := s.onChat(r.Context(), session, prompt)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session})
}
func (s *Server) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
session := r.URL.Query().Get("session")
if session == "" {
session = "main"
}
if s.onChatHistory == nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "session": session, "messages": []interface{}{}})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)})
}
func (s *Server) handleWebUIChatStream(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.onChat == nil {
http.Error(w, "chat handler not configured", http.StatusInternalServerError)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
var body struct {
Session string `json:"session"`
Message string `json:"message"`
Media string `json:"media"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
session := body.Session
if session == "" {
session = r.URL.Query().Get("session")
}
if session == "" {
session = "main"
}
prompt := body.Message
if body.Media != "" {
if prompt != "" {
prompt += "\n"
}
prompt += "[file: " + body.Media + "]"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
resp, err := s.onChat(r.Context(), session, prompt)
if err != nil {
_, _ = w.Write([]byte("Error: " + err.Error()))
flusher.Flush()
return
}
chunk := 180
for i := 0; i < len(resp); i += chunk {
end := i + chunk
if end > len(resp) {
end = len(resp)
}
_, _ = w.Write([]byte(resp[i:end]))
flusher.Flush()
}
}
func (s *Server) handleWebUIVersion(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
"webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
})
}
func (s *Server) handleWebUITools(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
toolsList := []map[string]interface{}{}
if s.onToolsCatalog != nil {
if items, ok := s.onToolsCatalog().([]map[string]interface{}); ok && items != nil {
toolsList = items
}
}
mcpItems := make([]map[string]interface{}, 0)
for _, item := range toolsList {
if strings.TrimSpace(fmt.Sprint(item["source"])) == "mcp" {
mcpItems = append(mcpItems, item)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tools": toolsList,
"mcp_tools": mcpItems,
})
}
func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Package string `json:"package"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
pkgName := strings.TrimSpace(body.Package)
if pkgName == "" {
http.Error(w, "package required", http.StatusBadRequest)
return
}
out, binName, binPath, err := ensureMCPPackageInstalled(r.Context(), pkgName)
if err != nil {
msg := err.Error()
if strings.TrimSpace(out) != "" {
msg = strings.TrimSpace(out) + "\n" + msg
}
http.Error(w, strings.TrimSpace(msg), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"package": pkgName,
"output": out,
"bin_name": binName,
"bin_path": binPath,
})
}
func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
list := []nodes.NodeInfo{}
if s.mgr != nil {
list = s.mgr.List()
}
host, _ := os.Hostname()
local := nodes.NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: time.Now(), Online: true}
if strings.TrimSpace(host) != "" {
local.Name = host
}
if ip := detectLocalIP(); ip != "" {
local.Endpoint = ip
}
hostLower := strings.ToLower(strings.TrimSpace(host))
matched := false
for i := range list {
id := strings.ToLower(strings.TrimSpace(list[i].ID))
name := strings.ToLower(strings.TrimSpace(list[i].Name))
if id == "local" || name == "local" || (hostLower != "" && name == hostLower) {
// Always keep local node green/alive with latest ip+version
list[i].ID = "local"
list[i].Online = true
list[i].Version = local.Version
if strings.TrimSpace(local.Endpoint) != "" {
list[i].Endpoint = local.Endpoint
}
if strings.TrimSpace(local.Name) != "" {
list[i].Name = local.Name
}
list[i].LastSeenAt = time.Now()
matched = true
break
}
}
if !matched {
list = append([]nodes.NodeInfo{local}, list...)
}
trees := s.buildNodeAgentTrees(r.Context(), list)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list, "trees": trees})
case http.MethodPost:
var body struct {
Action string `json:"action"`
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
action := strings.ToLower(body.Action)
if action != "delete" {
http.Error(w, "unsupported action", http.StatusBadRequest)
return
}
if s.mgr == nil {
http.Error(w, "nodes manager unavailable", http.StatusInternalServerError)
return
}
id := body.ID
ok := s.mgr.Remove(id)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": ok, "id": id})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) buildNodeAgentTrees(ctx context.Context, nodeList []nodes.NodeInfo) []map[string]interface{} {
trees := make([]map[string]interface{}, 0, len(nodeList))
localRegistry := s.fetchRegistryItems(ctx)
for _, node := range nodeList {
nodeID := strings.TrimSpace(node.ID)
items := []map[string]interface{}{}
source := "unavailable"
readonly := true
if nodeID == "local" {
items = localRegistry
source = "local_runtime"
readonly = false
} else if remoteItems, err := s.fetchRemoteNodeRegistry(ctx, node); err == nil {
items = remoteItems
source = "remote_webui"
}
trees = append(trees, map[string]interface{}{
"node_id": nodeID,
"node_name": fallbackNodeName(node),
"online": node.Online,
"source": source,
"readonly": readonly,
"root": buildAgentTreeRoot(nodeID, items),
})
}
return trees
}
func (s *Server) fetchRegistryItems(ctx context.Context) []map[string]interface{} {
if s == nil || s.onSubagents == nil {
return nil
}
result, err := s.onSubagents(ctx, "registry", nil)
if err != nil {
return nil
}
payload, ok := result.(map[string]interface{})
if !ok {
return nil
}
rawItems, ok := payload["items"].([]map[string]interface{})
if ok {
return rawItems
}
list, ok := payload["items"].([]interface{})
if !ok {
return nil
}
items := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
row, ok := item.(map[string]interface{})
if ok {
items = append(items, row)
}
}
return items
}
func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInfo) ([]map[string]interface{}, error) {
baseURL := nodeWebUIBaseURL(node)
if baseURL == "" {
return nil, fmt.Errorf("node %s endpoint missing", strings.TrimSpace(node.ID))
}
reqURL := baseURL + "/webui/api/subagents_runtime?action=registry"
if tok := strings.TrimSpace(node.Token); tok != "" {
reqURL += "&token=" + url.QueryEscape(tok)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("remote status %d", resp.StatusCode)
}
var payload struct {
OK bool `json:"ok"`
Result struct {
Items []map[string]interface{} `json:"items"`
} `json:"result"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return nil, err
}
return payload.Result.Items, nil
}
func nodeWebUIBaseURL(node nodes.NodeInfo) string {
endpoint := strings.TrimSpace(node.Endpoint)
if endpoint == "" || strings.EqualFold(endpoint, "gateway") {
return ""
}
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
return strings.TrimRight(endpoint, "/")
}
return "http://" + strings.TrimRight(endpoint, "/")
}
func fallbackNodeName(node nodes.NodeInfo) string {
if name := strings.TrimSpace(node.Name); name != "" {
return name
}
if id := strings.TrimSpace(node.ID); id != "" {
return id
}
return "node"
}
func buildAgentTreeRoot(nodeID string, items []map[string]interface{}) map[string]interface{} {
rootID := "main"
for _, item := range items {
if strings.TrimSpace(stringFromMap(item, "type")) == "router" && strings.TrimSpace(stringFromMap(item, "agent_id")) != "" {
rootID = strings.TrimSpace(stringFromMap(item, "agent_id"))
break
}
}
nodesByID := make(map[string]map[string]interface{}, len(items)+1)
for _, item := range items {
id := strings.TrimSpace(stringFromMap(item, "agent_id"))
if id == "" {
continue
}
nodesByID[id] = map[string]interface{}{
"agent_id": id,
"display_name": stringFromMap(item, "display_name"),
"role": stringFromMap(item, "role"),
"type": stringFromMap(item, "type"),
"transport": fallbackString(stringFromMap(item, "transport"), "local"),
"managed_by": stringFromMap(item, "managed_by"),
"node_id": stringFromMap(item, "node_id"),
"parent_agent_id": stringFromMap(item, "parent_agent_id"),
"enabled": boolFromMap(item, "enabled"),
"children": []map[string]interface{}{},
}
}
root, ok := nodesByID[rootID]
if !ok {
root = map[string]interface{}{
"agent_id": rootID,
"display_name": "Main Agent",
"role": "orchestrator",
"type": "router",
"transport": "local",
"managed_by": "derived",
"enabled": true,
"children": []map[string]interface{}{},
}
nodesByID[rootID] = root
}
for _, item := range items {
id := strings.TrimSpace(stringFromMap(item, "agent_id"))
if id == "" || id == rootID {
continue
}
parentID := strings.TrimSpace(stringFromMap(item, "parent_agent_id"))
if parentID == "" {
parentID = rootID
}
parent, ok := nodesByID[parentID]
if !ok {
parent = root
}
parent["children"] = append(parent["children"].([]map[string]interface{}), nodesByID[id])
}
return map[string]interface{}{
"node_id": nodeID,
"agent_id": root["agent_id"],
"root": root,
"child_cnt": len(root["children"].([]map[string]interface{})),
}
}
func stringFromMap(item map[string]interface{}, key string) string {
if item == nil {
return ""
}
v, _ := item[key].(string)
return strings.TrimSpace(v)
}
func boolFromMap(item map[string]interface{}, key string) bool {
if item == nil {
return false
}
v, _ := item[key].(bool)
return v
}
func fallbackString(value, fallback string) string {
value = strings.TrimSpace(value)
if value != "" {
return value
}
return strings.TrimSpace(fallback)
}
func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s.onCron == nil {
http.Error(w, "cron handler not configured", http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
id := strings.TrimSpace(r.URL.Query().Get("id"))
action := "list"
if id != "" {
action = "get"
}
res, err := s.onCron(action, map[string]interface{}{"id": id})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if action == "list" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "jobs": normalizeCronJobs(res)})
} else {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "job": normalizeCronJob(res)})
}
case http.MethodPost:
args := map[string]interface{}{}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&args)
}
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
args["id"] = id
}
action := "create"
if a, ok := args["action"].(string); ok && strings.TrimSpace(a) != "" {
action = strings.ToLower(strings.TrimSpace(a))
}
res, err := s.onCron(action, args)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": normalizeCronJob(res)})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
skillsDir := filepath.Join(s.workspacePath, "skills")
if strings.TrimSpace(skillsDir) == "" {
http.Error(w, "workspace not configured", http.StatusInternalServerError)
return
}
_ = os.MkdirAll(skillsDir, 0755)
resolveSkillPath := func(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("name required")
}
cands := []string{
filepath.Join(skillsDir, name),
filepath.Join(skillsDir, name+".disabled"),
filepath.Join("/root/clawgo/workspace/skills", name),
filepath.Join("/root/clawgo/workspace/skills", name+".disabled"),
}
for _, p := range cands {
if st, err := os.Stat(p); err == nil && st.IsDir() {
return p, nil
}
}
return "", fmt.Errorf("skill not found: %s", name)
}
switch r.Method {
case http.MethodGet:
clawhubPath := strings.TrimSpace(resolveClawHubBinary(r.Context()))
clawhubInstalled := clawhubPath != ""
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
skillPath, err := resolveSkillPath(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if strings.TrimSpace(r.URL.Query().Get("files")) == "1" {
var files []string
_ = filepath.WalkDir(skillPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(skillPath, path)
if strings.HasPrefix(rel, "..") {
return nil
}
files = append(files, filepath.ToSlash(rel))
return nil
})
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": id, "files": files})
return
}
if f := strings.TrimSpace(r.URL.Query().Get("file")); f != "" {
clean := filepath.Clean(f)
if strings.HasPrefix(clean, "..") {
http.Error(w, "invalid file path", http.StatusBadRequest)
return
}
full := filepath.Join(skillPath, clean)
b, err := os.ReadFile(full)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": id, "file": filepath.ToSlash(clean), "content": string(b)})
return
}
}
type skillItem struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tools []string `json:"tools"`
SystemPrompt string `json:"system_prompt,omitempty"`
Enabled bool `json:"enabled"`
UpdateChecked bool `json:"update_checked"`
RemoteFound bool `json:"remote_found,omitempty"`
RemoteVersion string `json:"remote_version,omitempty"`
CheckError string `json:"check_error,omitempty"`
Source string `json:"source,omitempty"`
}
candDirs := []string{skillsDir, filepath.Join("/root/clawgo/workspace", "skills")}
seenDirs := map[string]struct{}{}
seenSkills := map[string]struct{}{}
items := make([]skillItem, 0)
// Default off to avoid hammering clawhub search API on each UI refresh.
// Enable explicitly with ?check_updates=1 when needed.
checkUpdates := strings.TrimSpace(r.URL.Query().Get("check_updates")) == "1"
for _, dir := range candDirs {
dir = strings.TrimSpace(dir)
if dir == "" {
continue
}
if _, ok := seenDirs[dir]; ok {
continue
}
seenDirs[dir] = struct{}{}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
enabled := !strings.HasSuffix(name, ".disabled")
baseName := strings.TrimSuffix(name, ".disabled")
if _, ok := seenSkills[baseName]; ok {
continue
}
seenSkills[baseName] = struct{}{}
desc, tools, sys := readSkillMeta(filepath.Join(dir, name, "SKILL.md"))
if desc == "" || len(tools) == 0 || sys == "" {
d2, t2, s2 := readSkillMeta(filepath.Join(dir, baseName, "SKILL.md"))
if desc == "" {
desc = d2
}
if len(tools) == 0 {
tools = t2
}
if sys == "" {
sys = s2
}
}
if tools == nil {
tools = []string{}
}
it := skillItem{ID: baseName, Name: baseName, Description: desc, Tools: tools, SystemPrompt: sys, Enabled: enabled, UpdateChecked: checkUpdates && clawhubInstalled, Source: dir}
if checkUpdates && clawhubInstalled {
found, version, checkErr := queryClawHubSkillVersion(r.Context(), baseName)
it.RemoteFound = found
it.RemoteVersion = version
if checkErr != nil {
it.CheckError = checkErr.Error()
}
}
items = append(items, it)
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"skills": items,
"source": "clawhub",
"clawhub_installed": clawhubInstalled,
"clawhub_path": clawhubPath,
})
case http.MethodPost:
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
if strings.Contains(ct, "multipart/form-data") {
imported, err := importSkillArchiveFromMultipart(r, skillsDir)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "imported": imported})
return
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
action, _ := body["action"].(string)
action = strings.ToLower(strings.TrimSpace(action))
if action == "install_clawhub" {
output, err := ensureClawHubReady(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"output": output,
"installed": true,
"clawhub_path": resolveClawHubBinary(r.Context()),
})
return
}
id, _ := body["id"].(string)
name, _ := body["name"].(string)
if strings.TrimSpace(name) == "" {
name = id
}
name = strings.TrimSpace(name)
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
enabledPath := filepath.Join(skillsDir, name)
disabledPath := enabledPath + ".disabled"
switch action {
case "install":
clawhubPath := strings.TrimSpace(resolveClawHubBinary(r.Context()))
if clawhubPath == "" {
http.Error(w, "clawhub is not installed. please install clawhub first.", http.StatusPreconditionFailed)
return
}
ignoreSuspicious := false
switch v := body["ignore_suspicious"].(type) {
case bool:
ignoreSuspicious = v
case string:
ignoreSuspicious = strings.EqualFold(strings.TrimSpace(v), "true") || strings.TrimSpace(v) == "1"
}
args := []string{"install", name}
if ignoreSuspicious {
args = append(args, "--force")
}
cmd := exec.CommandContext(r.Context(), clawhubPath, args...)
cmd.Dir = strings.TrimSpace(s.workspacePath)
out, err := cmd.CombinedOutput()
if err != nil {
outText := string(out)
lower := strings.ToLower(outText)
if strings.Contains(lower, "rate limit exceeded") || strings.Contains(lower, "too many requests") {
http.Error(w, fmt.Sprintf("clawhub rate limit exceeded. please retry later or configure auth token.\n%s", outText), http.StatusTooManyRequests)
return
}
http.Error(w, fmt.Sprintf("install failed: %v\n%s", err, outText), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "installed": name, "output": string(out)})
case "enable":
if _, err := os.Stat(disabledPath); err == nil {
if err := os.Rename(disabledPath, enabledPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
case "disable":
if _, err := os.Stat(enabledPath); err == nil {
if err := os.Rename(enabledPath, disabledPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
case "write_file":
skillPath, err := resolveSkillPath(name)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
filePath, _ := body["file"].(string)
clean := filepath.Clean(strings.TrimSpace(filePath))
if clean == "" || strings.HasPrefix(clean, "..") {
http.Error(w, "invalid file path", http.StatusBadRequest)
return
}
content, _ := body["content"].(string)
full := filepath.Join(skillPath, clean)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.WriteFile(full, []byte(content), 0644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "name": name, "file": filepath.ToSlash(clean)})
case "create", "update":
desc, _ := body["description"].(string)
sys, _ := body["system_prompt"].(string)
var toolsList []string
if arr, ok := body["tools"].([]interface{}); ok {
for _, v := range arr {
if sv, ok := v.(string); ok && strings.TrimSpace(sv) != "" {
toolsList = append(toolsList, strings.TrimSpace(sv))
}
}
}
if action == "create" {
if _, err := os.Stat(enabledPath); err == nil {
http.Error(w, "skill already exists", http.StatusBadRequest)
return
}
}
if err := os.MkdirAll(filepath.Join(enabledPath, "scripts"), 0755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
skillMD := buildSkillMarkdown(name, desc, toolsList, sys)
if err := os.WriteFile(filepath.Join(enabledPath, "SKILL.md"), []byte(skillMD), 0644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
default:
http.Error(w, "unsupported action", http.StatusBadRequest)
}
case http.MethodDelete:
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
pathA := filepath.Join(skillsDir, id)
pathB := pathA + ".disabled"
deleted := false
if err := os.RemoveAll(pathA); err == nil {
deleted = true
}
if err := os.RemoveAll(pathB); err == nil {
deleted = true
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": deleted, "id": id})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func buildSkillMarkdown(name, desc string, tools []string, systemPrompt string) string {
if desc == "" {
desc = "No description provided."
}
if len(tools) == 0 {
tools = []string{""}
}
toolLines := make([]string, 0, len(tools))
for _, t := range tools {
if t == "" {
continue
}
toolLines = append(toolLines, "- "+t)
}
if len(toolLines) == 0 {
toolLines = []string{"- (none)"}
}
return fmt.Sprintf(`---
name: %s
description: %s
---
# %s
%s
## Tools
%s
## System Prompt
%s
`, name, desc, name, desc, strings.Join(toolLines, "\n"), systemPrompt)
}
func readSkillMeta(path string) (desc string, tools []string, systemPrompt string) {
b, err := os.ReadFile(path)
if err != nil {
return "", []string{}, ""
}
s := string(b)
reDesc := regexp.MustCompile(`(?m)^description:\s*(.+)$`)
reTools := regexp.MustCompile(`(?m)^##\s*Tools\s*$`)
rePrompt := regexp.MustCompile(`(?m)^##\s*System Prompt\s*$`)
if m := reDesc.FindStringSubmatch(s); len(m) > 1 {
desc = m[1]
}
if loc := reTools.FindStringIndex(s); loc != nil {
block := s[loc[1]:]
if p := rePrompt.FindStringIndex(block); p != nil {
block = block[:p[0]]
}
for _, line := range strings.Split(block, "\n") {
line = strings.TrimPrefix(line, "-")
if line != "" {
tools = append(tools, line)
}
}
}
if tools == nil {
tools = []string{}
}
if loc := rePrompt.FindStringIndex(s); loc != nil {
systemPrompt = s[loc[1]:]
}
return
}
func gatewayBuildVersion() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi != nil {
ver := strings.TrimSpace(bi.Main.Version)
rev := ""
for _, s := range bi.Settings {
if s.Key == "vcs.revision" {
rev = s.Value
break
}
}
if len(rev) > 8 {
rev = rev[:8]
}
if ver == "" || ver == "(devel)" {
ver = "devel"
}
if rev != "" {
return ver + "+" + rev
}
return ver
}
return "unknown"
}
func detectWebUIVersion(webUIDir string) string {
_ = webUIDir
return "dev"
}
func firstNonEmptyString(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func detectLocalIP() string {
ifaces, err := net.Interfaces()
if err == nil {
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := iface.Addrs()
for _, a := range addrs {
var ip net.IP
switch v := a.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
ip = ip.To4()
if ip == nil {
continue
}
return ip.String()
}
}
}
// Fallback: detect outbound source IP.
conn, err := net.Dial("udp", "8.8.8.8:80")
if err == nil {
defer conn.Close()
if ua, ok := conn.LocalAddr().(*net.UDPAddr); ok && ua.IP != nil {
if ip := ua.IP.To4(); ip != nil {
return ip.String()
}
}
}
return ""
}
func normalizeCronJob(v interface{}) map[string]interface{} {
if v == nil {
return map[string]interface{}{}
}
b, err := json.Marshal(v)
if err != nil {
return map[string]interface{}{"raw": fmt.Sprintf("%v", v)}
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return map[string]interface{}{"raw": string(b)}
}
out := map[string]interface{}{}
for k, val := range m {
out[k] = val
}
if sch, ok := m["schedule"].(map[string]interface{}); ok {
kind, _ := sch["kind"].(string)
if expr, ok := sch["expr"].(string); ok && expr != "" {
out["expr"] = expr
} else if strings.EqualFold(strings.TrimSpace(kind), "every") {
if every, ok := sch["everyMs"].(float64); ok && every > 0 {
out["expr"] = fmt.Sprintf("@every %s", (time.Duration(int64(every)) * time.Millisecond).String())
}
} else if strings.EqualFold(strings.TrimSpace(kind), "at") {
if at, ok := sch["atMs"].(float64); ok && at > 0 {
out["expr"] = time.UnixMilli(int64(at)).Format(time.RFC3339)
}
}
}
if payload, ok := m["payload"].(map[string]interface{}); ok {
if msg, ok := payload["message"]; ok {
out["message"] = msg
}
if d, ok := payload["deliver"]; ok {
out["deliver"] = d
}
if c, ok := payload["channel"]; ok {
out["channel"] = c
}
if to, ok := payload["to"]; ok {
out["to"] = to
}
}
return out
}
func normalizeCronJobs(v interface{}) []map[string]interface{} {
b, err := json.Marshal(v)
if err != nil {
return []map[string]interface{}{}
}
var arr []interface{}
if err := json.Unmarshal(b, &arr); err != nil {
return []map[string]interface{}{}
}
out := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
out = append(out, normalizeCronJob(it))
}
return out
}
func queryClawHubSkillVersion(ctx context.Context, skill string) (found bool, version string, err error) {
if skill == "" {
return false, "", fmt.Errorf("skill empty")
}
clawhubPath := strings.TrimSpace(resolveClawHubBinary(ctx))
if clawhubPath == "" {
return false, "", fmt.Errorf("clawhub not installed")
}
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, clawhubPath, "search", skill, "--json")
out, runErr := cmd.Output()
if runErr != nil {
return false, "", runErr
}
var payload interface{}
if err := json.Unmarshal(out, &payload); err != nil {
return false, "", err
}
lowerSkill := strings.ToLower(skill)
var walk func(v interface{}) (bool, string)
walk = func(v interface{}) (bool, string) {
switch t := v.(type) {
case map[string]interface{}:
name := strings.ToLower(strings.TrimSpace(anyToString(t["name"])))
if name == "" {
name = strings.ToLower(strings.TrimSpace(anyToString(t["id"])))
}
if name == lowerSkill || strings.Contains(name, lowerSkill) {
ver := anyToString(t["version"])
if ver == "" {
ver = anyToString(t["latest_version"])
}
return true, ver
}
for _, vv := range t {
if ok, ver := walk(vv); ok {
return ok, ver
}
}
case []interface{}:
for _, vv := range t {
if ok, ver := walk(vv); ok {
return ok, ver
}
}
}
return false, ""
}
ok, ver := walk(payload)
return ok, ver, nil
}
func resolveClawHubBinary(ctx context.Context) string {
if p, err := exec.LookPath("clawhub"); err == nil {
return p
}
prefix := strings.TrimSpace(npmGlobalPrefix(ctx))
if prefix != "" {
cand := filepath.Join(prefix, "bin", "clawhub")
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
cands := []string{
"/usr/local/bin/clawhub",
"/opt/homebrew/bin/clawhub",
filepath.Join(os.Getenv("HOME"), ".npm-global", "bin", "clawhub"),
}
for _, cand := range cands {
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
return ""
}
func npmGlobalPrefix(ctx context.Context) string {
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "npm", "config", "get", "prefix").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func runInstallCommand(ctx context.Context, cmdline string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(cctx, "sh", "-c", cmdline)
out, err := cmd.CombinedOutput()
msg := strings.TrimSpace(string(out))
if err != nil {
if msg == "" {
msg = err.Error()
}
return msg, fmt.Errorf("%s", msg)
}
return msg, nil
}
func ensureNodeRuntime(ctx context.Context) (string, error) {
if nodePath, err := exec.LookPath("node"); err == nil {
if _, err := exec.LookPath("npm"); err == nil {
if major, verr := detectNodeMajor(ctx, nodePath); verr == nil && major == 22 {
return "node@22 and npm already installed", nil
}
}
}
var output []string
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("brew"); err != nil {
return strings.Join(output, "\n"), fmt.Errorf("nodejs/npm missing and Homebrew not found; please install Homebrew then retry")
}
out, err := runInstallCommand(ctx, "brew install node@22 && brew link --overwrite --force node@22")
if out != "" {
output = append(output, out)
}
if err != nil {
return strings.Join(output, "\n"), err
}
case "linux":
var out string
var err error
switch {
case commandExists("apt-get"):
if commandExists("curl") {
out, err = runInstallCommand(ctx, "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs")
} else if commandExists("wget") {
out, err = runInstallCommand(ctx, "wget -qO- https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs")
} else {
err = fmt.Errorf("missing curl/wget required for NodeSource setup_22.x")
}
case commandExists("dnf"):
if commandExists("curl") {
out, err = runInstallCommand(ctx, "curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && dnf install -y nodejs")
} else if commandExists("wget") {
out, err = runInstallCommand(ctx, "wget -qO- https://rpm.nodesource.com/setup_22.x | bash - && dnf install -y nodejs")
} else {
err = fmt.Errorf("missing curl/wget required for NodeSource setup_22.x")
}
case commandExists("yum"):
if commandExists("curl") {
out, err = runInstallCommand(ctx, "curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && yum install -y nodejs")
} else if commandExists("wget") {
out, err = runInstallCommand(ctx, "wget -qO- https://rpm.nodesource.com/setup_22.x | bash - && yum install -y nodejs")
} else {
err = fmt.Errorf("missing curl/wget required for NodeSource setup_22.x")
}
case commandExists("pacman"):
out, err = runInstallCommand(ctx, "pacman -Sy --noconfirm nodejs npm")
case commandExists("apk"):
out, err = runInstallCommand(ctx, "apk add --no-cache nodejs npm")
default:
return strings.Join(output, "\n"), fmt.Errorf("nodejs/npm missing and no supported package manager found")
}
if out != "" {
output = append(output, out)
}
if err != nil {
return strings.Join(output, "\n"), err
}
default:
return strings.Join(output, "\n"), fmt.Errorf("unsupported OS for auto install: %s", runtime.GOOS)
}
if _, err := exec.LookPath("node"); err != nil {
return strings.Join(output, "\n"), fmt.Errorf("node installation completed but `node` still not found in PATH")
}
if _, err := exec.LookPath("npm"); err != nil {
return strings.Join(output, "\n"), fmt.Errorf("node installation completed but `npm` still not found in PATH")
}
nodePath, _ := exec.LookPath("node")
major, err := detectNodeMajor(ctx, nodePath)
if err != nil {
return strings.Join(output, "\n"), fmt.Errorf("failed to detect node major version: %w", err)
}
if major != 22 {
return strings.Join(output, "\n"), fmt.Errorf("node version is %d, expected 22", major)
}
output = append(output, "node@22/npm installed")
return strings.Join(output, "\n"), nil
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func detectNodeMajor(ctx context.Context, nodePath string) (int, error) {
nodePath = strings.TrimSpace(nodePath)
if nodePath == "" {
return 0, fmt.Errorf("node path empty")
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, nodePath, "-p", "process.versions.node.split('.')[0]").Output()
if err != nil {
return 0, err
}
majorStr := strings.TrimSpace(string(out))
if majorStr == "" {
return 0, fmt.Errorf("empty node major version")
}
v, err := strconv.Atoi(majorStr)
if err != nil {
return 0, err
}
return v, nil
}
func ensureClawHubReady(ctx context.Context) (string, error) {
outs := make([]string, 0, 4)
if p := resolveClawHubBinary(ctx); p != "" {
return "clawhub already installed at: " + p, nil
}
nodeOut, err := ensureNodeRuntime(ctx)
if nodeOut != "" {
outs = append(outs, nodeOut)
}
if err != nil {
return strings.Join(outs, "\n"), err
}
clawOut, err := runInstallCommand(ctx, "npm i -g clawhub")
if clawOut != "" {
outs = append(outs, clawOut)
}
if err != nil {
return strings.Join(outs, "\n"), err
}
if p := resolveClawHubBinary(ctx); p != "" {
outs = append(outs, "clawhub installed at: "+p)
return strings.Join(outs, "\n"), nil
}
return strings.Join(outs, "\n"), fmt.Errorf("installed clawhub but executable still not found in PATH")
}
func ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) {
pkgName = strings.TrimSpace(pkgName)
if pkgName == "" {
return "", "", "", fmt.Errorf("package empty")
}
outs := make([]string, 0, 4)
nodeOut, err := ensureNodeRuntime(ctx)
if nodeOut != "" {
outs = append(outs, nodeOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
installOut, err := runInstallCommand(ctx, "npm i -g "+shellEscapeArg(pkgName))
if installOut != "" {
outs = append(outs, installOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
binName, err = resolveNpmPackageBin(ctx, pkgName)
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
binPath = resolveInstalledBinary(ctx, binName)
if strings.TrimSpace(binPath) == "" {
return strings.Join(outs, "\n"), binName, "", fmt.Errorf("installed %s but binary %q not found in PATH", pkgName, binName)
}
outs = append(outs, fmt.Sprintf("installed %s", pkgName))
outs = append(outs, fmt.Sprintf("resolved binary: %s", binPath))
return strings.Join(outs, "\n"), binName, binPath, nil
}
func resolveNpmPackageBin(ctx context.Context, pkgName string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, "npm", "view", pkgName, "bin", "--json")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to query npm bin for %s: %w", pkgName, err)
}
trimmed := strings.TrimSpace(string(out))
if trimmed == "" || trimmed == "null" {
return "", fmt.Errorf("npm package %s does not expose a bin", pkgName)
}
var obj map[string]interface{}
if err := json.Unmarshal(out, &obj); err == nil && len(obj) > 0 {
keys := make([]string, 0, len(obj))
for key := range obj {
keys = append(keys, key)
}
sort.Strings(keys)
return keys[0], nil
}
var text string
if err := json.Unmarshal(out, &text); err == nil && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text), nil
}
return "", fmt.Errorf("unable to resolve bin for npm package %s", pkgName)
}
func resolveInstalledBinary(ctx context.Context, binName string) string {
binName = strings.TrimSpace(binName)
if binName == "" {
return ""
}
if p, err := exec.LookPath(binName); err == nil {
return p
}
prefix := strings.TrimSpace(npmGlobalPrefix(ctx))
if prefix != "" {
cand := filepath.Join(prefix, "bin", binName)
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
cands := []string{
filepath.Join("/usr/local/bin", binName),
filepath.Join("/opt/homebrew/bin", binName),
filepath.Join(os.Getenv("HOME"), ".npm-global", "bin", binName),
}
for _, cand := range cands {
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
return ""
}
func shellEscapeArg(in string) string {
if strings.TrimSpace(in) == "" {
return "''"
}
return "'" + strings.ReplaceAll(in, "'", `'\''`) + "'"
}
func importSkillArchiveFromMultipart(r *http.Request, skillsDir string) ([]string, error) {
if err := r.ParseMultipartForm(128 << 20); err != nil {
return nil, err
}
f, h, err := r.FormFile("file")
if err != nil {
return nil, fmt.Errorf("file required")
}
defer f.Close()
uploadDir := filepath.Join(os.TempDir(), "clawgo_skill_uploads")
_ = os.MkdirAll(uploadDir, 0755)
archivePath := filepath.Join(uploadDir, fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(h.Filename)))
out, err := os.Create(archivePath)
if err != nil {
return nil, err
}
if _, err := io.Copy(out, f); err != nil {
_ = out.Close()
_ = os.Remove(archivePath)
return nil, err
}
_ = out.Close()
defer os.Remove(archivePath)
extractDir, err := os.MkdirTemp("", "clawgo_skill_extract_*")
if err != nil {
return nil, err
}
defer os.RemoveAll(extractDir)
if err := extractArchive(archivePath, extractDir); err != nil {
return nil, err
}
type candidate struct {
name string
dir string
}
candidates := make([]candidate, 0)
seen := map[string]struct{}{}
err = filepath.WalkDir(extractDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if strings.EqualFold(d.Name(), "SKILL.md") {
dir := filepath.Dir(path)
rel, relErr := filepath.Rel(extractDir, dir)
if relErr != nil {
return nil
}
rel = filepath.ToSlash(strings.TrimSpace(rel))
if rel == "" {
rel = "."
}
name := filepath.Base(rel)
if rel == "." {
name = archiveBaseName(h.Filename)
}
name = sanitizeSkillName(name)
if name == "" {
return nil
}
if _, ok := seen[name]; ok {
return nil
}
seen[name] = struct{}{}
candidates = append(candidates, candidate{name: name, dir: dir})
}
return nil
})
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no SKILL.md found in archive")
}
imported := make([]string, 0, len(candidates))
for _, c := range candidates {
dst := filepath.Join(skillsDir, c.name)
if _, err := os.Stat(dst); err == nil {
return nil, fmt.Errorf("skill already exists: %s", c.name)
}
if _, err := os.Stat(dst + ".disabled"); err == nil {
return nil, fmt.Errorf("disabled skill already exists: %s", c.name)
}
if err := copyDir(c.dir, dst); err != nil {
return nil, err
}
imported = append(imported, c.name)
}
sort.Strings(imported)
return imported, nil
}
func archiveBaseName(filename string) string {
name := filepath.Base(strings.TrimSpace(filename))
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".tar.gz"):
return name[:len(name)-len(".tar.gz")]
case strings.HasSuffix(lower, ".tgz"):
return name[:len(name)-len(".tgz")]
case strings.HasSuffix(lower, ".zip"):
return name[:len(name)-len(".zip")]
case strings.HasSuffix(lower, ".tar"):
return name[:len(name)-len(".tar")]
default:
ext := filepath.Ext(name)
return strings.TrimSuffix(name, ext)
}
}
func sanitizeSkillName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, ch := range strings.ToLower(name) {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
b.WriteRune(ch)
lastDash = false
continue
}
if !lastDash {
b.WriteRune('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
if out == "" || out == "." {
return ""
}
return out
}
func extractArchive(archivePath, targetDir string) error {
lower := strings.ToLower(archivePath)
switch {
case strings.HasSuffix(lower, ".zip"):
return extractZip(archivePath, targetDir)
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return extractTarGz(archivePath, targetDir)
case strings.HasSuffix(lower, ".tar"):
return extractTar(archivePath, targetDir)
default:
return fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath))
}
}
func extractZip(archivePath, targetDir string) error {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer zr.Close()
for _, f := range zr.File {
if err := writeArchivedEntry(targetDir, f.Name, f.FileInfo().IsDir(), func() (io.ReadCloser, error) {
return f.Open()
}); err != nil {
return err
}
}
return nil
}
func extractTarGz(archivePath, targetDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
return extractTarReader(tar.NewReader(gz), targetDir)
}
func extractTar(archivePath, targetDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
return extractTarReader(tar.NewReader(f), targetDir)
}
func extractTarReader(tr *tar.Reader, targetDir string) error {
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := writeArchivedEntry(targetDir, hdr.Name, true, nil); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
name := hdr.Name
if err := writeArchivedEntry(targetDir, name, false, func() (io.ReadCloser, error) {
return io.NopCloser(tr), nil
}); err != nil {
return err
}
}
}
}
func writeArchivedEntry(targetDir, name string, isDir bool, opener func() (io.ReadCloser, error)) error {
clean := filepath.Clean(strings.TrimSpace(name))
clean = strings.TrimPrefix(clean, string(filepath.Separator))
clean = strings.TrimPrefix(clean, "/")
for strings.HasPrefix(clean, "../") {
clean = strings.TrimPrefix(clean, "../")
}
if clean == "." || clean == "" {
return nil
}
dst := filepath.Join(targetDir, clean)
absTarget, _ := filepath.Abs(targetDir)
absDst, _ := filepath.Abs(dst)
if !strings.HasPrefix(absDst, absTarget+string(filepath.Separator)) && absDst != absTarget {
return fmt.Errorf("invalid archive entry path: %s", name)
}
if isDir {
return os.MkdirAll(dst, 0755)
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
rc, err := opener()
if err != nil {
return err
}
defer rc.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}
func copyDir(src, dst string) error {
entries, err := os.ReadDir(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
info, err := e.Info()
if err != nil {
return err
}
if info.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
continue
}
in, err := os.Open(srcPath)
if err != nil {
return err
}
out, err := os.Create(dstPath)
if err != nil {
_ = in.Close()
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = in.Close()
return err
}
_ = out.Close()
_ = in.Close()
}
return nil
}
func anyToString(v interface{}) string {
switch t := v.(type) {
case string:
return t
case fmt.Stringer:
return t.String()
default:
if v == nil {
return ""
}
b, _ := json.Marshal(v)
return string(b)
}
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
sessionsDir := filepath.Join(filepath.Dir(s.workspacePath), "agents", "main", "sessions")
_ = os.MkdirAll(sessionsDir, 0755)
type item struct {
Key string `json:"key"`
Channel string `json:"channel,omitempty"`
}
out := make([]item, 0, 16)
entries, err := os.ReadDir(sessionsDir)
if err == nil {
seen := map[string]struct{}{}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".deleted.") {
continue
}
key := strings.TrimSuffix(name, ".jsonl")
if strings.TrimSpace(key) == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
channel := ""
if i := strings.Index(key, ":"); i > 0 {
channel = key[:i]
}
out = append(out, item{Key: key, Channel: channel})
}
}
if len(out) == 0 {
out = append(out, item{Key: "main", Channel: "main"})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "sessions": out})
}
func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
workspace := strings.TrimSpace(s.workspacePath)
if workspace == "" {
http.Error(w, "workspace path not set", http.StatusInternalServerError)
return
}
store := tools.NewSubagentProfileStore(workspace)
switch r.Method {
case http.MethodGet:
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentID != "" {
profile, ok, err := store.Get(agentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "found": ok, "profile": profile})
return
}
profiles, err := store.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profiles": profiles})
case http.MethodDelete:
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentID == "" {
http.Error(w, "agent_id required", http.StatusBadRequest)
return
}
if err := store.Delete(agentID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": true, "agent_id": agentID})
case http.MethodPost:
var body struct {
Action string `json:"action"`
AgentID string `json:"agent_id"`
Name string `json:"name"`
Role string `json:"role"`
SystemPrompt string `json:"system_prompt"`
MemoryNamespace string `json:"memory_namespace"`
Status string `json:"status"`
ToolAllowlist []string `json:"tool_allowlist"`
MaxRetries *int `json:"max_retries"`
RetryBackoffMS *int `json:"retry_backoff_ms"`
TimeoutSec *int `json:"timeout_sec"`
MaxTaskChars *int `json:"max_task_chars"`
MaxResultChars *int `json:"max_result_chars"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
action := strings.ToLower(strings.TrimSpace(body.Action))
if action == "" {
action = "upsert"
}
agentID := strings.TrimSpace(body.AgentID)
if agentID == "" {
http.Error(w, "agent_id required", http.StatusBadRequest)
return
}
switch action {
case "create":
if _, ok, err := store.Get(agentID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else if ok {
http.Error(w, "subagent profile already exists", http.StatusConflict)
return
}
profile, err := store.Upsert(tools.SubagentProfile{
AgentID: agentID,
Name: body.Name,
Role: body.Role,
SystemPrompt: body.SystemPrompt,
MemoryNamespace: body.MemoryNamespace,
Status: body.Status,
ToolAllowlist: body.ToolAllowlist,
MaxRetries: derefInt(body.MaxRetries),
RetryBackoff: derefInt(body.RetryBackoffMS),
TimeoutSec: derefInt(body.TimeoutSec),
MaxTaskChars: derefInt(body.MaxTaskChars),
MaxResultChars: derefInt(body.MaxResultChars),
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile})
case "update":
existing, ok, err := store.Get(agentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !ok || existing == nil {
http.Error(w, "subagent profile not found", http.StatusNotFound)
return
}
next := *existing
next.Name = body.Name
next.Role = body.Role
next.SystemPrompt = body.SystemPrompt
next.MemoryNamespace = body.MemoryNamespace
if body.Status != "" {
next.Status = body.Status
}
if body.ToolAllowlist != nil {
next.ToolAllowlist = body.ToolAllowlist
}
if body.MaxRetries != nil {
next.MaxRetries = *body.MaxRetries
}
if body.RetryBackoffMS != nil {
next.RetryBackoff = *body.RetryBackoffMS
}
if body.TimeoutSec != nil {
next.TimeoutSec = *body.TimeoutSec
}
if body.MaxTaskChars != nil {
next.MaxTaskChars = *body.MaxTaskChars
}
if body.MaxResultChars != nil {
next.MaxResultChars = *body.MaxResultChars
}
profile, err := store.Upsert(next)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile})
case "enable", "disable":
existing, ok, err := store.Get(agentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !ok || existing == nil {
http.Error(w, "subagent profile not found", http.StatusNotFound)
return
}
if action == "enable" {
existing.Status = "active"
} else {
existing.Status = "disabled"
}
profile, err := store.Upsert(*existing)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile})
case "delete":
if err := store.Delete(agentID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": true, "agent_id": agentID})
case "upsert":
profile, err := store.Upsert(tools.SubagentProfile{
AgentID: agentID,
Name: body.Name,
Role: body.Role,
SystemPrompt: body.SystemPrompt,
MemoryNamespace: body.MemoryNamespace,
Status: body.Status,
ToolAllowlist: body.ToolAllowlist,
MaxRetries: derefInt(body.MaxRetries),
RetryBackoff: derefInt(body.RetryBackoffMS),
TimeoutSec: derefInt(body.TimeoutSec),
MaxTaskChars: derefInt(body.MaxTaskChars),
MaxResultChars: derefInt(body.MaxResultChars),
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile})
default:
http.Error(w, "unsupported action", http.StatusBadRequest)
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebUIToolAllowlistGroups(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"groups": tools.ToolAllowlistGroups(),
})
}
func (s *Server) handleWebUISubagentsRuntime(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s.onSubagents == nil {
http.Error(w, "subagent runtime handler not configured", http.StatusServiceUnavailable)
return
}
action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action")))
args := map[string]interface{}{}
switch r.Method {
case http.MethodGet:
if action == "" {
action = "list"
}
for key, values := range r.URL.Query() {
if key == "action" || key == "token" || len(values) == 0 {
continue
}
args[key] = strings.TrimSpace(values[0])
}
case http.MethodPost:
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if body == nil {
body = map[string]interface{}{}
}
if action == "" {
if raw, _ := body["action"].(string); raw != "" {
action = strings.ToLower(strings.TrimSpace(raw))
}
}
delete(body, "action")
args = body
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
result, err := s.onSubagents(r.Context(), action, args)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": result})
}
func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
memoryDir := filepath.Join(s.workspacePath, "memory")
_ = os.MkdirAll(memoryDir, 0755)
switch r.Method {
case http.MethodGet:
path := strings.TrimSpace(r.URL.Query().Get("path"))
if path == "" {
entries, err := os.ReadDir(memoryDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
files := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
files = append(files, e.Name())
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "files": files})
return
}
clean := filepath.Clean(path)
if strings.HasPrefix(clean, "..") {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
full := filepath.Join(memoryDir, clean)
b, err := os.ReadFile(full)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "path": clean, "content": string(b)})
case http.MethodPost:
var body struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
clean := filepath.Clean(body.Path)
if clean == "" || strings.HasPrefix(clean, "..") {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
full := filepath.Join(memoryDir, clean)
if err := os.WriteFile(full, []byte(body.Content), 0644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "path": clean})
case http.MethodDelete:
path := filepath.Clean(r.URL.Query().Get("path"))
if path == "" || strings.HasPrefix(path, "..") {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
full := filepath.Join(memoryDir, path)
if err := os.Remove(full); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": true, "path": path})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebUITaskAudit(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
path := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "task-audit.jsonl")
includeHeartbeat := r.URL.Query().Get("include_heartbeat") == "1"
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
if n > 500 {
n = 500
}
limit = n
}
}
b, err := os.ReadFile(path)
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "items": []map[string]interface{}{}})
return
}
lines := strings.Split(string(b), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
items := make([]map[string]interface{}, 0, len(lines))
for _, ln := range lines {
if ln == "" {
continue
}
var row map[string]interface{}
if err := json.Unmarshal([]byte(ln), &row); err == nil {
source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"])))
if !includeHeartbeat && source == "heartbeat" {
continue
}
items = append(items, row)
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "items": items})
}
func (s *Server) handleWebUITaskQueue(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
path := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "task-audit.jsonl")
includeHeartbeat := r.URL.Query().Get("include_heartbeat") == "1"
b, err := os.ReadFile(path)
lines := []string{}
if err == nil {
lines = strings.Split(string(b), "\n")
}
type agg struct {
Last map[string]interface{}
Logs []string
Attempts int
}
m := map[string]*agg{}
for _, ln := range lines {
if ln == "" {
continue
}
var row map[string]interface{}
if err := json.Unmarshal([]byte(ln), &row); err != nil {
continue
}
source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"])))
if !includeHeartbeat && source == "heartbeat" {
continue
}
id := fmt.Sprintf("%v", row["task_id"])
if id == "" {
continue
}
if _, ok := m[id]; !ok {
m[id] = &agg{Last: row, Logs: []string{}, Attempts: 0}
}
a := m[id]
a.Last = row
a.Attempts++
if lg := strings.TrimSpace(fmt.Sprintf("%v", row["log"])); lg != "" {
if len(a.Logs) == 0 || a.Logs[len(a.Logs)-1] != lg {
a.Logs = append(a.Logs, lg)
if len(a.Logs) > 20 {
a.Logs = a.Logs[len(a.Logs)-20:]
}
}
}
}
items := make([]map[string]interface{}, 0, len(m))
running := make([]map[string]interface{}, 0)
for _, a := range m {
row := a.Last
row["logs"] = a.Logs
row["attempts"] = a.Attempts
items = append(items, row)
if fmt.Sprintf("%v", row["status"]) == "running" {
running = append(running, row)
}
}
// Merge command watchdog queue from memory/task_queue.json for visibility.
queuePath := filepath.Join(strings.TrimSpace(s.workspacePath), "memory", "task_queue.json")
if qb, qErr := os.ReadFile(queuePath); qErr == nil {
var q map[string]interface{}
if json.Unmarshal(qb, &q) == nil {
if arr, ok := q["running"].([]interface{}); ok {
for _, item := range arr {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
id := fmt.Sprintf("%v", row["id"])
if strings.TrimSpace(id) == "" {
continue
}
label := fmt.Sprintf("%v", row["label"])
source := strings.TrimSpace(fmt.Sprintf("%v", row["source"]))
if source == "" {
source = "task_watchdog"
}
rec := map[string]interface{}{
"task_id": "cmd:" + id,
"time": fmt.Sprintf("%v", row["started_at"]),
"status": "running",
"source": "task_watchdog",
"channel": source,
"session": "watchdog:" + id,
"input_preview": label,
"duration_ms": 0,
"attempts": 1,
"retry_count": 0,
"logs": []string{
fmt.Sprintf("watchdog source=%s heavy=%v", source, row["heavy"]),
fmt.Sprintf("next_check_at=%v stalled_rounds=%v/%v", row["next_check_at"], row["stalled_rounds"], row["stall_round_limit"]),
},
"idle_run": true,
}
items = append(items, rec)
running = append(running, rec)
}
}
if arr, ok := q["waiting"].([]interface{}); ok {
for _, item := range arr {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
id := fmt.Sprintf("%v", row["id"])
if strings.TrimSpace(id) == "" {
continue
}
label := fmt.Sprintf("%v", row["label"])
source := strings.TrimSpace(fmt.Sprintf("%v", row["source"]))
if source == "" {
source = "task_watchdog"
}
rec := map[string]interface{}{
"task_id": "cmd:" + id,
"time": fmt.Sprintf("%v", row["enqueued_at"]),
"status": "waiting",
"source": "task_watchdog",
"channel": source,
"session": "watchdog:" + id,
"input_preview": label,
"duration_ms": 0,
"attempts": 1,
"retry_count": 0,
"logs": []string{
fmt.Sprintf("watchdog source=%s heavy=%v", source, row["heavy"]),
fmt.Sprintf("enqueued_at=%v", row["enqueued_at"]),
},
"idle_run": true,
}
items = append(items, rec)
}
}
if wd, ok := q["watchdog"].(map[string]interface{}); ok {
items = append(items, map[string]interface{}{
"task_id": "cmd:watchdog",
"time": fmt.Sprintf("%v", q["time"]),
"status": "running",
"source": "task_watchdog",
"channel": "watchdog",
"session": "watchdog:stats",
"input_preview": "task watchdog capacity snapshot",
"duration_ms": 0,
"attempts": 1,
"retry_count": 0,
"logs": []string{
fmt.Sprintf("cpu_total=%v usage_ratio=%v reserve_pct=%v", wd["cpu_total"], wd["usage_ratio"], wd["reserve_pct"]),
fmt.Sprintf("active=%v/%v heavy=%v/%v waiting=%v running=%v", wd["active"], wd["max_active"], wd["active_heavy"], wd["max_heavy"], wd["waiting"], wd["running"]),
},
"idle_run": true,
})
}
}
}
sort.Slice(items, func(i, j int) bool { return fmt.Sprintf("%v", items[i]["time"]) > fmt.Sprintf("%v", items[j]["time"]) })
stats := map[string]int{"total": len(items), "running": len(running)}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "running": running, "items": items, "stats": stats})
}
func (s *Server) loadEKGRowsCached(path string, maxLines int) []map[string]interface{} {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return nil
}
s.ekgCacheMu.Lock()
defer s.ekgCacheMu.Unlock()
if s.ekgCachePath == path && s.ekgCacheSize == fi.Size() && s.ekgCacheStamp.Equal(fi.ModTime()) && len(s.ekgCacheRows) > 0 {
return s.ekgCacheRows
}
b, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(b), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if maxLines > 0 && len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
rows := make([]map[string]interface{}, 0, len(lines))
for _, ln := range lines {
if strings.TrimSpace(ln) == "" {
continue
}
var row map[string]interface{}
if json.Unmarshal([]byte(ln), &row) == nil {
rows = append(rows, row)
}
}
s.ekgCachePath = path
s.ekgCacheSize = fi.Size()
s.ekgCacheStamp = fi.ModTime()
s.ekgCacheRows = rows
return rows
}
func (s *Server) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
workspace := strings.TrimSpace(s.workspacePath)
ekgPath := filepath.Join(workspace, "memory", "ekg-events.jsonl")
window := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("window")))
windowDur := 24 * time.Hour
switch window {
case "6h":
windowDur = 6 * time.Hour
case "24h", "":
windowDur = 24 * time.Hour
case "7d":
windowDur = 7 * 24 * time.Hour
}
selectedWindow := window
if selectedWindow == "" {
selectedWindow = "24h"
}
cutoff := time.Now().UTC().Add(-windowDur)
rows := s.loadEKGRowsCached(ekgPath, 3000)
type kv struct {
Key string `json:"key"`
Score float64 `json:"score,omitempty"`
Count int `json:"count,omitempty"`
}
providerScore := map[string]float64{}
providerScoreWorkload := map[string]float64{}
errSigCount := map[string]int{}
errSigHeartbeat := map[string]int{}
errSigWorkload := map[string]int{}
sourceStats := map[string]int{}
channelStats := map[string]int{}
for _, row := range rows {
ts := strings.TrimSpace(fmt.Sprintf("%v", row["time"]))
if ts != "" {
if tm, err := time.Parse(time.RFC3339, ts); err == nil {
if tm.Before(cutoff) {
continue
}
}
}
provider := strings.TrimSpace(fmt.Sprintf("%v", row["provider"]))
status := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"])))
errSig := strings.TrimSpace(fmt.Sprintf("%v", row["errsig"]))
source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["source"])))
channel := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["channel"])))
if source == "heartbeat" {
continue
}
if source == "" {
source = "unknown"
}
if channel == "" {
channel = "unknown"
}
sourceStats[source]++
channelStats[channel]++
if provider != "" {
switch status {
case "success":
providerScore[provider] += 1
providerScoreWorkload[provider] += 1
case "suppressed":
providerScore[provider] += 0.2
providerScoreWorkload[provider] += 0.2
case "error":
providerScore[provider] -= 1
providerScoreWorkload[provider] -= 1
}
}
if errSig != "" && status == "error" {
errSigCount[errSig]++
errSigWorkload[errSig]++
}
}
toTopScore := func(m map[string]float64, n int) []kv {
out := make([]kv, 0, len(m))
for k, v := range m {
out = append(out, kv{Key: k, Score: v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
if len(out) > n {
out = out[:n]
}
return out
}
toTopCount := func(m map[string]int, n int) []kv {
out := make([]kv, 0, len(m))
for k, v := range m {
out = append(out, kv{Key: k, Count: v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > n {
out = out[:n]
}
return out
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"window": selectedWindow,
"provider_top": toTopScore(providerScore, 5),
"provider_top_workload": toTopScore(providerScoreWorkload, 5),
"errsig_top": toTopCount(errSigCount, 5),
"errsig_top_heartbeat": toTopCount(errSigHeartbeat, 5),
"errsig_top_workload": toTopCount(errSigWorkload, 5),
"source_stats": sourceStats,
"channel_stats": channelStats,
"escalation_count": 0,
})
}
func (s *Server) handleWebUIExecApprovals(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if strings.TrimSpace(s.configPath) == "" {
http.Error(w, "config path not set", http.StatusInternalServerError)
return
}
b, err := os.ReadFile(s.configPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var cfg map[string]interface{}
if err := json.Unmarshal(b, &cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Method == http.MethodGet {
toolsMap, _ := cfg["tools"].(map[string]interface{})
shellMap, _ := toolsMap["shell"].(map[string]interface{})
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "exec_approvals": shellMap})
return
}
if r.Method == http.MethodPost {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
toolsMap, _ := cfg["tools"].(map[string]interface{})
if toolsMap == nil {
toolsMap = map[string]interface{}{}
cfg["tools"] = toolsMap
}
shellMap, _ := toolsMap["shell"].(map[string]interface{})
if shellMap == nil {
shellMap = map[string]interface{}{}
toolsMap["shell"] = shellMap
}
for k, v := range body {
shellMap[k] = v
}
out, _ := json.MarshalIndent(cfg, "", " ")
tmp := s.configPath + ".tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.Rename(tmp, s.configPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if s.onConfigAfter != nil {
s.onConfigAfter()
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reloaded": true})
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func (s *Server) handleWebUILogsRecent(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimSpace(s.logFilePath)
if path == "" {
http.Error(w, "log path not configured", http.StatusInternalServerError)
return
}
limit := 10
if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
b, err := os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
start := 0
if len(lines) > limit {
start = len(lines) - limit
}
out := make([]map[string]interface{}, 0, limit)
for _, ln := range lines[start:] {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
if json.Valid([]byte(ln)) {
var m map[string]interface{}
if err := json.Unmarshal([]byte(ln), &m); err == nil {
out = append(out, m)
continue
}
}
out = append(out, map[string]interface{}{"time": time.Now().UTC().Format(time.RFC3339), "level": "INFO", "msg": ln})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "logs": out})
}
func (s *Server) handleWebUILogsStream(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimSpace(s.logFilePath)
if path == "" {
http.Error(w, "log path not configured", http.StatusInternalServerError)
return
}
f, err := os.Open(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, _ := f.Stat()
if fi != nil {
_, _ = f.Seek(fi.Size(), io.SeekStart)
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
reader := bufio.NewReader(f)
for {
select {
case <-r.Context().Done():
return
default:
line, err := reader.ReadString('\n')
if len(line) > 0 {
trim := strings.TrimRight(line, "\r\n")
if trim != "" {
if json.Valid([]byte(trim)) {
_, _ = w.Write([]byte(trim + "\n"))
} else {
fallback, _ := json.Marshal(map[string]interface{}{"time": time.Now().UTC().Format(time.RFC3339), "level": "INFO", "msg": trim})
_, _ = w.Write(append(fallback, '\n'))
}
flusher.Flush()
}
}
if err != nil {
time.Sleep(500 * time.Millisecond)
}
}
}
}
func (s *Server) checkAuth(r *http.Request) bool {
if s.token == "" {
return true
}
auth := strings.TrimSpace(r.Header.Get("Authorization"))
if auth == "Bearer "+s.token {
return true
}
if strings.TrimSpace(r.URL.Query().Get("token")) == s.token {
return true
}
if c, err := r.Cookie("clawgo_webui_token"); err == nil && strings.TrimSpace(c.Value) == s.token {
return true
}
// Browser asset fallback: allow token propagated via Referer query.
if ref := strings.TrimSpace(r.Referer()); ref != "" {
if u, err := url.Parse(ref); err == nil {
if strings.TrimSpace(u.Query().Get("token")) == s.token {
return true
}
}
}
return false
}
func hotReloadFieldInfo() []map[string]interface{} {
return []map[string]interface{}{
{"path": "logging.*", "name": "Logging", "description": "Log level, persistence, and related settings"},
{"path": "sentinel.*", "name": "Sentinel", "description": "Health checks and auto-heal behavior"},
{"path": "agents.*", "name": "Agent", "description": "Models, policies, and default behavior"},
{"path": "providers.*", "name": "Providers", "description": "LLM providers and proxy settings"},
{"path": "tools.*", "name": "Tools", "description": "Tool toggles and runtime options"},
{"path": "channels.*", "name": "Channels", "description": "Telegram and other channel settings"},
{"path": "cron.*", "name": "Cron", "description": "Global cron runtime settings"},
{"path": "agents.defaults.heartbeat.*", "name": "Heartbeat", "description": "Heartbeat interval and prompt template"},
{"path": "gateway.*", "name": "Gateway", "description": "Mostly hot-reloadable; host/port may require restart"},
}
}
const webUIHTML = `<!doctype html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ClawGo WebUI</title>
<style>body{font-family:system-ui;margin:20px;max-width:980px}textarea{width:100%;min-height:220px}#chatlog{white-space:pre-wrap;border:1px solid #ddd;padding:12px;min-height:180px}</style>
</head><body>
<h2>ClawGo WebUI</h2>
<p>Token: <input id="token" placeholder="gateway token" style="width:320px"/></p>
<h3>Config (dynamic + hot reload)</h3>
<button onclick="loadCfg()">Load Config</button>
<button onclick="saveCfg()">Save + Reload</button>
<textarea id="cfg"></textarea>
<h3>Chat (supports media upload)</h3>
<div>Session: <input id="session" value="webui:default"/> <input id="msg" placeholder="message" style="width:420px"/> <input id="file" type="file"/> <button onclick="sendChat()">Send</button></div>
<div id="chatlog"></div>
<script>
function auth(){const t=document.getElementById('token').value.trim();return t?('?token='+encodeURIComponent(t)):''}
async function loadCfg(){let r=await fetch('/webui/api/config'+auth());document.getElementById('cfg').value=await r.text()}
async function saveCfg(){let j=JSON.parse(document.getElementById('cfg').value);let r=await fetch('/webui/api/config'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(j)});alert(await r.text())}
async function sendChat(){
let media='';const f=document.getElementById('file').files[0];
if(f){let fd=new FormData();fd.append('file',f);let ur=await fetch('/webui/api/upload'+auth(),{method:'POST',body:fd});let uj=await ur.json();media=uj.path||''}
const payload={session:document.getElementById('session').value,message:document.getElementById('msg').value,media};
let r=await fetch('/webui/api/chat'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});let t=await r.text();
document.getElementById('chatlog').textContent += '\nUSER> '+payload.message+(media?(' [file:'+media+']'):'')+'\nBOT> '+t+'\n';
}
loadCfg();
</script></body></html>`