diff --git a/pkg/api/server.go b/pkg/api/server.go
index 23a7336..3852883 100644
--- a/pkg/api/server.go
+++ b/pkg/api/server.go
@@ -1,34 +1,21 @@
package api
import (
- "bufio"
- "bytes"
"context"
"encoding/json"
"fmt"
- "io"
- "net"
"net/http"
"net/url"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "runtime/debug"
- "sort"
"strconv"
"strings"
"sync"
"time"
"github.com/YspCoder/clawgo/pkg/channels"
- cfgpkg "github.com/YspCoder/clawgo/pkg/config"
"github.com/YspCoder/clawgo/pkg/nodes"
"github.com/YspCoder/clawgo/pkg/providers"
rpcpkg "github.com/YspCoder/clawgo/pkg/rpc"
- "github.com/YspCoder/clawgo/pkg/tools"
"github.com/gorilla/websocket"
- "rsc.io/qr"
)
type Server struct {
@@ -109,107 +96,6 @@ func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server
}
}
-type nodeSocketConn struct {
- connID string
- conn *websocket.Conn
- mu sync.Mutex
-}
-
-func (c *nodeSocketConn) writeJSON(payload interface{}) error {
- if c == nil || c.conn == nil {
- return fmt.Errorf("node websocket unavailable")
- }
- c.mu.Lock()
- defer c.mu.Unlock()
- _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- return c.conn.WriteJSON(payload)
-}
-
-func (c *nodeSocketConn) Send(msg nodes.WireMessage) error {
- return c.writeJSON(msg)
-}
-
-func publishLiveSnapshot(subs map[chan []byte]struct{}, payload []byte) {
- for ch := range subs {
- select {
- case ch <- payload:
- default:
- select {
- case <-ch:
- default:
- }
- select {
- case ch <- payload:
- default:
- }
- }
- }
-}
-
-func (s *Server) subscribeRuntimeLive(ctx context.Context) chan []byte {
- ch := make(chan []byte, 1)
- s.liveRuntimeMu.Lock()
- s.liveRuntimeSubs[ch] = struct{}{}
- start := !s.liveRuntimeOn
- if start {
- s.liveRuntimeOn = true
- }
- s.liveRuntimeMu.Unlock()
- if start {
- go s.runtimeLiveLoop()
- }
- go func() {
- <-ctx.Done()
- s.unsubscribeRuntimeLive(ch)
- }()
- return ch
-}
-
-func (s *Server) unsubscribeRuntimeLive(ch chan []byte) {
- s.liveRuntimeMu.Lock()
- delete(s.liveRuntimeSubs, ch)
- s.liveRuntimeMu.Unlock()
-}
-
-func (s *Server) runtimeLiveLoop() {
- ticker := time.NewTicker(10 * time.Second)
- defer ticker.Stop()
- for {
- if !s.publishRuntimeSnapshot(context.Background()) {
- s.liveRuntimeMu.Lock()
- if len(s.liveRuntimeSubs) == 0 {
- s.liveRuntimeOn = false
- s.liveRuntimeMu.Unlock()
- return
- }
- s.liveRuntimeMu.Unlock()
- }
- <-ticker.C
- }
-}
-
-func (s *Server) publishRuntimeSnapshot(ctx context.Context) bool {
- if s == nil {
- return false
- }
- payload := map[string]interface{}{
- "ok": true,
- "type": "runtime_snapshot",
- "snapshot": s.buildWebUIRuntimeSnapshot(ctx),
- }
- data, err := json.Marshal(payload)
- if err != nil {
- return false
- }
- s.liveRuntimeMu.Lock()
- defer s.liveRuntimeMu.Unlock()
- if len(s.liveRuntimeSubs) == 0 {
- return false
- }
- publishLiveSnapshot(s.liveRuntimeSubs, data)
- return true
-}
-
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) }
@@ -247,12 +133,6 @@ func (s *Server) SetProtectedRoute(path string, handler http.Handler) {
}
s.extraRoutes[path] = handler
}
-func (s *Server) SetNodeWebRTCTransport(t *nodes.WebRTCTransport) {
- s.nodeWebRTC = t
-}
-func (s *Server) SetNodeP2PStatusHandler(fn func() map[string]interface{}) {
- s.nodeP2PStatus = fn
-}
func (s *Server) SetWhatsAppBridge(service *channels.WhatsAppBridgeService, basePath string) {
s.whatsAppBridge = service
s.whatsAppBase = strings.TrimSpace(basePath)
@@ -319,81 +199,6 @@ func queryBoundedPositiveInt(r *http.Request, key string, fallback int, max int)
return n
}
-func (s *Server) rememberNodeConnection(nodeID, connID string) {
- nodeID = strings.TrimSpace(nodeID)
- connID = strings.TrimSpace(connID)
- if nodeID == "" || connID == "" {
- return
- }
- s.nodeConnMu.Lock()
- defer s.nodeConnMu.Unlock()
- s.nodeConnIDs[nodeID] = connID
-}
-
-func (s *Server) bindNodeSocket(nodeID, connID string, conn *websocket.Conn) {
- nodeID = strings.TrimSpace(nodeID)
- connID = strings.TrimSpace(connID)
- if nodeID == "" || connID == "" || conn == nil {
- return
- }
- next := &nodeSocketConn{connID: connID, conn: conn}
- s.nodeConnMu.Lock()
- prev := s.nodeSockets[nodeID]
- s.nodeSockets[nodeID] = next
- s.nodeConnMu.Unlock()
- if s.mgr != nil {
- s.mgr.RegisterWireSender(nodeID, next)
- }
- if s.nodeWebRTC != nil {
- s.nodeWebRTC.BindSignaler(nodeID, next)
- }
- if prev != nil && prev.connID != connID {
- _ = prev.conn.Close()
- }
-}
-
-func (s *Server) releaseNodeConnection(nodeID, connID string) bool {
- nodeID = strings.TrimSpace(nodeID)
- connID = strings.TrimSpace(connID)
- if nodeID == "" || connID == "" {
- return false
- }
- s.nodeConnMu.Lock()
- defer s.nodeConnMu.Unlock()
- if s.nodeConnIDs[nodeID] != connID {
- return false
- }
- delete(s.nodeConnIDs, nodeID)
- if sock := s.nodeSockets[nodeID]; sock != nil && sock.connID == connID {
- delete(s.nodeSockets, nodeID)
- }
- if s.mgr != nil {
- s.mgr.RegisterWireSender(nodeID, nil)
- }
- if s.nodeWebRTC != nil {
- s.nodeWebRTC.UnbindSignaler(nodeID)
- }
- return true
-}
-
-func (s *Server) getNodeSocket(nodeID string) *nodeSocketConn {
- nodeID = strings.TrimSpace(nodeID)
- if nodeID == "" {
- return nil
- }
- s.nodeConnMu.Lock()
- defer s.nodeConnMu.Unlock()
- return s.nodeSockets[nodeID]
-}
-
-func (s *Server) sendNodeSocketMessage(nodeID string, msg nodes.WireMessage) error {
- sock := s.getNodeSocket(nodeID)
- if sock == nil || sock.conn == nil {
- return fmt.Errorf("node %s not connected", strings.TrimSpace(nodeID))
- }
- return sock.writeJSON(msg)
-}
-
func (s *Server) Start(ctx context.Context) error {
if s.mgr == nil {
return nil
@@ -545,3060 +350,6 @@ func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"ok": true, "id": result.ID})
}
-func (s *Server) handleNodeConnect(w http.ResponseWriter, r *http.Request) {
- if !s.checkAuth(r) {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- if s.mgr == nil {
- http.Error(w, "nodes manager unavailable", http.StatusInternalServerError)
- return
- }
- conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
- if err != nil {
- return
- }
- defer conn.Close()
-
- var connectedID string
- connID := fmt.Sprintf("%d", time.Now().UnixNano())
- _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
- conn.SetPongHandler(func(string) error {
- return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
- })
-
- writeAck := func(ack nodes.WireAck) error {
- if strings.TrimSpace(connectedID) != "" {
- if sock := s.getNodeSocket(connectedID); sock != nil && sock.connID == connID && sock.conn == conn {
- return sock.writeJSON(ack)
- }
- }
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- return conn.WriteJSON(ack)
- }
-
- defer func() {
- if strings.TrimSpace(connectedID) != "" && s.releaseNodeConnection(connectedID, connID) {
- s.mgr.MarkOffline(connectedID)
- }
- }()
-
- for {
- var msg nodes.WireMessage
- if err := conn.ReadJSON(&msg); err != nil {
- return
- }
- _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
- if s.mgr != nil && s.mgr.HandleWireMessage(msg) {
- continue
- }
- type nodeSocketHandler func(nodes.WireMessage) bool
- handlers := map[string]nodeSocketHandler{
- "register": func(msg nodes.WireMessage) bool {
- if msg.Node == nil || strings.TrimSpace(msg.Node.ID) == "" {
- _ = writeAck(nodes.WireAck{OK: false, Type: "register", Error: "node.id required"})
- return true
- }
- s.mgr.Upsert(*msg.Node)
- connectedID = strings.TrimSpace(msg.Node.ID)
- s.rememberNodeConnection(connectedID, connID)
- s.bindNodeSocket(connectedID, connID, conn)
- return writeAck(nodes.WireAck{OK: true, Type: "registered", ID: connectedID}) == nil
- },
- "heartbeat": func(msg nodes.WireMessage) bool {
- id := strings.TrimSpace(msg.ID)
- if id == "" {
- id = connectedID
- }
- if id == "" {
- _ = writeAck(nodes.WireAck{OK: false, Type: "heartbeat", Error: "id required"})
- return true
- }
- if msg.Node != nil && strings.TrimSpace(msg.Node.ID) != "" {
- s.mgr.Upsert(*msg.Node)
- connectedID = strings.TrimSpace(msg.Node.ID)
- s.rememberNodeConnection(connectedID, connID)
- s.bindNodeSocket(connectedID, connID, conn)
- } else if n, ok := s.mgr.Get(id); ok {
- s.mgr.Upsert(n)
- connectedID = id
- s.rememberNodeConnection(connectedID, connID)
- s.bindNodeSocket(connectedID, connID, conn)
- } else {
- _ = writeAck(nodes.WireAck{OK: false, Type: "heartbeat", ID: id, Error: "node not found"})
- return true
- }
- return writeAck(nodes.WireAck{OK: true, Type: "heartbeat", ID: connectedID}) == nil
- },
- "signal_offer": func(msg nodes.WireMessage) bool { return s.handleNodeSignalMessage(msg, connectedID, writeAck) },
- "signal_answer": func(msg nodes.WireMessage) bool { return s.handleNodeSignalMessage(msg, connectedID, writeAck) },
- "signal_candidate": func(msg nodes.WireMessage) bool { return s.handleNodeSignalMessage(msg, connectedID, writeAck) },
- }
- if handler := handlers[strings.ToLower(strings.TrimSpace(msg.Type))]; handler != nil {
- if !handler(msg) {
- return
- }
- continue
- }
- if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "unsupported message type"}); err != nil {
- return
- }
- }
-}
-
-func (s *Server) handleNodeSignalMessage(msg nodes.WireMessage, connectedID string, writeAck func(nodes.WireAck) error) bool {
- targetID := strings.TrimSpace(msg.To)
- if s.nodeWebRTC != nil && (targetID == "" || strings.EqualFold(targetID, "gateway")) {
- if err := s.nodeWebRTC.HandleSignal(msg); err != nil {
- _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()})
- return true
- }
- return writeAck(nodes.WireAck{OK: true, Type: "signaled", ID: msg.ID}) == nil
- }
- if strings.TrimSpace(connectedID) == "" {
- _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, Error: "node not registered"})
- return true
- }
- if targetID == "" {
- _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "target node required"})
- return true
- }
- msg.From = connectedID
- if err := s.sendNodeSocketMessage(targetID, msg); err != nil {
- _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()})
- return true
- }
- return writeAck(nodes.WireAck{OK: true, Type: "relayed", ID: msg.ID}) == nil
-}
-
-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, "/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 strings.HasPrefix(r.URL.Path, "/api/") {
- http.NotFound(w, r)
- return
- }
- if r.URL.Path == "/" {
- s.handleWebUI(w, r)
- return
- }
- if s.tryServeWebUIDist(w, r, r.URL.Path) {
- return
- }
- // SPA fallback
- if s.tryServeWebUIDist(w, r, "/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, "/")
- if reqPath == "/" || reqPath == "/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 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 collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string {
- paths := []string{
- "channels.telegram.token",
- "channels.telegram.allow_from",
- "channels.telegram.allow_chats",
- "models.providers.openai.api_base",
- "models.providers.openai.api_key",
- "runtime.providers.openai.api_base",
- "runtime.providers.openai.api_key",
- "gateway.token",
- "gateway.port",
- }
- seen := map[string]bool{}
- for _, path := range paths {
- seen[path] = true
- }
- for _, name := range collectProviderNames(oldMap, newMap) {
- for _, field := range []string{"api_base", "api_key"} {
- path := "models.providers." + name + "." + field
- if !seen[path] {
- paths = append(paths, path)
- seen[path] = true
- }
- normalizedPath := "runtime.providers." + name + "." + field
- if !seen[normalizedPath] {
- paths = append(paths, normalizedPath)
- seen[normalizedPath] = true
- }
- }
- }
- return paths
-}
-
-func collectProviderNames(maps ...map[string]interface{}) []string {
- seen := map[string]bool{}
- names := make([]string, 0)
- for _, root := range maps {
- models, _ := root["models"].(map[string]interface{})
- providers, _ := models["providers"].(map[string]interface{})
- for name := range providers {
- if strings.TrimSpace(name) == "" || seen[name] {
- continue
- }
- seen[name] = true
- names = append(names, name)
- }
- runtimeMap, _ := root["runtime"].(map[string]interface{})
- runtimeProviders, _ := runtimeMap["providers"].(map[string]interface{})
- for name := range runtimeProviders {
- if strings.TrimSpace(name) == "" || seen[name] {
- continue
- }
- seen[name] = true
- names = append(names, name)
- }
- }
- sort.Strings(names)
- return names
-}
-
-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
- }
- writeJSON(w, 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
- }
- writeJSON(w, 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 {
- writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": []interface{}{}})
- return
- }
- writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)})
-}
-
-func (s *Server) handleWebUIChatLive(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
- }
- if s.onChat == nil {
- http.Error(w, "chat handler not configured", http.StatusInternalServerError)
- return
- }
- conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
- if err != nil {
- return
- }
- defer conn.Close()
-
- var body struct {
- Session string `json:"session"`
- Message string `json:"message"`
- Media string `json:"media"`
- }
- if err := conn.ReadJSON(&body); err != nil {
- _ = conn.WriteJSON(map[string]interface{}{"ok": false, "type": "chat_error", "error": "invalid json"})
- 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 {
- _ = conn.WriteJSON(map[string]interface{}{"ok": false, "type": "chat_error", "error": err.Error(), "session": session})
- return
- }
- chunk := 180
- for i := 0; i < len(resp); i += chunk {
- end := i + chunk
- if end > len(resp) {
- end = len(resp)
- }
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if err := conn.WriteJSON(map[string]interface{}{
- "ok": true,
- "type": "chat_chunk",
- "session": session,
- "delta": resp[i:end],
- }); err != nil {
- return
- }
- }
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- _ = conn.WriteJSON(map[string]interface{}{
- "ok": true,
- "type": "chat_done",
- "session": session,
- })
-}
-
-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
- }
- writeJSON(w, map[string]interface{}{
- "ok": true,
- "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
- "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
- "compiled_channels": channels.CompiledChannelKeys(),
- })
-}
-
-func (s *Server) handleWebUIWhatsAppStatus(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
- }
- payload, code := s.webUIWhatsAppStatusPayload(r.Context())
- writeJSONStatus(w, code, payload)
-}
-
-func (s *Server) handleWebUIWhatsAppLogout(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
- }
- cfg, err := s.loadConfig()
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- logoutURL, err := channels.BridgeLogoutURL(s.resolveWhatsAppBridgeURL(cfg))
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, logoutURL, nil)
- resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadGateway)
- return
- }
- defer resp.Body.Close()
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(resp.StatusCode)
- if _, err := io.Copy(w, resp.Body); err != nil {
- return
- }
-}
-
-func (s *Server) handleWebUIWhatsAppQR(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
- }
- payload, code := s.webUIWhatsAppStatusPayload(r.Context())
- status, _ := payload["status"].(map[string]interface{})
- qrCode := ""
- if status != nil {
- qrCode = stringFromMap(status, "qr_code")
- }
- if code != http.StatusOK || strings.TrimSpace(qrCode) == "" {
- http.Error(w, "qr unavailable", http.StatusNotFound)
- return
- }
- qrCode = strings.TrimSpace(qrCode)
- qrImage, err := qr.Encode(qrCode, qr.M)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadGateway)
- return
- }
- w.Header().Set("Content-Type", "image/svg+xml")
- _, _ = io.WriteString(w, renderQRCodeSVG(qrImage, 8, 24))
-}
-
-func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]interface{}, int) {
- cfg, err := s.loadConfig()
- if err != nil {
- return map[string]interface{}{
- "ok": false,
- "error": err.Error(),
- }, http.StatusInternalServerError
- }
- waCfg := cfg.Channels.WhatsApp
- bridgeURL := s.resolveWhatsAppBridgeURL(cfg)
- statusURL, err := channels.BridgeStatusURL(bridgeURL)
- if err != nil {
- return map[string]interface{}{
- "ok": false,
- "enabled": waCfg.Enabled,
- "bridge_url": bridgeURL,
- "error": err.Error(),
- }, http.StatusBadRequest
- }
- req, _ := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
- resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req)
- if err != nil {
- return map[string]interface{}{
- "ok": false,
- "enabled": waCfg.Enabled,
- "bridge_url": bridgeURL,
- "bridge_running": false,
- "error": err.Error(),
- }, http.StatusOK
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 300 {
- body, _ := io.ReadAll(resp.Body)
- return map[string]interface{}{
- "ok": false,
- "enabled": waCfg.Enabled,
- "bridge_url": bridgeURL,
- "bridge_running": false,
- "error": strings.TrimSpace(string(body)),
- }, http.StatusOK
- }
- var status channels.WhatsAppBridgeStatus
- if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
- return map[string]interface{}{
- "ok": false,
- "enabled": waCfg.Enabled,
- "bridge_url": bridgeURL,
- "bridge_running": false,
- "error": err.Error(),
- }, http.StatusOK
- }
- return map[string]interface{}{
- "ok": true,
- "enabled": waCfg.Enabled,
- "bridge_url": bridgeURL,
- "bridge_running": true,
- "status": map[string]interface{}{
- "state": status.State,
- "connected": status.Connected,
- "logged_in": status.LoggedIn,
- "bridge_addr": status.BridgeAddr,
- "user_jid": status.UserJID,
- "push_name": status.PushName,
- "platform": status.Platform,
- "qr_available": status.QRAvailable,
- "qr_code": status.QRCode,
- "last_event": status.LastEvent,
- "last_error": status.LastError,
- "updated_at": status.UpdatedAt,
- },
- }, http.StatusOK
-}
-
-func (s *Server) loadWhatsAppConfig() (cfgpkg.WhatsAppConfig, error) {
- cfg, err := s.loadConfig()
- if err != nil {
- return cfgpkg.WhatsAppConfig{}, err
- }
- return cfg.Channels.WhatsApp, nil
-}
-
-func (s *Server) loadConfig() (*cfgpkg.Config, error) {
- configPath := strings.TrimSpace(s.configPath)
- if configPath == "" {
- configPath = filepath.Join(cfgpkg.GetConfigDir(), "config.json")
- }
- cfg, err := cfgpkg.LoadConfig(configPath)
- if err != nil {
- return nil, err
- }
- return cfg, nil
-}
-
-func (s *Server) resolveWhatsAppBridgeURL(cfg *cfgpkg.Config) string {
- if cfg == nil {
- return ""
- }
- raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL)
- if raw == "" {
- return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
- }
- hostPort := comparableBridgeHostPort(raw)
- if hostPort == "" {
- return raw
- }
- if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" {
- return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
- }
- if hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port) {
- return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
- }
- return raw
-}
-
-func embeddedWhatsAppBridgeURL(host string, port int) string {
- host = strings.TrimSpace(host)
- switch host {
- case "", "0.0.0.0", "::", "[::]":
- host = "127.0.0.1"
- }
- return fmt.Sprintf("ws://%s:%d/whatsapp/ws", host, port)
-}
-
-func comparableBridgeHostPort(raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- if !strings.Contains(raw, "://") {
- return strings.ToLower(raw)
- }
- u, err := url.Parse(raw)
- if err != nil {
- return ""
- }
- return strings.ToLower(strings.TrimSpace(u.Host))
-}
-
-func comparableGatewayHostPort(host string, port int) string {
- host = strings.TrimSpace(strings.ToLower(host))
- switch host {
- case "", "0.0.0.0", "::", "[::]":
- host = "127.0.0.1"
- }
- return fmt.Sprintf("%s:%d", host, port)
-}
-
-func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string {
- if code == nil || code.Size <= 0 {
- return ""
- }
- if scale <= 0 {
- scale = 8
- }
- if quietZone < 0 {
- quietZone = 0
- }
- total := (code.Size + quietZone*2) * scale
- var b strings.Builder
- b.Grow(total * 8)
- b.WriteString(fmt.Sprintf(``)
- return b.String()
-}
-
-func (s *Server) handleWebUIRuntime(w http.ResponseWriter, r *http.Request) {
- if !s.checkAuth(r) {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
- if err != nil {
- return
- }
- defer conn.Close()
-
- ctx := r.Context()
- sub := s.subscribeRuntimeLive(ctx)
- initial := map[string]interface{}{
- "ok": true,
- "type": "runtime_snapshot",
- "snapshot": s.buildWebUIRuntimeSnapshot(ctx),
- }
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if err := conn.WriteJSON(initial); err != nil {
- return
- }
- for {
- select {
- case <-ctx.Done():
- return
- case payload := <-sub:
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
- return
- }
- }
- }
-}
-
-func (s *Server) buildWebUIRuntimeSnapshot(ctx context.Context) map[string]interface{} {
- var providerPayload map[string]interface{}
- var normalizedConfig interface{}
- if strings.TrimSpace(s.configPath) != "" {
- if cfg, err := cfgpkg.LoadConfig(strings.TrimSpace(s.configPath)); err == nil {
- providerPayload = providers.GetProviderRuntimeSnapshot(cfg)
- normalizedConfig = cfg.NormalizedView()
- }
- }
- if providerPayload == nil {
- providerPayload = map[string]interface{}{"items": []interface{}{}}
- }
- runtimePayload := map[string]interface{}{}
- if s.onSubagents != nil {
- if res, err := s.onSubagents(ctx, "snapshot", map[string]interface{}{"limit": 200}); err == nil {
- if m, ok := res.(map[string]interface{}); ok {
- runtimePayload = m
- }
- }
- }
- return map[string]interface{}{
- "version": s.webUIVersionPayload(),
- "config": normalizedConfig,
- "runtime": runtimePayload,
- "nodes": s.webUINodesPayload(ctx),
- "sessions": s.webUISessionsPayload(),
- "task_queue": s.webUITaskQueuePayload(false),
- "ekg": s.webUIEKGSummaryPayload("24h"),
- "providers": providerPayload,
- }
-}
-
-func (s *Server) webUIVersionPayload() map[string]interface{} {
- return map[string]interface{}{
- "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
- "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
- "compiled_channels": channels.CompiledChannelKeys(),
- }
-}
-
-func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} {
- list := []nodes.NodeInfo{}
- if s.mgr != nil {
- list = s.mgr.List()
- }
- localRegistry := s.fetchRegistryItems(ctx)
- localAgents := make([]nodes.AgentInfo, 0, len(localRegistry))
- for _, item := range localRegistry {
- agentID := strings.TrimSpace(stringFromMap(item, "agent_id"))
- if agentID == "" {
- continue
- }
- localAgents = append(localAgents, nodes.AgentInfo{
- ID: agentID,
- DisplayName: strings.TrimSpace(stringFromMap(item, "display_name")),
- Role: strings.TrimSpace(stringFromMap(item, "role")),
- Type: strings.TrimSpace(stringFromMap(item, "type")),
- Transport: fallbackString(strings.TrimSpace(stringFromMap(item, "transport")), "local"),
- })
- }
- host, _ := os.Hostname()
- local := nodes.NodeInfo{
- ID: "local",
- Name: "local",
- Endpoint: "gateway",
- Version: gatewayBuildVersion(),
- OS: runtime.GOOS,
- Arch: runtime.GOARCH,
- LastSeenAt: time.Now(),
- Online: true,
- Capabilities: nodes.Capabilities{Run: true, Invoke: true, Model: true, Camera: true, Screen: true, Location: true, Canvas: true},
- Actions: []string{"run", "agent_task", "camera_snap", "camera_clip", "screen_snapshot", "screen_record", "location_get", "canvas_snapshot", "canvas_action"},
- Models: []string{"local-sim"},
- Agents: localAgents,
- }
- 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) {
- 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...)
- }
- p2p := map[string]interface{}{}
- if s.nodeP2PStatus != nil {
- p2p = s.nodeP2PStatus()
- }
- dispatches := s.webUINodesDispatchPayload(12)
- return map[string]interface{}{
- "nodes": list,
- "trees": s.buildNodeAgentTrees(ctx, list),
- "p2p": p2p,
- "dispatches": dispatches,
- "alerts": s.webUINodeAlertsPayload(list, p2p, dispatches),
- "artifact_retention": s.artifactStatsSnapshot(),
- }
-}
-
-func (s *Server) webUINodeAlertsPayload(nodeList []nodes.NodeInfo, p2p map[string]interface{}, dispatches []map[string]interface{}) []map[string]interface{} {
- alerts := make([]map[string]interface{}, 0)
- for _, node := range nodeList {
- nodeID := strings.TrimSpace(node.ID)
- if nodeID == "" || nodeID == "local" {
- continue
- }
- if !node.Online {
- alerts = append(alerts, map[string]interface{}{
- "severity": "critical",
- "kind": "node_offline",
- "node": nodeID,
- "title": "Node offline",
- "detail": fmt.Sprintf("node %s is offline", nodeID),
- })
- }
- }
- if sessions, ok := p2p["nodes"].([]map[string]interface{}); ok {
- for _, session := range sessions {
- appendNodeSessionAlert(&alerts, session)
- }
- } else if sessions, ok := p2p["nodes"].([]interface{}); ok {
- for _, raw := range sessions {
- if session, ok := raw.(map[string]interface{}); ok {
- appendNodeSessionAlert(&alerts, session)
- }
- }
- }
- failuresByNode := map[string]int{}
- for _, row := range dispatches {
- nodeID := strings.TrimSpace(fmt.Sprint(row["node"]))
- if nodeID == "" {
- continue
- }
- if ok, _ := tools.MapBoolArg(row, "ok"); ok {
- continue
- }
- failuresByNode[nodeID]++
- }
- for nodeID, count := range failuresByNode {
- if count < 2 {
- continue
- }
- alerts = append(alerts, map[string]interface{}{
- "severity": "warning",
- "kind": "dispatch_failures",
- "node": nodeID,
- "title": "Repeated dispatch failures",
- "detail": fmt.Sprintf("node %s has %d recent failed dispatches", nodeID, count),
- "count": count,
- })
- }
- return alerts
-}
-
-func appendNodeSessionAlert(alerts *[]map[string]interface{}, session map[string]interface{}) {
- nodeID := strings.TrimSpace(fmt.Sprint(session["node"]))
- if nodeID == "" {
- return
- }
- status := strings.ToLower(strings.TrimSpace(fmt.Sprint(session["status"])))
- retryCount := int(int64Value(session["retry_count"]))
- lastError := strings.TrimSpace(fmt.Sprint(session["last_error"]))
- switch {
- case status == "failed" || status == "closed":
- *alerts = append(*alerts, map[string]interface{}{
- "severity": "critical",
- "kind": "p2p_session_down",
- "node": nodeID,
- "title": "P2P session down",
- "detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session is %s", nodeID, status)),
- })
- case retryCount >= 3 || (status == "connecting" && retryCount >= 2):
- *alerts = append(*alerts, map[string]interface{}{
- "severity": "warning",
- "kind": "p2p_session_unstable",
- "node": nodeID,
- "title": "P2P session unstable",
- "detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session retry_count=%d", nodeID, retryCount)),
- "count": retryCount,
- })
- }
-}
-
-func int64Value(v interface{}) int64 {
- switch value := v.(type) {
- case int:
- return int64(value)
- case int32:
- return int64(value)
- case int64:
- return value
- case float32:
- return int64(value)
- case float64:
- return int64(value)
- case json.Number:
- if n, err := value.Int64(); err == nil {
- return n
- }
- }
- return 0
-}
-
-func (s *Server) webUINodesDispatchPayload(limit int) []map[string]interface{} {
- path := s.memoryFilePath("nodes-dispatch-audit.jsonl")
- if path == "" {
- return []map[string]interface{}{}
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return []map[string]interface{}{}
- }
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" {
- return []map[string]interface{}{}
- }
- out := make([]map[string]interface{}, 0, limit)
- for i := len(lines) - 1; i >= 0; i-- {
- line := strings.TrimSpace(lines[i])
- if line == "" {
- continue
- }
- row := map[string]interface{}{}
- if err := json.Unmarshal([]byte(line), &row); err != nil {
- continue
- }
- out = append(out, row)
- if limit > 0 && len(out) >= limit {
- break
- }
- }
- return out
-}
-
-func (s *Server) webUINodeArtifactsPayload(limit int) []map[string]interface{} {
- return s.webUINodeArtifactsPayloadFiltered("", "", "", limit)
-}
-
-func (s *Server) readNodeDispatchAuditRows() ([]map[string]interface{}, string) {
- path := s.memoryFilePath("nodes-dispatch-audit.jsonl")
- if path == "" {
- return nil, ""
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, path
- }
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- rows := make([]map[string]interface{}, 0, len(lines))
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- row := map[string]interface{}{}
- if err := json.Unmarshal([]byte(line), &row); err != nil {
- continue
- }
- rows = append(rows, row)
- }
- return rows, path
-}
-
-func resolveRelativeFilePath(root, raw string) (string, string, error) {
- root = strings.TrimSpace(root)
- if root == "" {
- return "", "", fmt.Errorf("workspace not configured")
- }
- clean := filepath.Clean(strings.TrimSpace(raw))
- if clean == "." || clean == "" || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
- return "", "", fmt.Errorf("invalid path")
- }
- full := filepath.Join(root, clean)
- cleanRoot := filepath.Clean(root)
- if full != cleanRoot {
- prefix := cleanRoot + string(os.PathSeparator)
- if !strings.HasPrefix(filepath.Clean(full), prefix) {
- return "", "", fmt.Errorf("invalid path")
- }
- }
- return clean, full, nil
-}
-
-func relativeFilePathStatus(err error) int {
- if err == nil {
- return http.StatusOK
- }
- if err.Error() == "workspace not configured" {
- return http.StatusInternalServerError
- }
- return http.StatusBadRequest
-}
-
-func readRelativeTextFile(root, raw string) (string, string, bool, error) {
- clean, full, err := resolveRelativeFilePath(root, raw)
- if err != nil {
- return "", "", false, err
- }
- b, err := os.ReadFile(full)
- if err != nil {
- if os.IsNotExist(err) {
- return clean, "", false, nil
- }
- return clean, "", false, err
- }
- return clean, string(b), true, nil
-}
-
-func writeRelativeTextFile(root, raw string, content string, ensureDir bool) (string, error) {
- clean, full, err := resolveRelativeFilePath(root, raw)
- if err != nil {
- return "", err
- }
- if ensureDir {
- if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
- return "", err
- }
- }
- if err := os.WriteFile(full, []byte(content), 0644); err != nil {
- return "", err
- }
- return clean, nil
-}
-
-func (s *Server) memoryFilePath(name string) string {
- workspace := strings.TrimSpace(s.workspacePath)
- if workspace == "" {
- return ""
- }
- return filepath.Join(workspace, "memory", strings.TrimSpace(name))
-}
-
-func (s *Server) setArtifactStats(summary map[string]interface{}) {
- s.artifactStatsMu.Lock()
- defer s.artifactStatsMu.Unlock()
- if summary == nil {
- s.artifactStats = map[string]interface{}{}
- return
- }
- copySummary := make(map[string]interface{}, len(summary))
- for k, v := range summary {
- copySummary[k] = v
- }
- s.artifactStats = copySummary
-}
-
-func (s *Server) artifactStatsSnapshot() map[string]interface{} {
- s.artifactStatsMu.Lock()
- defer s.artifactStatsMu.Unlock()
- out := make(map[string]interface{}, len(s.artifactStats))
- for k, v := range s.artifactStats {
- out[k] = v
- }
- return out
-}
-
-func (s *Server) nodeArtifactRetentionConfig() cfgpkg.GatewayNodesArtifactsConfig {
- cfg := cfgpkg.DefaultConfig()
- if strings.TrimSpace(s.configPath) != "" {
- if loaded, err := cfgpkg.LoadConfig(s.configPath); err == nil && loaded != nil {
- cfg = loaded
- }
- }
- return cfg.Gateway.Nodes.Artifacts
-}
-
-func (s *Server) applyNodeArtifactRetention() map[string]interface{} {
- retention := s.nodeArtifactRetentionConfig()
- if !retention.Enabled || !retention.PruneOnRead || retention.KeepLatest <= 0 {
- summary := map[string]interface{}{
- "enabled": retention.Enabled,
- "keep_latest": retention.KeepLatest,
- "retain_days": retention.RetainDays,
- "prune_on_read": retention.PruneOnRead,
- "pruned": 0,
- "last_run_at": time.Now().UTC().Format(time.RFC3339),
- }
- s.setArtifactStats(summary)
- return summary
- }
- items := s.webUINodeArtifactsPayload(0)
- cutoff := time.Time{}
- if retention.RetainDays > 0 {
- cutoff = time.Now().UTC().Add(-time.Duration(retention.RetainDays) * 24 * time.Hour)
- }
- pruned := 0
- prunedByAge := 0
- prunedByCount := 0
- for index, item := range items {
- drop := false
- dropByAge := false
- if !cutoff.IsZero() {
- if tm, err := time.Parse(time.RFC3339, strings.TrimSpace(fmt.Sprint(item["time"]))); err == nil && tm.Before(cutoff) {
- drop = true
- dropByAge = true
- }
- }
- if !drop && index >= retention.KeepLatest {
- drop = true
- }
- if !drop {
- continue
- }
- _, deletedAudit, _ := s.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"])))
- if deletedAudit {
- pruned++
- if dropByAge {
- prunedByAge++
- } else {
- prunedByCount++
- }
- }
- }
- summary := map[string]interface{}{
- "enabled": true,
- "keep_latest": retention.KeepLatest,
- "retain_days": retention.RetainDays,
- "prune_on_read": retention.PruneOnRead,
- "pruned": pruned,
- "pruned_by_age": prunedByAge,
- "pruned_by_count": prunedByCount,
- "remaining": len(s.webUINodeArtifactsPayload(0)),
- "last_run_at": time.Now().UTC().Format(time.RFC3339),
- }
- s.setArtifactStats(summary)
- return summary
-}
-
-func (s *Server) deleteNodeArtifact(id string) (bool, bool, error) {
- id = strings.TrimSpace(id)
- if id == "" {
- return false, false, fmt.Errorf("id is required")
- }
- rows, auditPath := s.readNodeDispatchAuditRows()
- if len(rows) == 0 || auditPath == "" {
- return false, false, fmt.Errorf("artifact audit is empty")
- }
- deletedFile := false
- deletedAudit := false
- for rowIndex, row := range rows {
- artifacts, _ := row["artifacts"].([]interface{})
- if len(artifacts) == 0 {
- continue
- }
- nextArtifacts := make([]interface{}, 0, len(artifacts))
- for artifactIndex, raw := range artifacts {
- artifact, ok := raw.(map[string]interface{})
- if !ok {
- nextArtifacts = append(nextArtifacts, raw)
- continue
- }
- if buildNodeArtifactID(row, artifact, artifactIndex) != id {
- nextArtifacts = append(nextArtifacts, artifact)
- continue
- }
- for _, rawPath := range []string{fmt.Sprint(artifact["source_path"]), fmt.Sprint(artifact["path"])} {
- if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" {
- if err := os.Remove(path); err == nil {
- deletedFile = true
- break
- }
- }
- }
- deletedAudit = true
- }
- if deletedAudit {
- row["artifacts"] = nextArtifacts
- row["artifact_count"] = len(nextArtifacts)
- kinds := make([]string, 0, len(nextArtifacts))
- for _, raw := range nextArtifacts {
- if artifact, ok := raw.(map[string]interface{}); ok {
- if kind := strings.TrimSpace(fmt.Sprint(artifact["kind"])); kind != "" {
- kinds = append(kinds, kind)
- }
- }
- }
- if len(kinds) > 0 {
- row["artifact_kinds"] = kinds
- } else {
- delete(row, "artifact_kinds")
- }
- rows[rowIndex] = row
- break
- }
- }
- if !deletedAudit {
- return false, false, fmt.Errorf("artifact not found")
- }
- var buf bytes.Buffer
- for _, row := range rows {
- encoded, err := json.Marshal(row)
- if err != nil {
- continue
- }
- buf.Write(encoded)
- buf.WriteByte('\n')
- }
- if err := os.WriteFile(auditPath, buf.Bytes(), 0644); err != nil {
- return deletedFile, false, err
- }
- return deletedFile, true, nil
-}
-
-func (s *Server) webUISessionsPayload() map[string]interface{} {
- 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"})
- }
- return map[string]interface{}{"sessions": out}
-}
-
-func (s *Server) webUITaskQueuePayload(includeHeartbeat bool) map[string]interface{} {
- path := s.memoryFilePath("task-audit.jsonl")
- 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)
- }
- }
- queuePath := s.memoryFilePath("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 _, it := range arr {
- if row, ok := it.(map[string]interface{}); ok {
- running = append(running, row)
- }
- }
- }
- }
- }
- sort.Slice(items, func(i, j int) bool {
- return fmt.Sprintf("%v", items[i]["updated_at"]) > fmt.Sprintf("%v", items[j]["updated_at"])
- })
- sort.Slice(running, func(i, j int) bool {
- return fmt.Sprintf("%v", running[i]["updated_at"]) > fmt.Sprintf("%v", running[j]["updated_at"])
- })
- if len(items) > 30 {
- items = items[:30]
- }
- return map[string]interface{}{"items": items, "running": running}
-}
-
-func (s *Server) webUIEKGSummaryPayload(window string) map[string]interface{} {
- ekgPath := s.memoryFilePath("ekg-events.jsonl")
- window = strings.ToLower(strings.TrimSpace(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 && 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 != "" {
- providerScoreWorkload[provider] += 1
- if status == "success" {
- providerScore[provider] += 1
- } else if status == "error" {
- providerScore[provider] -= 2
- }
- }
- if errSig != "" {
- errSigWorkload[errSig]++
- if source == "heartbeat" {
- errSigHeartbeat[errSig]++
- } else if status == "error" {
- errSigCount[errSig]++
- }
- }
- }
- toTopScore := func(m map[string]float64, limit 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 {
- if out[i].Score == out[j].Score {
- return out[i].Key < out[j].Key
- }
- return out[i].Score > out[j].Score
- })
- if len(out) > limit {
- out = out[:limit]
- }
- return out
- }
- toTopCount := func(m map[string]int, limit 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 {
- if out[i].Count == out[j].Count {
- return out[i].Key < out[j].Key
- }
- return out[i].Count > out[j].Count
- })
- if len(out) > limit {
- out = out[:limit]
- }
- return out
- }
- return map[string]interface{}{
- "ok": true,
- "window": selectedWindow,
- "rows": len(rows),
- "provider_top_score": toTopScore(providerScore, 5),
- "provider_top_workload": toTopCount(mapFromFloatCounts(providerScoreWorkload), 5),
- "errsig_top": toTopCount(errSigCount, 5),
- "errsig_top_heartbeat": toTopCount(errSigHeartbeat, 5),
- "errsig_top_workload": toTopCount(errSigWorkload, 5),
- "source_top": toTopCount(sourceStats, 5),
- "channel_top": toTopCount(channelStats, 5),
- }
-}
-
-func mapFromFloatCounts(src map[string]float64) map[string]int {
- out := make(map[string]int, len(src))
- for k, v := range src {
- out[k] = int(v)
- }
- return out
-}
-
-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)
- }
- }
- serverChecks := []map[string]interface{}{}
- if strings.TrimSpace(s.configPath) != "" {
- if cfg, err := cfgpkg.LoadConfig(s.configPath); err == nil {
- serverChecks = buildMCPServerChecks(cfg)
- }
- }
- writeJSON(w, map[string]interface{}{
- "tools": toolsList,
- "mcp_tools": mcpItems,
- "mcp_server_checks": serverChecks,
- })
-}
-
-func buildMCPServerChecks(cfg *cfgpkg.Config) []map[string]interface{} {
- if cfg == nil {
- return nil
- }
- names := make([]string, 0, len(cfg.Tools.MCP.Servers))
- for name := range cfg.Tools.MCP.Servers {
- names = append(names, name)
- }
- sort.Strings(names)
- items := make([]map[string]interface{}, 0, len(names))
- for _, name := range names {
- server := cfg.Tools.MCP.Servers[name]
- transport := strings.ToLower(strings.TrimSpace(server.Transport))
- if transport == "" {
- transport = "stdio"
- }
- command := strings.TrimSpace(server.Command)
- status := "missing_command"
- message := "command is empty"
- resolved := ""
- missingCommand := false
- if !server.Enabled {
- status = "disabled"
- message = "server is disabled"
- } else if transport != "stdio" {
- status = "not_applicable"
- message = "command check not required for non-stdio transport"
- } else if command != "" {
- if filepath.IsAbs(command) {
- if info, err := os.Stat(command); err == nil && !info.IsDir() {
- status = "ok"
- message = "command found"
- resolved = command
- } else {
- status = "missing_command"
- message = fmt.Sprintf("command not found: %s", command)
- missingCommand = true
- }
- } else if path, err := exec.LookPath(command); err == nil {
- status = "ok"
- message = "command found"
- resolved = path
- } else {
- status = "missing_command"
- message = fmt.Sprintf("command not found in PATH: %s", command)
- missingCommand = true
- }
- }
- installSpec := inferMCPInstallSpec(server)
- items = append(items, map[string]interface{}{
- "name": name,
- "enabled": server.Enabled,
- "transport": transport,
- "status": status,
- "message": message,
- "command": command,
- "resolved": resolved,
- "package": installSpec.Package,
- "installer": installSpec.Installer,
- "installable": missingCommand && installSpec.AutoInstallSupported,
- "missing_command": missingCommand,
- })
- }
- return items
-}
-
-type mcpInstallSpec struct {
- Installer string
- Package string
- AutoInstallSupported bool
-}
-
-func inferMCPInstallSpec(server cfgpkg.MCPServerConfig) mcpInstallSpec {
- if pkgName := strings.TrimSpace(server.Package); pkgName != "" {
- return mcpInstallSpec{Installer: "npm", Package: pkgName, AutoInstallSupported: true}
- }
- command := strings.TrimSpace(server.Command)
- args := make([]string, 0, len(server.Args))
- for _, arg := range server.Args {
- if v := strings.TrimSpace(arg); v != "" {
- args = append(args, v)
- }
- }
- base := filepath.Base(command)
- switch base {
- case "npx":
- return mcpInstallSpec{Installer: "npm", Package: firstNonFlagArg(args), AutoInstallSupported: firstNonFlagArg(args) != ""}
- case "uvx":
- pkgName := firstNonFlagArg(args)
- return mcpInstallSpec{Installer: "uv", Package: pkgName, AutoInstallSupported: pkgName != ""}
- case "bunx":
- pkgName := firstNonFlagArg(args)
- return mcpInstallSpec{Installer: "bun", Package: pkgName, AutoInstallSupported: pkgName != ""}
- case "python", "python3":
- if len(args) >= 2 && args[0] == "-m" {
- return mcpInstallSpec{Installer: "pip", Package: strings.TrimSpace(args[1]), AutoInstallSupported: false}
- }
- }
- return mcpInstallSpec{}
-}
-
-func firstNonFlagArg(args []string) string {
- for _, arg := range args {
- item := strings.TrimSpace(arg)
- if item == "" || strings.HasPrefix(item, "-") {
- continue
- }
- return item
- }
- return ""
-}
-
-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"`
- Installer string `json:"installer"`
- }
- 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 := ensureMCPPackageInstalledWithInstaller(r.Context(), pkgName, body.Installer)
- 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
- }
- writeJSON(w, 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:
- payload := s.webUINodesPayload(r.Context())
- payload["ok"] = true
- writeJSON(w, payload)
- 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)
- writeJSON(w, map[string]interface{}{"ok": true, "deleted": ok, "id": id})
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
-}
-
-func (s *Server) handleWebUINodeDispatches(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
- }
- limit := queryBoundedPositiveInt(r, "limit", 50, 500)
- writeJSON(w, map[string]interface{}{
- "ok": true,
- "items": s.webUINodesDispatchPayload(limit),
- })
-}
-
-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 + "/api/config?mode=normalized"
- 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 s.fetchRemoteNodeRegistryLegacy(ctx, node)
- }
- var payload struct {
- OK bool `json:"ok"`
- Config cfgpkg.NormalizedConfig `json:"config"`
- RawConfig map[string]interface{} `json:"raw_config"`
- }
- if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
- return s.fetchRemoteNodeRegistryLegacy(ctx, node)
- }
- items := buildRegistryItemsFromNormalizedConfig(payload.Config)
- if len(items) > 0 {
- return items, nil
- }
- return s.fetchRemoteNodeRegistryLegacy(ctx, node)
-}
-
-func (s *Server) fetchRemoteNodeRegistryLegacy(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 + "/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 buildRegistryItemsFromNormalizedConfig(view cfgpkg.NormalizedConfig) []map[string]interface{} {
- items := make([]map[string]interface{}, 0, len(view.Core.Subagents))
- for agentID, subcfg := range view.Core.Subagents {
- if strings.TrimSpace(agentID) == "" {
- continue
- }
- items = append(items, map[string]interface{}{
- "agent_id": agentID,
- "enabled": subcfg.Enabled,
- "type": "subagent",
- "transport": fallbackString(strings.TrimSpace(subcfg.RuntimeClass), "local"),
- "node_id": "",
- "parent_agent_id": "",
- "notify_main_policy": "final_only",
- "display_name": "",
- "role": strings.TrimSpace(subcfg.Role),
- "description": "",
- "system_prompt_file": strings.TrimSpace(subcfg.Prompt),
- "prompt_file_found": false,
- "memory_namespace": "",
- "tool_allowlist": append([]string(nil), subcfg.ToolAllowlist...),
- "tool_visibility": map[string]interface{}{},
- "effective_tools": []string{},
- "inherited_tools": []string{},
- "routing_keywords": routeKeywordsForRegistry(view.Runtime.Router.Rules, agentID),
- "managed_by": "config.json",
- })
- }
- sort.Slice(items, func(i, j int) bool {
- return stringFromMap(items[i], "agent_id") < stringFromMap(items[j], "agent_id")
- })
- return items
-}
-
-func routeKeywordsForRegistry(rules []cfgpkg.AgentRouteRule, agentID string) []string {
- agentID = strings.TrimSpace(agentID)
- for _, rule := range rules {
- if strings.TrimSpace(rule.AgentID) == agentID {
- return append([]string(nil), rule.Keywords...)
- }
- }
- return 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 {
- return tools.MapStringArg(item, key)
-}
-
-func boolFromMap(item map[string]interface{}, key string) bool {
- if item == nil {
- return false
- }
- v, _ := tools.MapBoolArg(item, key)
- return v
-}
-
-func rawStringFromMap(item map[string]interface{}, key string) string {
- return tools.MapRawStringArg(item, key)
-}
-
-func stringListFromMap(item map[string]interface{}, key string) []string {
- return tools.MapStringListArg(item, key)
-}
-
-func intFromMap(item map[string]interface{}, key string, fallback int) int {
- return tools.MapIntArg(item, key, fallback)
-}
-
-func fallbackString(value, fallback string) string {
- value = strings.TrimSpace(value)
- if value != "" {
- return value
- }
- return strings.TrimSpace(fallback)
-}
-
-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 := stringFromMap(sch, "kind")
- if expr := stringFromMap(sch, "expr"); expr != "" {
- out["expr"] = expr
- } else if strings.EqualFold(strings.TrimSpace(kind), "every") {
- if every := intFromMap(sch, "everyMs", 0); every > 0 {
- out["expr"] = fmt.Sprintf("@every %s", (time.Duration(every) * time.Millisecond).String())
- }
- } else if strings.EqualFold(strings.TrimSpace(kind), "at") {
- if at := intFromMap(sch, "atMs", 0); 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 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 ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) {
- return ensureMCPPackageInstalledWithInstaller(ctx, pkgName, "npm")
-}
-
-func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, installer string) (output string, binName string, binPath string, err error) {
- pkgName = strings.TrimSpace(pkgName)
- if pkgName == "" {
- return "", "", "", fmt.Errorf("package empty")
- }
- installer = strings.ToLower(strings.TrimSpace(installer))
- if installer == "" {
- installer = "npm"
- }
- outs := make([]string, 0, 4)
- switch installer {
- case "npm":
- 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
- }
- case "uv":
- if !commandExists("uv") {
- return "", "", "", fmt.Errorf("uv is not installed; install uv first to auto-install %s", pkgName)
- }
- installOut, err := runInstallCommand(ctx, "uv tool install "+shellEscapeArg(pkgName))
- if installOut != "" {
- outs = append(outs, installOut)
- }
- if err != nil {
- return strings.Join(outs, "\n"), "", "", err
- }
- binName = guessSimpleCommandName(pkgName)
- case "bun":
- if !commandExists("bun") {
- return "", "", "", fmt.Errorf("bun is not installed; install bun first to auto-install %s", pkgName)
- }
- installOut, err := runInstallCommand(ctx, "bun add -g "+shellEscapeArg(pkgName))
- if installOut != "" {
- outs = append(outs, installOut)
- }
- if err != nil {
- return strings.Join(outs, "\n"), "", "", err
- }
- binName = guessSimpleCommandName(pkgName)
- default:
- return "", "", "", fmt.Errorf("unsupported installer: %s", installer)
- }
- 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 via %s", pkgName, installer))
- outs = append(outs, fmt.Sprintf("resolved binary: %s", binPath))
- return strings.Join(outs, "\n"), binName, binPath, nil
-}
-
-func guessSimpleCommandName(pkgName string) string {
- pkgName = strings.TrimSpace(pkgName)
- pkgName = strings.TrimPrefix(pkgName, "@")
- if idx := strings.LastIndex(pkgName, "/"); idx >= 0 {
- pkgName = pkgName[idx+1:]
- }
- return strings.TrimSpace(pkgName)
-}
-
-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 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)
- includeInternal := r.URL.Query().Get("include_internal") == "1"
- 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 !includeInternal && !isUserFacingSessionKey(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"})
- }
- writeJSON(w, map[string]interface{}{"ok": true, "sessions": out})
-}
-
-func isUserFacingSessionKey(key string) bool {
- k := strings.ToLower(strings.TrimSpace(key))
- if k == "" {
- return false
- }
- switch {
- case strings.HasPrefix(k, "subagent:"):
- return false
- case strings.HasPrefix(k, "internal:"):
- return false
- case strings.HasPrefix(k, "heartbeat:"):
- return false
- case strings.HasPrefix(k, "cron:"):
- return false
- case strings.HasPrefix(k, "hook:"):
- return false
- case strings.HasPrefix(k, "node:"):
- return false
- default:
- return true
- }
-}
-
-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
- }
- writeJSON(w, map[string]interface{}{
- "ok": true,
- "groups": tools.ToolAllowlistGroups(),
- })
-}
-
-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 := s.memoryFilePath("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 := s.memoryFilePath("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)}
- writeJSON(w, 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
- }
- ekgPath := s.memoryFilePath("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
- }
- writeJSON(w, 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) 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 := queryBoundedPositiveInt(r, "limit", 10, 200)
- 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:] {
- if parsed, ok := parseLogLine(ln); ok {
- out = append(out, parsed)
- }
- }
- writeJSON(w, map[string]interface{}{"ok": true, "logs": out})
-}
-
-func parseLogLine(line string) (map[string]interface{}, bool) {
- line = strings.TrimSpace(line)
- if line == "" {
- return nil, false
- }
- if json.Valid([]byte(line)) {
- var m map[string]interface{}
- if err := json.Unmarshal([]byte(line), &m); err == nil {
- return m, true
- }
- }
- return map[string]interface{}{
- "time": time.Now().UTC().Format(time.RFC3339),
- "level": "INFO",
- "msg": line,
- }, true
-}
-
-func (s *Server) handleWebUILogsLive(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
- }
- conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
- if err != nil {
- return
- }
- defer conn.Close()
-
- f, err := os.Open(path)
- if err != nil {
- _ = conn.WriteJSON(map[string]interface{}{"ok": false, "error": err.Error()})
- return
- }
- defer f.Close()
- fi, _ := f.Stat()
- if fi != nil {
- _, _ = f.Seek(fi.Size(), io.SeekStart)
- }
- reader := bufio.NewReader(f)
- ctx := r.Context()
- for {
- select {
- case <-ctx.Done():
- return
- default:
- line, err := reader.ReadString('\n')
- if parsed, ok := parseLogLine(line); ok {
- _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
- if writeErr := conn.WriteJSON(map[string]interface{}{"ok": true, "type": "log_entry", "entry": parsed}); writeErr != nil {
- return
- }
- }
- if err != nil {
- time.Sleep(500 * time.Millisecond)
- }
- }
- }
-}
-
func (s *Server) checkAuth(r *http.Request) bool {
if s.token == "" {
return true
@@ -3623,45 +374,3 @@ func (s *Server) checkAuth(r *http.Request) bool {
}
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": "models.providers.*", "name": "Providers", "description": "LLM provider registry and auth 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 = `
-
-ClawGo WebUI
-
-
-ClawGo WebUI
-Token:
-Config (dynamic + hot reload)
-
-
-
-Chat (supports media upload)
-Session:
-
-`
diff --git a/pkg/api/server_chat_whatsapp.go b/pkg/api/server_chat_whatsapp.go
new file mode 100644
index 0000000..9d1a216
--- /dev/null
+++ b/pkg/api/server_chat_whatsapp.go
@@ -0,0 +1,411 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/YspCoder/clawgo/pkg/channels"
+ cfgpkg "github.com/YspCoder/clawgo/pkg/config"
+ "rsc.io/qr"
+)
+
+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
+ }
+ writeJSON(w, 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 {
+ writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": []interface{}{}})
+ return
+ }
+ writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)})
+}
+
+func (s *Server) handleWebUIChatLive(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
+ }
+ if s.onChat == nil {
+ http.Error(w, "chat handler not configured", http.StatusInternalServerError)
+ return
+ }
+ conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ var body struct {
+ Session string `json:"session"`
+ Message string `json:"message"`
+ Media string `json:"media"`
+ }
+ if err := conn.ReadJSON(&body); err != nil {
+ _ = conn.WriteJSON(map[string]interface{}{"ok": false, "type": "chat_error", "error": "invalid json"})
+ 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 {
+ _ = conn.WriteJSON(map[string]interface{}{"ok": false, "type": "chat_error", "error": err.Error(), "session": session})
+ return
+ }
+ chunk := 180
+ for i := 0; i < len(resp); i += chunk {
+ end := i + chunk
+ if end > len(resp) {
+ end = len(resp)
+ }
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if err := conn.WriteJSON(map[string]interface{}{
+ "ok": true,
+ "type": "chat_chunk",
+ "session": session,
+ "delta": resp[i:end],
+ }); err != nil {
+ return
+ }
+ }
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ _ = conn.WriteJSON(map[string]interface{}{
+ "ok": true,
+ "type": "chat_done",
+ "session": session,
+ })
+}
+
+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
+ }
+ writeJSON(w, map[string]interface{}{
+ "ok": true,
+ "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
+ "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
+ "compiled_channels": channels.CompiledChannelKeys(),
+ })
+}
+
+func (s *Server) handleWebUIWhatsAppStatus(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
+ }
+ payload, code := s.webUIWhatsAppStatusPayload(r.Context())
+ writeJSONStatus(w, code, payload)
+}
+
+func (s *Server) handleWebUIWhatsAppLogout(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
+ }
+ cfg, err := s.loadConfig()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ logoutURL, err := channels.BridgeLogoutURL(s.resolveWhatsAppBridgeURL(cfg))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, logoutURL, nil)
+ resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body)
+}
+
+func (s *Server) handleWebUIWhatsAppQR(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
+ }
+ payload, code := s.webUIWhatsAppStatusPayload(r.Context())
+ status, _ := payload["status"].(map[string]interface{})
+ qrCode := ""
+ if status != nil {
+ qrCode = stringFromMap(status, "qr_code")
+ }
+ if code != http.StatusOK || strings.TrimSpace(qrCode) == "" {
+ http.Error(w, "qr unavailable", http.StatusNotFound)
+ return
+ }
+ qrImage, err := qr.Encode(strings.TrimSpace(qrCode), qr.M)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "image/svg+xml")
+ _, _ = io.WriteString(w, renderQRCodeSVG(qrImage, 8, 24))
+}
+
+func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]interface{}, int) {
+ cfg, err := s.loadConfig()
+ if err != nil {
+ return map[string]interface{}{"ok": false, "error": err.Error()}, http.StatusInternalServerError
+ }
+ waCfg := cfg.Channels.WhatsApp
+ bridgeURL := s.resolveWhatsAppBridgeURL(cfg)
+ statusURL, err := channels.BridgeStatusURL(bridgeURL)
+ if err != nil {
+ return map[string]interface{}{
+ "ok": false,
+ "enabled": waCfg.Enabled,
+ "bridge_url": bridgeURL,
+ "error": err.Error(),
+ }, http.StatusBadRequest
+ }
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
+ resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req)
+ if err != nil {
+ return map[string]interface{}{
+ "ok": false,
+ "enabled": waCfg.Enabled,
+ "bridge_url": bridgeURL,
+ "bridge_running": false,
+ "error": err.Error(),
+ }, http.StatusOK
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ return map[string]interface{}{
+ "ok": false,
+ "enabled": waCfg.Enabled,
+ "bridge_url": bridgeURL,
+ "bridge_running": false,
+ "error": strings.TrimSpace(string(body)),
+ }, http.StatusOK
+ }
+ var status channels.WhatsAppBridgeStatus
+ if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
+ return map[string]interface{}{
+ "ok": false,
+ "enabled": waCfg.Enabled,
+ "bridge_url": bridgeURL,
+ "bridge_running": false,
+ "error": err.Error(),
+ }, http.StatusOK
+ }
+ return map[string]interface{}{
+ "ok": true,
+ "enabled": waCfg.Enabled,
+ "bridge_url": bridgeURL,
+ "bridge_running": true,
+ "status": map[string]interface{}{
+ "state": status.State,
+ "connected": status.Connected,
+ "logged_in": status.LoggedIn,
+ "bridge_addr": status.BridgeAddr,
+ "user_jid": status.UserJID,
+ "push_name": status.PushName,
+ "platform": status.Platform,
+ "qr_available": status.QRAvailable,
+ "qr_code": status.QRCode,
+ "last_event": status.LastEvent,
+ "last_error": status.LastError,
+ "updated_at": status.UpdatedAt,
+ },
+ }, http.StatusOK
+}
+
+func (s *Server) loadWhatsAppConfig() (cfgpkg.WhatsAppConfig, error) {
+ cfg, err := s.loadConfig()
+ if err != nil {
+ return cfgpkg.WhatsAppConfig{}, err
+ }
+ return cfg.Channels.WhatsApp, nil
+}
+
+func (s *Server) loadConfig() (*cfgpkg.Config, error) {
+ configPath := strings.TrimSpace(s.configPath)
+ if configPath == "" {
+ configPath = filepath.Join(cfgpkg.GetConfigDir(), "config.json")
+ }
+ return cfgpkg.LoadConfig(configPath)
+}
+
+func (s *Server) resolveWhatsAppBridgeURL(cfg *cfgpkg.Config) string {
+ if cfg == nil {
+ return ""
+ }
+ raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL)
+ if raw == "" {
+ return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
+ }
+ hostPort := comparableBridgeHostPort(raw)
+ if hostPort == "" {
+ return raw
+ }
+ if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" {
+ return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
+ }
+ if hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port) {
+ return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
+ }
+ return raw
+}
+
+func embeddedWhatsAppBridgeURL(host string, port int) string {
+ host = strings.TrimSpace(host)
+ switch host {
+ case "", "0.0.0.0", "::", "[::]":
+ host = "127.0.0.1"
+ }
+ return fmt.Sprintf("ws://%s:%d/whatsapp/ws", host, port)
+}
+
+func comparableBridgeHostPort(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+ if !strings.Contains(raw, "://") {
+ return strings.ToLower(raw)
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return ""
+ }
+ return strings.ToLower(strings.TrimSpace(u.Host))
+}
+
+func comparableGatewayHostPort(host string, port int) string {
+ host = strings.TrimSpace(strings.ToLower(host))
+ switch host {
+ case "", "0.0.0.0", "::", "[::]":
+ host = "127.0.0.1"
+ }
+ return fmt.Sprintf("%s:%d", host, port)
+}
+
+func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string {
+ if code == nil || code.Size <= 0 {
+ return ""
+ }
+ if scale <= 0 {
+ scale = 8
+ }
+ if quietZone < 0 {
+ quietZone = 0
+ }
+ total := (code.Size + quietZone*2) * scale
+ var b strings.Builder
+ b.Grow(total * 8)
+ b.WriteString(fmt.Sprintf(``)
+ return b.String()
+}
diff --git a/pkg/api/server_common_helpers.go b/pkg/api/server_common_helpers.go
new file mode 100644
index 0000000..ca358a2
--- /dev/null
+++ b/pkg/api/server_common_helpers.go
@@ -0,0 +1,292 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/YspCoder/clawgo/pkg/tools"
+)
+
+func resolveRelativeFilePath(root, raw string) (string, string, error) {
+ root = strings.TrimSpace(root)
+ if root == "" {
+ return "", "", fmt.Errorf("workspace not configured")
+ }
+ clean := filepath.Clean(strings.TrimSpace(raw))
+ if clean == "." || clean == "" || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
+ return "", "", fmt.Errorf("invalid path")
+ }
+ full := filepath.Join(root, clean)
+ cleanRoot := filepath.Clean(root)
+ if full != cleanRoot {
+ prefix := cleanRoot + string(os.PathSeparator)
+ if !strings.HasPrefix(filepath.Clean(full), prefix) {
+ return "", "", fmt.Errorf("invalid path")
+ }
+ }
+ return clean, full, nil
+}
+
+func relativeFilePathStatus(err error) int {
+ if err == nil {
+ return 200
+ }
+ switch {
+ case err.Error() == "workspace not configured":
+ return 500
+ default:
+ return 400
+ }
+}
+
+func readRelativeTextFile(root, raw string) (string, string, bool, error) {
+ clean, full, err := resolveRelativeFilePath(root, raw)
+ if err != nil {
+ return "", "", false, err
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return clean, "", false, nil
+ }
+ return clean, "", false, err
+ }
+ return clean, string(b), true, nil
+}
+
+func writeRelativeTextFile(root, raw string, content string, ensureDir bool) (string, error) {
+ clean, full, err := resolveRelativeFilePath(root, raw)
+ if err != nil {
+ return "", err
+ }
+ if ensureDir {
+ if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
+ return "", err
+ }
+ }
+ if err := os.WriteFile(full, []byte(content), 0644); err != nil {
+ return "", err
+ }
+ return clean, nil
+}
+
+func (s *Server) memoryFilePath(name string) string {
+ workspace := strings.TrimSpace(s.workspacePath)
+ if workspace == "" {
+ return ""
+ }
+ return filepath.Join(workspace, "memory", strings.TrimSpace(name))
+}
+
+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
+ }
+ children, _ := parent["children"].([]map[string]interface{})
+ parent["children"] = append(children, 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 {
+ return tools.MapStringArg(item, key)
+}
+
+func boolFromMap(item map[string]interface{}, key string) bool {
+ if item == nil {
+ return false
+ }
+ v, _ := tools.MapBoolArg(item, key)
+ return v
+}
+
+func rawStringFromMap(item map[string]interface{}, key string) string {
+ return tools.MapRawStringArg(item, key)
+}
+
+func stringListFromMap(item map[string]interface{}, key string) []string {
+ return tools.MapStringListArg(item, key)
+}
+
+func intFromMap(item map[string]interface{}, key string, fallback int) int {
+ return tools.MapIntArg(item, key, fallback)
+}
+
+func fallbackString(value, fallback string) string {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ return value
+ }
+ return strings.TrimSpace(fallback)
+}
+
+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()
+ }
+ }
+ }
+ 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 := stringFromMap(sch, "kind")
+ if expr := stringFromMap(sch, "expr"); expr != "" {
+ out["expr"] = expr
+ } else if strings.EqualFold(strings.TrimSpace(kind), "every") {
+ if every := intFromMap(sch, "everyMs", 0); every > 0 {
+ out["expr"] = fmt.Sprintf("@every %s", (time.Duration(every) * time.Millisecond).String())
+ }
+ } else if strings.EqualFold(strings.TrimSpace(kind), "at") {
+ if at := intFromMap(sch, "atMs", 0); 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
+}
diff --git a/pkg/api/server_config_helpers.go b/pkg/api/server_config_helpers.go
new file mode 100644
index 0000000..c63b99b
--- /dev/null
+++ b/pkg/api/server_config_helpers.go
@@ -0,0 +1,114 @@
+package api
+
+import (
+ "sort"
+ "strings"
+)
+
+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 collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string {
+ paths := []string{
+ "channels.telegram.token",
+ "channels.telegram.allow_from",
+ "channels.telegram.allow_chats",
+ "models.providers.openai.api_base",
+ "models.providers.openai.api_key",
+ "runtime.providers.openai.api_base",
+ "runtime.providers.openai.api_key",
+ "gateway.token",
+ "gateway.port",
+ }
+ seen := map[string]bool{}
+ for _, path := range paths {
+ seen[path] = true
+ }
+ for _, name := range collectProviderNames(oldMap, newMap) {
+ for _, field := range []string{"api_base", "api_key"} {
+ path := "models.providers." + name + "." + field
+ if !seen[path] {
+ paths = append(paths, path)
+ seen[path] = true
+ }
+ normalizedPath := "runtime.providers." + name + "." + field
+ if !seen[normalizedPath] {
+ paths = append(paths, normalizedPath)
+ seen[normalizedPath] = true
+ }
+ }
+ }
+ return paths
+}
+
+func collectProviderNames(maps ...map[string]interface{}) []string {
+ seen := map[string]bool{}
+ names := make([]string, 0)
+ for _, root := range maps {
+ models, _ := root["models"].(map[string]interface{})
+ providers, _ := models["providers"].(map[string]interface{})
+ for name := range providers {
+ if strings.TrimSpace(name) == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+ names = append(names, name)
+ }
+ runtimeMap, _ := root["runtime"].(map[string]interface{})
+ runtimeProviders, _ := runtimeMap["providers"].(map[string]interface{})
+ for name := range runtimeProviders {
+ if strings.TrimSpace(name) == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+ names = append(names, name)
+ }
+ }
+ sort.Strings(names)
+ return names
+}
+
+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": "models.providers.*", "name": "Providers", "description": "LLM provider registry and auth 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"},
+ }
+}
diff --git a/pkg/api/server_install_helpers.go b/pkg/api/server_install_helpers.go
new file mode 100644
index 0000000..09d4d63
--- /dev/null
+++ b/pkg/api/server_install_helpers.go
@@ -0,0 +1,340 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+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 ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) {
+ return ensureMCPPackageInstalledWithInstaller(ctx, pkgName, "npm")
+}
+
+func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, installer string) (output string, binName string, binPath string, err error) {
+ pkgName = strings.TrimSpace(pkgName)
+ if pkgName == "" {
+ return "", "", "", fmt.Errorf("package empty")
+ }
+ installer = strings.ToLower(strings.TrimSpace(installer))
+ if installer == "" {
+ installer = "npm"
+ }
+ outs := make([]string, 0, 4)
+ switch installer {
+ case "npm":
+ 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
+ }
+ case "uv":
+ if !commandExists("uv") {
+ return "", "", "", fmt.Errorf("uv is not installed; install uv first to auto-install %s", pkgName)
+ }
+ installOut, err := runInstallCommand(ctx, "uv tool install "+shellEscapeArg(pkgName))
+ if installOut != "" {
+ outs = append(outs, installOut)
+ }
+ if err != nil {
+ return strings.Join(outs, "\n"), "", "", err
+ }
+ binName = guessSimpleCommandName(pkgName)
+ case "bun":
+ if !commandExists("bun") {
+ return "", "", "", fmt.Errorf("bun is not installed; install bun first to auto-install %s", pkgName)
+ }
+ installOut, err := runInstallCommand(ctx, "bun add -g "+shellEscapeArg(pkgName))
+ if installOut != "" {
+ outs = append(outs, installOut)
+ }
+ if err != nil {
+ return strings.Join(outs, "\n"), "", "", err
+ }
+ binName = guessSimpleCommandName(pkgName)
+ default:
+ return "", "", "", fmt.Errorf("unsupported installer: %s", installer)
+ }
+ 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 via %s", pkgName, installer))
+ outs = append(outs, fmt.Sprintf("resolved binary: %s", binPath))
+ return strings.Join(outs, "\n"), binName, binPath, nil
+}
+
+func guessSimpleCommandName(pkgName string) string {
+ pkgName = strings.TrimSpace(pkgName)
+ pkgName = strings.TrimPrefix(pkgName, "@")
+ if idx := strings.LastIndex(pkgName, "/"); idx >= 0 {
+ pkgName = pkgName[idx+1:]
+ }
+ return strings.TrimSpace(pkgName)
+}
+
+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 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
+}
diff --git a/pkg/api/server_live.go b/pkg/api/server_live.go
new file mode 100644
index 0000000..ace918e
--- /dev/null
+++ b/pkg/api/server_live.go
@@ -0,0 +1,186 @@
+package api
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/YspCoder/clawgo/pkg/nodes"
+ "github.com/gorilla/websocket"
+)
+
+func publishLiveSnapshot(subs map[chan []byte]struct{}, payload []byte) {
+ for ch := range subs {
+ select {
+ case ch <- payload:
+ default:
+ select {
+ case <-ch:
+ default:
+ }
+ select {
+ case ch <- payload:
+ default:
+ }
+ }
+ }
+}
+
+func (s *Server) subscribeRuntimeLive(ctx context.Context) chan []byte {
+ ch := make(chan []byte, 1)
+ s.liveRuntimeMu.Lock()
+ s.liveRuntimeSubs[ch] = struct{}{}
+ start := !s.liveRuntimeOn
+ if start {
+ s.liveRuntimeOn = true
+ }
+ s.liveRuntimeMu.Unlock()
+ if start {
+ go s.runtimeLiveLoop()
+ }
+ go func() {
+ <-ctx.Done()
+ s.unsubscribeRuntimeLive(ch)
+ }()
+ return ch
+}
+
+func (s *Server) unsubscribeRuntimeLive(ch chan []byte) {
+ s.liveRuntimeMu.Lock()
+ delete(s.liveRuntimeSubs, ch)
+ s.liveRuntimeMu.Unlock()
+}
+
+func (s *Server) runtimeLiveLoop() {
+ ticker := time.NewTicker(10 * time.Second)
+ defer ticker.Stop()
+ for {
+ if !s.publishRuntimeSnapshot(context.Background()) {
+ s.liveRuntimeMu.Lock()
+ if len(s.liveRuntimeSubs) == 0 {
+ s.liveRuntimeOn = false
+ s.liveRuntimeMu.Unlock()
+ return
+ }
+ s.liveRuntimeMu.Unlock()
+ }
+ <-ticker.C
+ }
+}
+
+func (s *Server) publishRuntimeSnapshot(ctx context.Context) bool {
+ if s == nil {
+ return false
+ }
+ payload := map[string]interface{}{
+ "ok": true,
+ "type": "runtime_snapshot",
+ "snapshot": s.buildWebUIRuntimeSnapshot(ctx),
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return false
+ }
+ s.liveRuntimeMu.Lock()
+ defer s.liveRuntimeMu.Unlock()
+ if len(s.liveRuntimeSubs) == 0 {
+ return false
+ }
+ publishLiveSnapshot(s.liveRuntimeSubs, data)
+ return true
+}
+
+func parseLogLine(line string) (map[string]interface{}, bool) {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return nil, false
+ }
+ if json.Valid([]byte(line)) {
+ var m map[string]interface{}
+ if err := json.Unmarshal([]byte(line), &m); err == nil {
+ return m, true
+ }
+ }
+ return map[string]interface{}{
+ "time": time.Now().UTC().Format(time.RFC3339),
+ "level": "INFO",
+ "msg": line,
+ }, true
+}
+
+func (s *Server) handleWebUILogsLive(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
+ }
+ conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ f, err := os.Open(path)
+ if err != nil {
+ _ = conn.WriteJSON(map[string]interface{}{"ok": false, "error": err.Error()})
+ return
+ }
+ defer f.Close()
+ fi, _ := f.Stat()
+ if fi != nil {
+ _, _ = f.Seek(fi.Size(), io.SeekStart)
+ }
+ reader := bufio.NewReader(f)
+ ctx := r.Context()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ line, err := reader.ReadString('\n')
+ if parsed, ok := parseLogLine(line); ok {
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if writeErr := conn.WriteJSON(map[string]interface{}{"ok": true, "type": "log_entry", "entry": parsed}); writeErr != nil {
+ return
+ }
+ }
+ if err != nil {
+ time.Sleep(500 * time.Millisecond)
+ }
+ }
+ }
+}
+
+type nodeSocketConn struct {
+ connID string
+ conn *websocket.Conn
+ mu sync.Mutex
+}
+
+func (c *nodeSocketConn) writeJSON(payload interface{}) error {
+ if c == nil || c.conn == nil {
+ return fmt.Errorf("node websocket unavailable")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ return c.conn.WriteJSON(payload)
+}
+
+func (c *nodeSocketConn) Send(msg nodes.WireMessage) error {
+ return c.writeJSON(msg)
+}
diff --git a/pkg/api/server_node_artifacts.go b/pkg/api/server_node_artifacts.go
index ef86f6d..8ac6f12 100644
--- a/pkg/api/server_node_artifacts.go
+++ b/pkg/api/server_node_artifacts.go
@@ -13,9 +13,39 @@ import (
"strings"
"time"
+ cfgpkg "github.com/YspCoder/clawgo/pkg/config"
"github.com/YspCoder/clawgo/pkg/nodes"
)
+func (s *Server) webUINodeArtifactsPayload(limit int) []map[string]interface{} {
+ return s.webUINodeArtifactsPayloadFiltered("", "", "", limit)
+}
+
+func (s *Server) readNodeDispatchAuditRows() ([]map[string]interface{}, string) {
+ path := s.memoryFilePath("nodes-dispatch-audit.jsonl")
+ if path == "" {
+ return nil, ""
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, path
+ }
+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+ rows := make([]map[string]interface{}, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ row := map[string]interface{}{}
+ if err := json.Unmarshal([]byte(line), &row); err != nil {
+ continue
+ }
+ rows = append(rows, row)
+ }
+ return rows, path
+}
+
func (s *Server) webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter string, limit int) []map[string]interface{} {
nodeFilter = strings.TrimSpace(nodeFilter)
actionFilter = strings.TrimSpace(actionFilter)
@@ -65,6 +95,177 @@ func (s *Server) webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kin
return out
}
+func (s *Server) setArtifactStats(summary map[string]interface{}) {
+ s.artifactStatsMu.Lock()
+ defer s.artifactStatsMu.Unlock()
+ if summary == nil {
+ s.artifactStats = map[string]interface{}{}
+ return
+ }
+ copySummary := make(map[string]interface{}, len(summary))
+ for k, v := range summary {
+ copySummary[k] = v
+ }
+ s.artifactStats = copySummary
+}
+
+func (s *Server) artifactStatsSnapshot() map[string]interface{} {
+ s.artifactStatsMu.Lock()
+ defer s.artifactStatsMu.Unlock()
+ out := make(map[string]interface{}, len(s.artifactStats))
+ for k, v := range s.artifactStats {
+ out[k] = v
+ }
+ return out
+}
+
+func (s *Server) nodeArtifactRetentionConfig() cfgpkg.GatewayNodesArtifactsConfig {
+ cfg := cfgpkg.DefaultConfig()
+ if strings.TrimSpace(s.configPath) != "" {
+ if loaded, err := cfgpkg.LoadConfig(s.configPath); err == nil && loaded != nil {
+ cfg = loaded
+ }
+ }
+ return cfg.Gateway.Nodes.Artifacts
+}
+
+func (s *Server) applyNodeArtifactRetention() map[string]interface{} {
+ retention := s.nodeArtifactRetentionConfig()
+ if !retention.Enabled || !retention.PruneOnRead || retention.KeepLatest <= 0 {
+ summary := map[string]interface{}{
+ "enabled": retention.Enabled,
+ "keep_latest": retention.KeepLatest,
+ "retain_days": retention.RetainDays,
+ "prune_on_read": retention.PruneOnRead,
+ "pruned": 0,
+ "last_run_at": time.Now().UTC().Format(time.RFC3339),
+ }
+ s.setArtifactStats(summary)
+ return summary
+ }
+ items := s.webUINodeArtifactsPayload(0)
+ cutoff := time.Time{}
+ if retention.RetainDays > 0 {
+ cutoff = time.Now().UTC().Add(-time.Duration(retention.RetainDays) * 24 * time.Hour)
+ }
+ pruned := 0
+ prunedByAge := 0
+ prunedByCount := 0
+ for index, item := range items {
+ drop := false
+ dropByAge := false
+ if !cutoff.IsZero() {
+ if tm, err := time.Parse(time.RFC3339, strings.TrimSpace(fmt.Sprint(item["time"]))); err == nil && tm.Before(cutoff) {
+ drop = true
+ dropByAge = true
+ }
+ }
+ if !drop && index >= retention.KeepLatest {
+ drop = true
+ }
+ if !drop {
+ continue
+ }
+ _, deletedAudit, _ := s.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"])))
+ if deletedAudit {
+ pruned++
+ if dropByAge {
+ prunedByAge++
+ } else {
+ prunedByCount++
+ }
+ }
+ }
+ summary := map[string]interface{}{
+ "enabled": true,
+ "keep_latest": retention.KeepLatest,
+ "retain_days": retention.RetainDays,
+ "prune_on_read": retention.PruneOnRead,
+ "pruned": pruned,
+ "pruned_by_age": prunedByAge,
+ "pruned_by_count": prunedByCount,
+ "remaining": len(s.webUINodeArtifactsPayload(0)),
+ "last_run_at": time.Now().UTC().Format(time.RFC3339),
+ }
+ s.setArtifactStats(summary)
+ return summary
+}
+
+func (s *Server) deleteNodeArtifact(id string) (bool, bool, error) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return false, false, fmt.Errorf("id is required")
+ }
+ rows, auditPath := s.readNodeDispatchAuditRows()
+ if len(rows) == 0 || auditPath == "" {
+ return false, false, fmt.Errorf("artifact audit is empty")
+ }
+ deletedFile := false
+ deletedAudit := false
+ for rowIndex, row := range rows {
+ artifacts, _ := row["artifacts"].([]interface{})
+ if len(artifacts) == 0 {
+ continue
+ }
+ nextArtifacts := make([]interface{}, 0, len(artifacts))
+ for artifactIndex, raw := range artifacts {
+ artifact, ok := raw.(map[string]interface{})
+ if !ok {
+ nextArtifacts = append(nextArtifacts, raw)
+ continue
+ }
+ if buildNodeArtifactID(row, artifact, artifactIndex) != id {
+ nextArtifacts = append(nextArtifacts, artifact)
+ continue
+ }
+ for _, rawPath := range []string{fmt.Sprint(artifact["source_path"]), fmt.Sprint(artifact["path"])} {
+ if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" {
+ if err := os.Remove(path); err == nil {
+ deletedFile = true
+ break
+ }
+ }
+ }
+ deletedAudit = true
+ }
+ if deletedAudit {
+ row["artifacts"] = nextArtifacts
+ row["artifact_count"] = len(nextArtifacts)
+ kinds := make([]string, 0, len(nextArtifacts))
+ for _, raw := range nextArtifacts {
+ if artifact, ok := raw.(map[string]interface{}); ok {
+ if kind := strings.TrimSpace(fmt.Sprint(artifact["kind"])); kind != "" {
+ kinds = append(kinds, kind)
+ }
+ }
+ }
+ if len(kinds) > 0 {
+ row["artifact_kinds"] = kinds
+ } else {
+ delete(row, "artifact_kinds")
+ }
+ rows[rowIndex] = row
+ break
+ }
+ }
+ if !deletedAudit {
+ return false, false, fmt.Errorf("artifact not found")
+ }
+ var buf bytes.Buffer
+ for _, row := range rows {
+ encoded, err := json.Marshal(row)
+ if err != nil {
+ continue
+ }
+ buf.Write(encoded)
+ buf.WriteByte('\n')
+ }
+ if err := os.WriteFile(auditPath, buf.Bytes(), 0644); err != nil {
+ return deletedFile, false, err
+ }
+ return deletedFile, true, nil
+}
+
func buildNodeArtifactID(row, artifact map[string]interface{}, artifactIndex int) string {
seed := fmt.Sprintf("%v|%v|%v|%d|%v|%v|%v",
row["time"], row["node"], row["action"], artifactIndex,
diff --git a/pkg/api/server_nodes_gateway.go b/pkg/api/server_nodes_gateway.go
new file mode 100644
index 0000000..f38716a
--- /dev/null
+++ b/pkg/api/server_nodes_gateway.go
@@ -0,0 +1,273 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/YspCoder/clawgo/pkg/nodes"
+ "github.com/gorilla/websocket"
+)
+
+func (s *Server) SetNodeWebRTCTransport(t *nodes.WebRTCTransport) {
+ s.nodeWebRTC = t
+}
+
+func (s *Server) SetNodeP2PStatusHandler(fn func() map[string]interface{}) {
+ s.nodeP2PStatus = fn
+}
+
+func (s *Server) rememberNodeConnection(nodeID, connID string) {
+ nodeID = strings.TrimSpace(nodeID)
+ connID = strings.TrimSpace(connID)
+ if nodeID == "" || connID == "" {
+ return
+ }
+ s.nodeConnMu.Lock()
+ defer s.nodeConnMu.Unlock()
+ s.nodeConnIDs[nodeID] = connID
+}
+
+func (s *Server) bindNodeSocket(nodeID, connID string, conn *websocket.Conn) {
+ nodeID = strings.TrimSpace(nodeID)
+ connID = strings.TrimSpace(connID)
+ if nodeID == "" || connID == "" || conn == nil {
+ return
+ }
+ next := &nodeSocketConn{connID: connID, conn: conn}
+ s.nodeConnMu.Lock()
+ prev := s.nodeSockets[nodeID]
+ s.nodeSockets[nodeID] = next
+ s.nodeConnMu.Unlock()
+ if s.mgr != nil {
+ s.mgr.RegisterWireSender(nodeID, next)
+ }
+ if s.nodeWebRTC != nil {
+ s.nodeWebRTC.BindSignaler(nodeID, next)
+ }
+ if prev != nil && prev.connID != connID {
+ _ = prev.conn.Close()
+ }
+}
+
+func (s *Server) releaseNodeConnection(nodeID, connID string) bool {
+ nodeID = strings.TrimSpace(nodeID)
+ connID = strings.TrimSpace(connID)
+ if nodeID == "" || connID == "" {
+ return false
+ }
+ s.nodeConnMu.Lock()
+ defer s.nodeConnMu.Unlock()
+ if s.nodeConnIDs[nodeID] != connID {
+ return false
+ }
+ delete(s.nodeConnIDs, nodeID)
+ if sock := s.nodeSockets[nodeID]; sock != nil && sock.connID == connID {
+ delete(s.nodeSockets, nodeID)
+ }
+ if s.mgr != nil {
+ s.mgr.RegisterWireSender(nodeID, nil)
+ }
+ if s.nodeWebRTC != nil {
+ s.nodeWebRTC.UnbindSignaler(nodeID)
+ }
+ return true
+}
+
+func (s *Server) getNodeSocket(nodeID string) *nodeSocketConn {
+ nodeID = strings.TrimSpace(nodeID)
+ if nodeID == "" {
+ return nil
+ }
+ s.nodeConnMu.Lock()
+ defer s.nodeConnMu.Unlock()
+ return s.nodeSockets[nodeID]
+}
+
+func (s *Server) sendNodeSocketMessage(nodeID string, msg nodes.WireMessage) error {
+ sock := s.getNodeSocket(nodeID)
+ if sock == nil || sock.conn == nil {
+ return fmt.Errorf("node %s not connected", strings.TrimSpace(nodeID))
+ }
+ return sock.writeJSON(msg)
+}
+
+func (s *Server) handleNodeConnect(w http.ResponseWriter, r *http.Request) {
+ if !s.checkAuth(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if s.mgr == nil {
+ http.Error(w, "nodes manager unavailable", http.StatusInternalServerError)
+ return
+ }
+ conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ var connectedID string
+ connID := fmt.Sprintf("%d", time.Now().UnixNano())
+ _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
+ conn.SetPongHandler(func(string) error {
+ return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
+ })
+
+ writeAck := func(ack nodes.WireAck) error {
+ if strings.TrimSpace(connectedID) != "" {
+ if sock := s.getNodeSocket(connectedID); sock != nil && sock.connID == connID && sock.conn == conn {
+ return sock.writeJSON(ack)
+ }
+ }
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ return conn.WriteJSON(ack)
+ }
+
+ defer func() {
+ if strings.TrimSpace(connectedID) != "" && s.releaseNodeConnection(connectedID, connID) {
+ s.mgr.MarkOffline(connectedID)
+ }
+ }()
+
+ for {
+ var msg nodes.WireMessage
+ if err := conn.ReadJSON(&msg); err != nil {
+ return
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
+ if s.mgr != nil && s.mgr.HandleWireMessage(msg) {
+ continue
+ }
+ type nodeSocketHandler func(nodes.WireMessage) bool
+ handlers := map[string]nodeSocketHandler{
+ "register": func(msg nodes.WireMessage) bool {
+ if msg.Node == nil || strings.TrimSpace(msg.Node.ID) == "" {
+ _ = writeAck(nodes.WireAck{OK: false, Type: "register", Error: "node.id required"})
+ return true
+ }
+ s.mgr.Upsert(*msg.Node)
+ connectedID = strings.TrimSpace(msg.Node.ID)
+ s.rememberNodeConnection(connectedID, connID)
+ s.bindNodeSocket(connectedID, connID, conn)
+ return writeAck(nodes.WireAck{OK: true, Type: "registered", ID: connectedID}) == nil
+ },
+ "heartbeat": func(msg nodes.WireMessage) bool {
+ id := strings.TrimSpace(msg.ID)
+ if id == "" {
+ id = connectedID
+ }
+ if id == "" {
+ _ = writeAck(nodes.WireAck{OK: false, Type: "heartbeat", Error: "id required"})
+ return true
+ }
+ if msg.Node != nil && strings.TrimSpace(msg.Node.ID) != "" {
+ s.mgr.Upsert(*msg.Node)
+ connectedID = strings.TrimSpace(msg.Node.ID)
+ s.rememberNodeConnection(connectedID, connID)
+ s.bindNodeSocket(connectedID, connID, conn)
+ } else if n, ok := s.mgr.Get(id); ok {
+ s.mgr.Upsert(n)
+ connectedID = id
+ s.rememberNodeConnection(connectedID, connID)
+ s.bindNodeSocket(connectedID, connID, conn)
+ } else {
+ _ = writeAck(nodes.WireAck{OK: false, Type: "heartbeat", ID: id, Error: "node not found"})
+ return true
+ }
+ return writeAck(nodes.WireAck{OK: true, Type: "heartbeat", ID: connectedID}) == nil
+ },
+ "signal_offer": func(msg nodes.WireMessage) bool { return s.handleNodeSignalMessage(msg, connectedID, writeAck) },
+ "signal_answer": func(msg nodes.WireMessage) bool { return s.handleNodeSignalMessage(msg, connectedID, writeAck) },
+ "signal_candidate": func(msg nodes.WireMessage) bool { return s.handleNodeSignalMessage(msg, connectedID, writeAck) },
+ }
+ if handler := handlers[strings.ToLower(strings.TrimSpace(msg.Type))]; handler != nil {
+ if !handler(msg) {
+ return
+ }
+ continue
+ }
+ if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "unsupported message type"}); err != nil {
+ return
+ }
+ }
+}
+
+func (s *Server) handleNodeSignalMessage(msg nodes.WireMessage, connectedID string, writeAck func(nodes.WireAck) error) bool {
+ targetID := strings.TrimSpace(msg.To)
+ if s.nodeWebRTC != nil && (targetID == "" || strings.EqualFold(targetID, "gateway")) {
+ if err := s.nodeWebRTC.HandleSignal(msg); err != nil {
+ _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()})
+ return true
+ }
+ return writeAck(nodes.WireAck{OK: true, Type: "signaled", ID: msg.ID}) == nil
+ }
+ if strings.TrimSpace(connectedID) == "" {
+ _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, Error: "node not registered"})
+ return true
+ }
+ if targetID == "" {
+ _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "target node required"})
+ return true
+ }
+ msg.From = connectedID
+ if err := s.sendNodeSocketMessage(targetID, msg); err != nil {
+ _ = writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()})
+ return true
+ }
+ return writeAck(nodes.WireAck{OK: true, Type: "relayed", ID: msg.ID}) == nil
+}
+
+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:
+ payload := s.webUINodesPayload(r.Context())
+ payload["ok"] = true
+ writeJSON(w, payload)
+ 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)
+ writeJSON(w, map[string]interface{}{"ok": true, "deleted": ok, "id": id})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) handleWebUINodeDispatches(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
+ }
+ limit := queryBoundedPositiveInt(r, "limit", 50, 500)
+ writeJSON(w, map[string]interface{}{
+ "ok": true,
+ "items": s.webUINodesDispatchPayload(limit),
+ })
+}
diff --git a/pkg/api/server_observability.go b/pkg/api/server_observability.go
new file mode 100644
index 0000000..0a791bc
--- /dev/null
+++ b/pkg/api/server_observability.go
@@ -0,0 +1,694 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+func (s *Server) webUISessionsPayload() map[string]interface{} {
+ 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"})
+ }
+ return map[string]interface{}{"sessions": out}
+}
+
+func (s *Server) webUITaskQueuePayload(includeHeartbeat bool) map[string]interface{} {
+ path := s.memoryFilePath("task-audit.jsonl")
+ 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)
+ }
+ }
+ queuePath := s.memoryFilePath("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 _, it := range arr {
+ if row, ok := it.(map[string]interface{}); ok {
+ running = append(running, row)
+ }
+ }
+ }
+ }
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return fmt.Sprintf("%v", items[i]["updated_at"]) > fmt.Sprintf("%v", items[j]["updated_at"])
+ })
+ sort.Slice(running, func(i, j int) bool {
+ return fmt.Sprintf("%v", running[i]["updated_at"]) > fmt.Sprintf("%v", running[j]["updated_at"])
+ })
+ if len(items) > 30 {
+ items = items[:30]
+ }
+ return map[string]interface{}{"items": items, "running": running}
+}
+
+func (s *Server) webUIEKGSummaryPayload(window string) map[string]interface{} {
+ ekgPath := s.memoryFilePath("ekg-events.jsonl")
+ window = strings.ToLower(strings.TrimSpace(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 && 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 != "" {
+ providerScoreWorkload[provider] += 1
+ if status == "success" {
+ providerScore[provider] += 1
+ } else if status == "error" {
+ providerScore[provider] -= 2
+ }
+ }
+ if errSig != "" {
+ errSigWorkload[errSig]++
+ if source == "heartbeat" {
+ errSigHeartbeat[errSig]++
+ } else if status == "error" {
+ errSigCount[errSig]++
+ }
+ }
+ }
+ toTopScore := func(m map[string]float64, limit 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 {
+ if out[i].Score == out[j].Score {
+ return out[i].Key < out[j].Key
+ }
+ return out[i].Score > out[j].Score
+ })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out
+ }
+ toTopCount := func(m map[string]int, limit 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 {
+ if out[i].Count == out[j].Count {
+ return out[i].Key < out[j].Key
+ }
+ return out[i].Count > out[j].Count
+ })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out
+ }
+ return map[string]interface{}{
+ "ok": true,
+ "window": selectedWindow,
+ "rows": len(rows),
+ "provider_top_score": toTopScore(providerScore, 5),
+ "provider_top_workload": toTopCount(mapFromFloatCounts(providerScoreWorkload), 5),
+ "errsig_top": toTopCount(errSigCount, 5),
+ "errsig_top_heartbeat": toTopCount(errSigHeartbeat, 5),
+ "errsig_top_workload": toTopCount(errSigWorkload, 5),
+ "source_top": toTopCount(sourceStats, 5),
+ "channel_top": toTopCount(channelStats, 5),
+ }
+}
+
+func mapFromFloatCounts(src map[string]float64) map[string]int {
+ out := make(map[string]int, len(src))
+ for k, v := range src {
+ out[k] = int(v)
+ }
+ return out
+}
+
+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)
+ includeInternal := r.URL.Query().Get("include_internal") == "1"
+ 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 !includeInternal && !isUserFacingSessionKey(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"})
+ }
+ writeJSON(w, map[string]interface{}{"ok": true, "sessions": out})
+}
+
+func isUserFacingSessionKey(key string) bool {
+ k := strings.ToLower(strings.TrimSpace(key))
+ if k == "" {
+ return false
+ }
+ switch {
+ case strings.HasPrefix(k, "subagent:"):
+ return false
+ case strings.HasPrefix(k, "internal:"):
+ return false
+ case strings.HasPrefix(k, "heartbeat:"):
+ return false
+ case strings.HasPrefix(k, "cron:"):
+ return false
+ case strings.HasPrefix(k, "hook:"):
+ return false
+ case strings.HasPrefix(k, "node:"):
+ return false
+ default:
+ return true
+ }
+}
+
+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 := s.memoryFilePath("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)
+ }
+ }
+
+ queuePath := s.memoryFilePath("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)}
+ writeJSON(w, 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
+ }
+ ekgPath := s.memoryFilePath("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
+ }
+ writeJSON(w, 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) 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 := queryBoundedPositiveInt(r, "limit", 10, 200)
+ 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:] {
+ if parsed, ok := parseLogLine(ln); ok {
+ out = append(out, parsed)
+ }
+ }
+ writeJSON(w, map[string]interface{}{"ok": true, "logs": out})
+}
diff --git a/pkg/api/server_runtime_nodes.go b/pkg/api/server_runtime_nodes.go
new file mode 100644
index 0000000..0b9ba54
--- /dev/null
+++ b/pkg/api/server_runtime_nodes.go
@@ -0,0 +1,503 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/YspCoder/clawgo/pkg/channels"
+ cfgpkg "github.com/YspCoder/clawgo/pkg/config"
+ "github.com/YspCoder/clawgo/pkg/nodes"
+ "github.com/YspCoder/clawgo/pkg/providers"
+ "github.com/YspCoder/clawgo/pkg/tools"
+ "github.com/gorilla/websocket"
+)
+
+func (s *Server) handleWebUIRuntime(w http.ResponseWriter, r *http.Request) {
+ if !s.checkAuth(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ ctx := r.Context()
+ sub := s.subscribeRuntimeLive(ctx)
+ initial := map[string]interface{}{
+ "ok": true,
+ "type": "runtime_snapshot",
+ "snapshot": s.buildWebUIRuntimeSnapshot(ctx),
+ }
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if err := conn.WriteJSON(initial); err != nil {
+ return
+ }
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case payload := <-sub:
+ _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
+ return
+ }
+ }
+ }
+}
+
+func (s *Server) buildWebUIRuntimeSnapshot(ctx context.Context) map[string]interface{} {
+ var providerPayload map[string]interface{}
+ var normalizedConfig interface{}
+ if strings.TrimSpace(s.configPath) != "" {
+ if cfg, err := cfgpkg.LoadConfig(strings.TrimSpace(s.configPath)); err == nil {
+ providerPayload = providers.GetProviderRuntimeSnapshot(cfg)
+ normalizedConfig = cfg.NormalizedView()
+ }
+ }
+ if providerPayload == nil {
+ providerPayload = map[string]interface{}{"items": []interface{}{}}
+ }
+ runtimePayload := map[string]interface{}{}
+ if s.onSubagents != nil {
+ if res, err := s.onSubagents(ctx, "snapshot", map[string]interface{}{"limit": 200}); err == nil {
+ if m, ok := res.(map[string]interface{}); ok {
+ runtimePayload = m
+ }
+ }
+ }
+ return map[string]interface{}{
+ "version": s.webUIVersionPayload(),
+ "config": normalizedConfig,
+ "runtime": runtimePayload,
+ "nodes": s.webUINodesPayload(ctx),
+ "sessions": s.webUISessionsPayload(),
+ "task_queue": s.webUITaskQueuePayload(false),
+ "ekg": s.webUIEKGSummaryPayload("24h"),
+ "providers": providerPayload,
+ }
+}
+
+func (s *Server) webUIVersionPayload() map[string]interface{} {
+ return map[string]interface{}{
+ "gateway_version": firstNonEmptyString(s.gatewayVersion, gatewayBuildVersion()),
+ "webui_version": firstNonEmptyString(s.webuiVersion, detectWebUIVersion(strings.TrimSpace(s.webUIDir))),
+ "compiled_channels": channels.CompiledChannelKeys(),
+ }
+}
+
+func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} {
+ list := []nodes.NodeInfo{}
+ if s.mgr != nil {
+ list = s.mgr.List()
+ }
+ localRegistry := s.fetchRegistryItems(ctx)
+ localAgents := make([]nodes.AgentInfo, 0, len(localRegistry))
+ for _, item := range localRegistry {
+ agentID := strings.TrimSpace(stringFromMap(item, "agent_id"))
+ if agentID == "" {
+ continue
+ }
+ localAgents = append(localAgents, nodes.AgentInfo{
+ ID: agentID,
+ DisplayName: strings.TrimSpace(stringFromMap(item, "display_name")),
+ Role: strings.TrimSpace(stringFromMap(item, "role")),
+ Type: strings.TrimSpace(stringFromMap(item, "type")),
+ Transport: fallbackString(strings.TrimSpace(stringFromMap(item, "transport")), "local"),
+ })
+ }
+ host, _ := os.Hostname()
+ local := nodes.NodeInfo{
+ ID: "local",
+ Name: "local",
+ Endpoint: "gateway",
+ Version: gatewayBuildVersion(),
+ OS: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ LastSeenAt: time.Now(),
+ Online: true,
+ Capabilities: nodes.Capabilities{Run: true, Invoke: true, Model: true, Camera: true, Screen: true, Location: true, Canvas: true},
+ Actions: []string{"run", "agent_task", "camera_snap", "camera_clip", "screen_snapshot", "screen_record", "location_get", "canvas_snapshot", "canvas_action"},
+ Models: []string{"local-sim"},
+ Agents: localAgents,
+ }
+ 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) {
+ 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...)
+ }
+ p2p := map[string]interface{}{}
+ if s.nodeP2PStatus != nil {
+ p2p = s.nodeP2PStatus()
+ }
+ dispatches := s.webUINodesDispatchPayload(12)
+ return map[string]interface{}{
+ "nodes": list,
+ "trees": s.buildNodeAgentTrees(ctx, list),
+ "p2p": p2p,
+ "dispatches": dispatches,
+ "alerts": s.webUINodeAlertsPayload(list, p2p, dispatches),
+ "artifact_retention": s.artifactStatsSnapshot(),
+ }
+}
+
+func (s *Server) webUINodeAlertsPayload(nodeList []nodes.NodeInfo, p2p map[string]interface{}, dispatches []map[string]interface{}) []map[string]interface{} {
+ alerts := make([]map[string]interface{}, 0)
+ for _, node := range nodeList {
+ nodeID := strings.TrimSpace(node.ID)
+ if nodeID == "" || nodeID == "local" {
+ continue
+ }
+ if !node.Online {
+ alerts = append(alerts, map[string]interface{}{
+ "severity": "critical",
+ "kind": "node_offline",
+ "node": nodeID,
+ "title": "Node offline",
+ "detail": fmt.Sprintf("node %s is offline", nodeID),
+ })
+ }
+ }
+ if sessions, ok := p2p["nodes"].([]map[string]interface{}); ok {
+ for _, session := range sessions {
+ appendNodeSessionAlert(&alerts, session)
+ }
+ } else if sessions, ok := p2p["nodes"].([]interface{}); ok {
+ for _, raw := range sessions {
+ if session, ok := raw.(map[string]interface{}); ok {
+ appendNodeSessionAlert(&alerts, session)
+ }
+ }
+ }
+ failuresByNode := map[string]int{}
+ for _, row := range dispatches {
+ nodeID := strings.TrimSpace(fmt.Sprint(row["node"]))
+ if nodeID == "" {
+ continue
+ }
+ if ok, _ := tools.MapBoolArg(row, "ok"); ok {
+ continue
+ }
+ failuresByNode[nodeID]++
+ }
+ for nodeID, count := range failuresByNode {
+ if count < 2 {
+ continue
+ }
+ alerts = append(alerts, map[string]interface{}{
+ "severity": "warning",
+ "kind": "dispatch_failures",
+ "node": nodeID,
+ "title": "Repeated dispatch failures",
+ "detail": fmt.Sprintf("node %s has %d recent failed dispatches", nodeID, count),
+ "count": count,
+ })
+ }
+ return alerts
+}
+
+func appendNodeSessionAlert(alerts *[]map[string]interface{}, session map[string]interface{}) {
+ nodeID := strings.TrimSpace(fmt.Sprint(session["node"]))
+ if nodeID == "" {
+ return
+ }
+ status := strings.ToLower(strings.TrimSpace(fmt.Sprint(session["status"])))
+ retryCount := int(int64Value(session["retry_count"]))
+ lastError := strings.TrimSpace(fmt.Sprint(session["last_error"]))
+ switch {
+ case status == "failed" || status == "closed":
+ *alerts = append(*alerts, map[string]interface{}{
+ "severity": "critical",
+ "kind": "p2p_session_down",
+ "node": nodeID,
+ "title": "P2P session down",
+ "detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session is %s", nodeID, status)),
+ })
+ case retryCount >= 3 || (status == "connecting" && retryCount >= 2):
+ *alerts = append(*alerts, map[string]interface{}{
+ "severity": "warning",
+ "kind": "p2p_session_unstable",
+ "node": nodeID,
+ "title": "P2P session unstable",
+ "detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session retry_count=%d", nodeID, retryCount)),
+ "count": retryCount,
+ })
+ }
+}
+
+func int64Value(v interface{}) int64 {
+ switch value := v.(type) {
+ case int:
+ return int64(value)
+ case int32:
+ return int64(value)
+ case int64:
+ return value
+ case float32:
+ return int64(value)
+ case float64:
+ return int64(value)
+ case json.Number:
+ if n, err := value.Int64(); err == nil {
+ return n
+ }
+ }
+ return 0
+}
+
+func (s *Server) webUINodesDispatchPayload(limit int) []map[string]interface{} {
+ path := s.memoryFilePath("nodes-dispatch-audit.jsonl")
+ if path == "" {
+ return []map[string]interface{}{}
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return []map[string]interface{}{}
+ }
+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+ if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" {
+ return []map[string]interface{}{}
+ }
+ out := make([]map[string]interface{}, 0, limit)
+ for i := len(lines) - 1; i >= 0; i-- {
+ line := strings.TrimSpace(lines[i])
+ if line == "" {
+ continue
+ }
+ row := map[string]interface{}{}
+ if err := json.Unmarshal([]byte(line), &row); err != nil {
+ continue
+ }
+ out = append(out, row)
+ if limit > 0 && len(out) >= limit {
+ break
+ }
+ }
+ return out
+}
+
+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
+ }
+ if rawItems, ok := payload["items"].([]map[string]interface{}); ok {
+ return rawItems
+ }
+ list, ok := payload["items"].([]interface{})
+ if !ok {
+ return nil
+ }
+ items := make([]map[string]interface{}, 0, len(list))
+ for _, item := range list {
+ if row, ok := item.(map[string]interface{}); 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 + "/api/config?mode=normalized"
+ 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 s.fetchRemoteNodeRegistryLegacy(ctx, node)
+ }
+ var payload struct {
+ OK bool `json:"ok"`
+ Config cfgpkg.NormalizedConfig `json:"config"`
+ RawConfig map[string]interface{} `json:"raw_config"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
+ return s.fetchRemoteNodeRegistryLegacy(ctx, node)
+ }
+ items := buildRegistryItemsFromNormalizedConfig(payload.Config)
+ if len(items) > 0 {
+ return items, nil
+ }
+ return s.fetchRemoteNodeRegistryLegacy(ctx, node)
+}
+
+func (s *Server) fetchRemoteNodeRegistryLegacy(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 + "/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 buildRegistryItemsFromNormalizedConfig(view cfgpkg.NormalizedConfig) []map[string]interface{} {
+ items := make([]map[string]interface{}, 0, len(view.Core.Subagents))
+ for agentID, subcfg := range view.Core.Subagents {
+ if strings.TrimSpace(agentID) == "" {
+ continue
+ }
+ items = append(items, map[string]interface{}{
+ "agent_id": agentID,
+ "enabled": subcfg.Enabled,
+ "type": "subagent",
+ "transport": fallbackString(strings.TrimSpace(subcfg.RuntimeClass), "local"),
+ "node_id": "",
+ "parent_agent_id": "",
+ "notify_main_policy": "final_only",
+ "display_name": "",
+ "role": strings.TrimSpace(subcfg.Role),
+ "description": "",
+ "system_prompt_file": strings.TrimSpace(subcfg.Prompt),
+ "prompt_file_found": false,
+ "memory_namespace": "",
+ "tool_allowlist": append([]string(nil), subcfg.ToolAllowlist...),
+ "tool_visibility": map[string]interface{}{},
+ "effective_tools": []string{},
+ "inherited_tools": []string{},
+ "routing_keywords": routeKeywordsForRegistry(view.Runtime.Router.Rules, agentID),
+ "managed_by": "config.json",
+ })
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return stringFromMap(items[i], "agent_id") < stringFromMap(items[j], "agent_id")
+ })
+ return items
+}
+
+func routeKeywordsForRegistry(rules []cfgpkg.AgentRouteRule, agentID string) []string {
+ agentID = strings.TrimSpace(agentID)
+ for _, rule := range rules {
+ if strings.TrimSpace(rule.AgentID) == agentID {
+ return append([]string(nil), rule.Keywords...)
+ }
+ }
+ return 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"
+}
diff --git a/pkg/api/server_tools_mcp.go b/pkg/api/server_tools_mcp.go
new file mode 100644
index 0000000..c8a3b4d
--- /dev/null
+++ b/pkg/api/server_tools_mcp.go
@@ -0,0 +1,216 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ cfgpkg "github.com/YspCoder/clawgo/pkg/config"
+ "github.com/YspCoder/clawgo/pkg/tools"
+)
+
+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)
+ }
+ }
+ serverChecks := []map[string]interface{}{}
+ if strings.TrimSpace(s.configPath) != "" {
+ if cfg, err := cfgpkg.LoadConfig(s.configPath); err == nil {
+ serverChecks = buildMCPServerChecks(cfg)
+ }
+ }
+ writeJSON(w, map[string]interface{}{
+ "tools": toolsList,
+ "mcp_tools": mcpItems,
+ "mcp_server_checks": serverChecks,
+ })
+}
+
+func buildMCPServerChecks(cfg *cfgpkg.Config) []map[string]interface{} {
+ if cfg == nil {
+ return nil
+ }
+ names := make([]string, 0, len(cfg.Tools.MCP.Servers))
+ for name := range cfg.Tools.MCP.Servers {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ items := make([]map[string]interface{}, 0, len(names))
+ for _, name := range names {
+ server := cfg.Tools.MCP.Servers[name]
+ transport := strings.ToLower(strings.TrimSpace(server.Transport))
+ if transport == "" {
+ transport = "stdio"
+ }
+ command := strings.TrimSpace(server.Command)
+ status := "missing_command"
+ message := "command is empty"
+ resolved := ""
+ missingCommand := false
+ if !server.Enabled {
+ status = "disabled"
+ message = "server is disabled"
+ } else if transport != "stdio" {
+ status = "not_applicable"
+ message = "command check not required for non-stdio transport"
+ } else if command != "" {
+ if filepath.IsAbs(command) {
+ if info, err := os.Stat(command); err == nil && !info.IsDir() {
+ status = "ok"
+ message = "command found"
+ resolved = command
+ } else {
+ status = "missing_command"
+ message = fmt.Sprintf("command not found: %s", command)
+ missingCommand = true
+ }
+ } else if path, err := exec.LookPath(command); err == nil {
+ status = "ok"
+ message = "command found"
+ resolved = path
+ } else {
+ status = "missing_command"
+ message = fmt.Sprintf("command not found in PATH: %s", command)
+ missingCommand = true
+ }
+ }
+ installSpec := inferMCPInstallSpec(server)
+ items = append(items, map[string]interface{}{
+ "name": name,
+ "enabled": server.Enabled,
+ "transport": transport,
+ "status": status,
+ "message": message,
+ "command": command,
+ "resolved": resolved,
+ "package": installSpec.Package,
+ "installer": installSpec.Installer,
+ "installable": missingCommand && installSpec.AutoInstallSupported,
+ "missing_command": missingCommand,
+ })
+ }
+ return items
+}
+
+type mcpInstallSpec struct {
+ Installer string
+ Package string
+ AutoInstallSupported bool
+}
+
+func inferMCPInstallSpec(server cfgpkg.MCPServerConfig) mcpInstallSpec {
+ if pkgName := strings.TrimSpace(server.Package); pkgName != "" {
+ return mcpInstallSpec{Installer: "npm", Package: pkgName, AutoInstallSupported: true}
+ }
+ command := strings.TrimSpace(server.Command)
+ args := make([]string, 0, len(server.Args))
+ for _, arg := range server.Args {
+ if v := strings.TrimSpace(arg); v != "" {
+ args = append(args, v)
+ }
+ }
+ base := filepath.Base(command)
+ switch base {
+ case "npx":
+ return mcpInstallSpec{Installer: "npm", Package: firstNonFlagArg(args), AutoInstallSupported: firstNonFlagArg(args) != ""}
+ case "uvx":
+ pkgName := firstNonFlagArg(args)
+ return mcpInstallSpec{Installer: "uv", Package: pkgName, AutoInstallSupported: pkgName != ""}
+ case "bunx":
+ pkgName := firstNonFlagArg(args)
+ return mcpInstallSpec{Installer: "bun", Package: pkgName, AutoInstallSupported: pkgName != ""}
+ case "python", "python3":
+ if len(args) >= 2 && args[0] == "-m" {
+ return mcpInstallSpec{Installer: "pip", Package: strings.TrimSpace(args[1]), AutoInstallSupported: false}
+ }
+ }
+ return mcpInstallSpec{}
+}
+
+func firstNonFlagArg(args []string) string {
+ for _, arg := range args {
+ item := strings.TrimSpace(arg)
+ if item == "" || strings.HasPrefix(item, "-") {
+ continue
+ }
+ return item
+ }
+ return ""
+}
+
+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"`
+ Installer string `json:"installer"`
+ }
+ 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 := ensureMCPPackageInstalledWithInstaller(r.Context(), pkgName, body.Installer)
+ 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
+ }
+ writeJSON(w, map[string]interface{}{
+ "ok": true,
+ "package": pkgName,
+ "output": out,
+ "bin_name": binName,
+ "bin_path": binPath,
+ })
+}
+
+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
+ }
+ writeJSON(w, map[string]interface{}{
+ "ok": true,
+ "groups": tools.ToolAllowlistGroups(),
+ })
+}
diff --git a/pkg/api/server_webui.go b/pkg/api/server_webui.go
new file mode 100644
index 0000000..d81b99e
--- /dev/null
+++ b/pkg/api/server_webui.go
@@ -0,0 +1,179 @@
+package api
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime/debug"
+ "strings"
+ "time"
+)
+
+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, "/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 strings.HasPrefix(r.URL.Path, "/api/") {
+ http.NotFound(w, r)
+ return
+ }
+ if r.URL.Path == "/" {
+ s.handleWebUI(w, r)
+ return
+ }
+ if s.tryServeWebUIDist(w, r, r.URL.Path) {
+ return
+ }
+ if s.tryServeWebUIDist(w, r, "/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, "/")
+ if reqPath == "/" || reqPath == "/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) 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
+ }
+ writeJSON(w, map[string]interface{}{"ok": true, "path": path, "name": h.Filename})
+}
+
+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"
+}
+
+const webUIHTML = `
+
+ClawGo WebUI
+
+
+ClawGo WebUI
+Token:
+Config (dynamic + hot reload)
+
+
+
+Chat (supports media upload)
+Session:
+
+`