This commit is contained in:
Dark aura 2026-05-07 05:52:53 +01:00
commit d0cac40dd3
22 changed files with 1795 additions and 19 deletions

2
.gitignore vendored
View file

@ -11,6 +11,8 @@ build/
/picoclaw /picoclaw
/picoclaw-test /picoclaw-test
cmd/**/workspace cmd/**/workspace
.gocache/
dist/
# Picoclaw specific # Picoclaw specific

49
AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
Microsoft Windows [Version 10.0.26200.8116]
(c) Microsoft Corporation. All rights reserved.
C:\Users\user>ollama list
NAME ID SIZE MODIFIED
smollm2:1.7b cef4a1e09247 1.8 GB 5 hours ago
qwen3.5:4b 2a654d98e6fb 3.4 GB 3 days ago
deepseek-v4-pro:cloud 22bfd5026abd - 3 days ago
qwen3-coder-next:cloud aa626c11ae8d - 2 months ago
C:\Users\user>
# PicoClaw
## Build & Test Commands
```bash
make build # Build for current platform (runs generate first)
make build-all # Cross-compile for all supported platforms
make test # Run Go tests + web tests
make lint # golangci-lint with goolm,stdjson tags
make check # deps + fmt + vet + test + lint-docs
```
## Environment Setup
- Copy `.env.example` to `.env` and configure API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
- Go 1.25.9+ required
- Build tags: `goolm,stdjson` (set via GO_BUILD_TAGS or Makefile)
- CGO_ENABLED=0 by default; CGO_ENABLED=1 only for macOS launcher builds
## Critical Constraints
- **Always run `make generate` before `make build`** — code generation creates required workspace symlinks
- **Never edit `cmd/picoclaw/workspace/` directly** — it's regenerated by `go generate`
- **MIPS builds require ELF e_flags patch** — handled automatically by Makefile
- **loong64 needs manual ztypes_loong64.go** — handled automatically by Makefile
- **WhatsApp native builds** add `whatsapp_native` tag but produce larger binaries
- **Workspace location**: `~/.picoclaw/workspace` (skills, memory stored here at runtime)
- **macOS launcher**: requires CGO_ENABLED=1 and minimal macOS 10.11 target
- **No hardcoded API keys in CLI** — project is migrating to OAuth 2.0 flows
- **Memory target**: core process <20MB for 64MB RAM boards; optimize data structures over storage
## Architecture Notes
- **Protocol-first**: Migrating from vendor-based to protocol-based provider classification (OpenAI-compat, Ollama-compat)
- **Multi-architecture**: x86_64, ARM64, MIPS, RISC-V, LoongArch
- **14+ chat channels** via adapter pattern in pkg/channels/
- **MCP support**: Model Context Protocol server in pkg/mcp/
- **Tools API**: See `docs/reference/tools-api.md` for complete tools documentation

View file

@ -1,3 +1,15 @@
Microsoft Windows [Version 10.0.26200.8116]
(c) Microsoft Corporation. All rights reserved.
C:\Users\user>ollama list
NAME ID SIZE MODIFIED
smollm2:1.7b cef4a1e09247 1.8 GB 5 hours ago
qwen3.5:4b 2a654d98e6fb 3.4 GB 3 days ago
deepseek-v4-pro:cloud 22bfd5026abd - 3 days ago
qwen3-coder-next:cloud aa626c11ae8d - 2 months ago
C:\Users\user>
# PicoClaw # PicoClaw
## Build & Test Commands ## Build & Test Commands

View file

@ -3,7 +3,7 @@
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "restrict_to_workspace": true,
"model_name": "gpt-5.4", "model_name": "smollm2",
"max_tokens": 8192, "max_tokens": 8192,
"context_window": 131072, "context_window": 131072,
"temperature": 0.7, "temperature": 0.7,
@ -21,6 +21,24 @@
} }
}, },
"model_list": [ "model_list": [
{
"model_name": "smollm2",
"provider": "ollama",
"model": "smollm2:1.7b",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "qwen3.5",
"provider": "ollama",
"model": "qwen3.5:4b",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "deepseek-v4-pro",
"provider": "ollama",
"model": "deepseek-v4-pro:cloud",
"api_base": "http://localhost:11434/v1"
},
{ {
"model_name": "gpt-5.4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.4", "model": "openai/gpt-5.4",

View file

@ -28,6 +28,7 @@ import (
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -57,6 +58,7 @@ type AgentLoop struct {
mcp mcpRuntime mcp mcpRuntime
hookRuntime hookRuntime hookRuntime hookRuntime
steering *steeringQueue steering *steeringQueue
subagents *tools.SubagentManager
pendingSkills sync.Map pendingSkills sync.Map
mu sync.RWMutex mu sync.RWMutex

View file

@ -242,6 +242,9 @@ func registerSharedTools(
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
if agentID == "main" {
al.subagents = subagentManager
}
// Inject a media resolver so the legacy RunToolLoop fallback path can // Inject a media resolver so the legacy RunToolLoop fallback path can
// resolve media:// refs in the same way the main AgentLoop does. // resolve media:// refs in the same way the main AgentLoop does.

View file

@ -119,3 +119,29 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
return info return info
} }
func (al *AgentLoop) GetMainSubagentTasks(channel, chatID string) []tools.SubagentTask {
al.mu.RLock()
manager := al.subagents
al.mu.RUnlock()
if manager == nil {
return nil
}
all := manager.ListTaskCopies()
if channel == "" && chatID == "" {
return all
}
filtered := make([]tools.SubagentTask, 0, len(all))
for _, task := range all {
if channel != "" && task.OriginChannel != "" && task.OriginChannel != channel {
continue
}
if chatID != "" && task.OriginChatID != "" && task.OriginChatID != chatID {
continue
}
filtered = append(filtered, task)
}
return filtered
}

View file

@ -246,6 +246,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error { runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error {
return agentLoop.GrantPermission(agentID, path, duration) return agentLoop.GrantPermission(agentID, path, duration)
}) })
runningServices.HealthServer.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{
"channel": channel,
"chat_id": chatID,
"tasks": agentLoop.GetMainSubagentTasks(channel, chatID),
}, nil
})
for _, bindHost := range listenResult.BindHosts { for _, bindHost := range listenResult.BindHosts {
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port))) fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))

View file

@ -19,8 +19,9 @@ type Server struct {
ready bool ready bool
checks map[string]Check checks map[string]Check
startTime time.Time startTime time.Time
reloadFunc func() error reloadFunc func() error
permissionGrantFunc func(agentID, path, duration string) error permissionGrantFunc func(agentID, path, duration string) error
subagentStatusFunc func(channel, chatID string) (any, error)
authToken string // optional bearer token for protected endpoints authToken string // optional bearer token for protected endpoints
} }
@ -51,6 +52,7 @@ func NewServer(host string, port int, token string) *Server {
mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler) mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
addr := net.JoinHostPort(host, strconv.Itoa(port)) addr := net.JoinHostPort(host, strconv.Itoa(port))
s.server = &http.Server{ s.server = &http.Server{
@ -128,6 +130,12 @@ func (s *Server) SetPermissionGrantFunc(fn func(agentID, path, duration string)
s.permissionGrantFunc = fn s.permissionGrantFunc = fn
} }
func (s *Server) SetSubagentStatusFunc(fn func(channel, chatID string) (any, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.subagentStatusFunc = fn
}
// permissionGrantHandler handles POST /internal/permission/grant requests. // permissionGrantHandler handles POST /internal/permission/grant requests.
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@ -241,6 +249,49 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"}) json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
} }
func (s *Server) subagentStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use GET"})
return
}
s.mu.RLock()
requiredToken := s.authToken
statusFunc := s.subagentStatusFunc
s.mu.RUnlock()
if requiredToken != "" {
given := extractBearerToken(r.Header.Get("Authorization"))
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
}
if statusFunc == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "subagent status not configured"})
return
}
payload, err := statusFunc(r.URL.Query().Get("channel"), r.URL.Query().Get("chat_id"))
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(payload)
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -307,6 +358,8 @@ func (s *Server) RegisterOnMux(mux HandlerMux) {
mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
} }
func statusString(ok bool) string { func statusString(ok bool) string {

View file

@ -215,6 +215,60 @@ func TestReloadHandler_Error(t *testing.T) {
} }
} }
func TestSubagentStatusHandler_Success(t *testing.T) {
s := newTestServer()
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{
"channel": channel,
"chat_id": chatID,
"tasks": []map[string]any{
{"id": "subagent-1", "status": "running"},
},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status?channel=pico&chat_id=session-1", nil)
req.Header.Set("Authorization", "Bearer test")
w := httptest.NewRecorder()
s.subagentStatusHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
var resp struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []map[string]any `json:"tasks"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Channel != "pico" || resp.ChatID != "session-1" {
t.Fatalf("response = %#v, want channel/chat_id preserved", resp)
}
if len(resp.Tasks) != 1 {
t.Fatalf("tasks len = %d, want 1", len(resp.Tasks))
}
}
func TestSubagentStatusHandler_RequiresAuth(t *testing.T) {
s := newTestServer()
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{}, nil
})
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status", nil)
w := httptest.NewRecorder()
s.subagentStatusHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestSetReady_Toggle(t *testing.T) { func TestSetReady_Toggle(t *testing.T) {
s := newTestServer() s := newTestServer()

View file

@ -0,0 +1,98 @@
@echo off
REM Build PicoClaw core and web launcher without using make.
REM Usage: scripts\build-without-make.bat
SETLOCAL ENABLEEXTENSIONS
SET "GO_TAGS=goolm,stdjson"
REM Resolve the repository root directory from the script location.
PUSHD "%~dp0.."
IF ERRORLEVEL 1 (
echo ERROR: Failed to resolve repository root from "%~dp0..".
EXIT /B 1
)
SET "REPO_ROOT=%CD%"
POPD
REM Ensure Go is available
where go >nul 2>&1
IF ERRORLEVEL 1 (
echo ERROR: go is not installed or not on PATH.
EXIT /B 1
)
REM Ensure pnpm is available
where pnpm >nul 2>&1
IF ERRORLEVEL 1 (
echo ERROR: pnpm is not installed or not on PATH.
EXIT /B 1
)
PUSHD "%REPO_ROOT%"
IF ERRORLEVEL 1 (
echo ERROR: Failed to change directory to "%REPO_ROOT%".
EXIT /B 1
)
IF NOT EXIST build (
mkdir build
)
echo === Generating Go code ===
go generate ./...
IF ERRORLEVEL 1 (
echo ERROR: go generate failed.
POPD
EXIT /B 1
)
echo.
echo === Building PicoClaw core binary ===
go build -tags "%GO_TAGS%" -o build\picoclaw.exe .\cmd\picoclaw
IF ERRORLEVEL 1 (
echo ERROR: go build failed for core binary.
POPD
EXIT /B 1
)
echo.
echo === Building PicoClaw web frontend assets ===
PUSHD "%REPO_ROOT%\web\frontend"
IF ERRORLEVEL 1 (
echo ERROR: Failed to change directory to web\frontend.
POPD
EXIT /B 1
)
CALL pnpm install --frozen-lockfile
IF ERRORLEVEL 1 (
echo ERROR: pnpm install failed.
POPD
POPD
EXIT /B 1
)
CALL pnpm build:backend
IF ERRORLEVEL 1 (
echo ERROR: pnpm build:backend failed.
POPD
POPD
EXIT /B 1
)
POPd
echo.
echo === Building PicoClaw web launcher ===
go build -tags "%GO_TAGS%" -o build\picoclaw-launcher.exe .\web\backend
IF ERRORLEVEL 1 (
echo ERROR: go build failed for web launcher.
POPD
EXIT /B 1
)
echo.
echo === Build complete ===
echo Core binary: %REPO_ROOT%\build\picoclaw.exe
echo Web launcher: %REPO_ROOT%\build\picoclaw-launcher.exe
POPD
EXIT /B 0

View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Build PicoClaw core and web launcher without using make.
# Usage: ./scripts/build-without-make.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)"
cd "${REPO_ROOT}"
GO_TAGS="goolm,stdjson"
EXE_EXT=""
UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
case "${UNAME_S}" in
*MINGW*|*MSYS*|*CYGWIN*|*Windows_NT*)
EXE_EXT=".exe"
;;
esac
command -v go >/dev/null 2>&1 || {
echo "ERROR: go is not installed or not on PATH."
exit 1
}
command -v pnpm >/dev/null 2>&1 || {
echo "ERROR: pnpm is not installed or not on PATH."
exit 1
}
mkdir -p "${REPO_ROOT}/build"
echo "=== Generating Go code ==="
go generate ./...
echo ""
echo "=== Building PicoClaw core binary ==="
go build -tags "${GO_TAGS}" -o "${REPO_ROOT}/build/picoclaw${EXE_EXT}" ./cmd/picoclaw
echo ""
echo "=== Building PicoClaw web frontend assets ==="
cd "${REPO_ROOT}/web/frontend"
pnpm install --frozen-lockfile
pnpm build:backend
echo ""
echo "=== Building PicoClaw web launcher ==="
cd "${REPO_ROOT}"
go build -tags "${GO_TAGS}" -o "${REPO_ROOT}/build/picoclaw-launcher${EXE_EXT}" ./web/backend
echo ""
echo "=== Build complete ==="
echo "Core binary: ${REPO_ROOT}/build/picoclaw${EXE_EXT}"
echo "Web launcher: ${REPO_ROOT}/build/picoclaw-launcher${EXE_EXT}"

View file

@ -5,8 +5,14 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -17,6 +23,8 @@ import (
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
mux.HandleFunc("GET /api/pico/memory-graph", h.handleGetPicoMemoryGraph)
mux.HandleFunc("GET /api/pico/subagents", h.handleGetPicoSubagents)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
@ -28,6 +36,43 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy()) mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
} }
type picoSubagentStatusItem struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Status string `json:"status"`
Created int64 `json:"created"`
Result string `json:"result,omitempty"`
}
type picoSubagentStatusResponse struct {
SessionID string `json:"session_id"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []picoSubagentStatusItem `json:"tasks"`
}
type picoMemoryGraphNode struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
Group string `json:"group"`
Preview string `json:"preview,omitempty"`
Weight int `json:"weight,omitempty"`
}
type picoMemoryGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Kind string `json:"kind"`
}
type picoMemoryGraphResponse struct {
SessionID string `json:"session_id"`
GeneratedAt string `json:"generated_at"`
Nodes []picoMemoryGraphNode `json:"nodes"`
Edges []picoMemoryGraphEdge `json:"edges"`
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
// The gateway bind host and port are resolved from the latest configuration. // The gateway bind host and port are resolved from the latest configuration.
func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy { func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
@ -207,6 +252,433 @@ func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) {
h.writePicoInfoResponse(w, r, cfg, nil) h.writePicoInfoResponse(w, r, cfg, nil)
} }
func (h *Handler) handleGetPicoMemoryGraph(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, "failed to load config", http.StatusInternalServerError)
return
}
sessionDir := resolveSessionsDir(cfg.Agents.Defaults.Workspace)
toolFeedbackMaxArgsLength := cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
workspaceDir := resolveWorkspaceDir(cfg.Agents.Defaults.Workspace)
ref, refErr := h.findPicoJSONLSession(sessionDir, sessionID)
var sess sessionFile
err = refErr
if refErr == nil {
sess, err = h.readJSONLSession(sessionDir, ref.Key)
}
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
nodes, edges := buildPicoMemoryGraph(
sessionID,
detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength),
workspaceDir,
)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(picoMemoryGraphResponse{
SessionID: sessionID,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Nodes: nodes,
Edges: edges,
})
}
func (h *Handler) handleGetPicoSubagents(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for Pico subagent status proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
}
gateway.mu.Lock()
pidData := gateway.pidData
gateway.mu.Unlock()
if pidData == nil || pidData.Token == "" {
logger.Warnf("Gateway auth token not available for Pico subagent status proxy")
http.Error(w, "Gateway auth token not available", http.StatusServiceUnavailable)
return
}
target := h.gatewayProxyURL()
target.Path = "/internal/subagents/status"
query := target.Query()
query.Set("channel", "pico")
query.Set("chat_id", sessionID)
target.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "Failed to create subagent status request", http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+pidData.Token)
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
logger.Errorf("Failed to fetch Pico subagent status: %v", err)
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
var upstream struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
Created int64 `json:"created"`
Result string `json:"result"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&upstream); err != nil {
http.Error(w, "Failed to decode subagent status response", http.StatusBadGateway)
return
}
tasks := make([]picoSubagentStatusItem, 0, len(upstream.Tasks))
for _, task := range upstream.Tasks {
tasks = append(tasks, picoSubagentStatusItem{
ID: task.ID,
Label: task.Label,
Status: task.Status,
Created: task.Created,
Result: summarizeSubagentResult(task.Result),
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(picoSubagentStatusResponse{
SessionID: sessionID,
Channel: upstream.Channel,
ChatID: upstream.ChatID,
Tasks: tasks,
})
}
func summarizeSubagentResult(result string) string {
result = strings.TrimSpace(result)
if result == "" {
return ""
}
const maxRunes = 180
runes := []rune(result)
if len(runes) <= maxRunes {
return result
}
return string(runes[:maxRunes]) + "..."
}
func resolveWorkspaceDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
}
if len(workspace) > 0 && workspace[0] == '~' {
home, _ := os.UserHomeDir()
if len(workspace) > 1 && workspace[1] == '/' {
workspace = home + workspace[1:]
} else {
workspace = home
}
}
return workspace
}
func buildPicoMemoryGraph(sessionID string, messages []sessionChatMessage, workspaceDir string) ([]picoMemoryGraphNode, []picoMemoryGraphEdge) {
nodes := make([]picoMemoryGraphNode, 0, 32)
edges := make([]picoMemoryGraphEdge, 0, 48)
seenNodes := make(map[string]struct{})
seenEdges := make(map[string]struct{})
addNode := func(node picoMemoryGraphNode) {
if _, exists := seenNodes[node.ID]; exists {
return
}
seenNodes[node.ID] = struct{}{}
nodes = append(nodes, node)
}
addEdge := func(edge picoMemoryGraphEdge) {
key := edge.Source + "\x00" + edge.Target + "\x00" + edge.Kind
if _, exists := seenEdges[key]; exists {
return
}
seenEdges[key] = struct{}{}
edges = append(edges, edge)
}
const (
memoryRootID = "memory-root"
sessionRootID = "session-root"
)
addNode(picoMemoryGraphNode{
ID: memoryRootID,
Label: "Workspace Memory",
Kind: "root",
Group: "memory",
Preview: "Long-term memory and recent notes",
Weight: 5,
})
addNode(picoMemoryGraphNode{
ID: sessionRootID,
Label: "Active Session",
Kind: "root",
Group: "session",
Preview: sessionID,
Weight: 5,
})
addEdge(picoMemoryGraphEdge{Source: memoryRootID, Target: sessionRootID, Kind: "context"})
appendMemoryDocumentGraph(memoryRootID, filepath.Join(workspaceDir, "memory", "MEMORY.md"), "memory", "Long-term Memory", 8, addNode, addEdge)
appendRecentDailyNoteGraph(memoryRootID, filepath.Join(workspaceDir, "memory"), addNode, addEdge)
appendSessionGraph(sessionRootID, messages, addNode, addEdge)
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].ID < nodes[j].ID
})
sort.Slice(edges, func(i, j int) bool {
if edges[i].Source == edges[j].Source {
if edges[i].Target == edges[j].Target {
return edges[i].Kind < edges[j].Kind
}
return edges[i].Target < edges[j].Target
}
return edges[i].Source < edges[j].Source
})
return nodes, edges
}
func appendMemoryDocumentGraph(
rootID string,
path string,
group string,
title string,
maxItems int,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return
}
docID := group + ":document"
addNode(picoMemoryGraphNode{
ID: docID,
Label: title,
Kind: "document",
Group: group,
Preview: filepath.Base(path),
Weight: 4,
})
addEdge(picoMemoryGraphEdge{Source: rootID, Target: docID, Kind: "contains"})
lines := strings.Split(string(data), "\n")
currentParent := docID
items := 0
headingIndex := 0
noteIndex := 0
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
headingText := summarizeGraphText(strings.TrimSpace(strings.TrimLeft(line, "#")), 48)
if headingText == "" {
continue
}
headingIndex++
headingID := group + ":heading:" + strconv.Itoa(headingIndex)
addNode(picoMemoryGraphNode{
ID: headingID,
Label: headingText,
Kind: "heading",
Group: group,
Preview: headingText,
Weight: 3,
})
addEdge(picoMemoryGraphEdge{Source: docID, Target: headingID, Kind: "section"})
currentParent = headingID
continue
}
trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. "))
if trimmed == "" {
continue
}
noteIndex++
noteID := group + ":note:" + strconv.Itoa(noteIndex)
addNode(picoMemoryGraphNode{
ID: noteID,
Label: summarizeGraphText(trimmed, 38),
Kind: "note",
Group: group,
Preview: summarizeGraphText(trimmed, 140),
Weight: 2,
})
addEdge(picoMemoryGraphEdge{Source: currentParent, Target: noteID, Kind: "note"})
items++
if items >= maxItems {
break
}
}
}
func appendRecentDailyNoteGraph(
rootID string,
memoryDir string,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
matches, err := filepath.Glob(filepath.Join(memoryDir, "*", "*.md"))
if err != nil || len(matches) == 0 {
return
}
sort.Slice(matches, func(i, j int) bool {
return matches[i] > matches[j]
})
for idx, match := range matches {
if idx >= 3 {
break
}
appendMemoryDocumentGraph(
rootID,
match,
"daily-"+strconv.Itoa(idx+1),
"Daily Note "+filepath.Base(match),
4,
addNode,
addEdge,
)
}
}
func appendSessionGraph(
rootID string,
messages []sessionChatMessage,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
if len(messages) == 0 {
return
}
start := 0
if len(messages) > 8 {
start = len(messages) - 8
}
recent := messages[start:]
previousID := rootID
for index, message := range recent {
messageID := "session:message:" + strconv.Itoa(index)
label := strings.ToUpper(message.Role)
if preview := summarizeGraphText(message.Content, 34); preview != "" {
label += ": " + preview
}
if label == strings.ToUpper(message.Role) && len(message.Attachments) > 0 {
label += ": attachment"
}
addNode(picoMemoryGraphNode{
ID: messageID,
Label: label,
Kind: "message",
Group: "session",
Preview: summarizeGraphText(message.Content, 160),
Weight: 3,
})
addEdge(picoMemoryGraphEdge{Source: previousID, Target: messageID, Kind: "flow"})
previousID = messageID
for toolIndex, toolCall := range message.ToolCalls {
if toolCall.Function == nil {
continue
}
name := strings.TrimSpace(toolCall.Function.Name)
if name == "" {
continue
}
toolID := messageID + ":tool:" + strconv.Itoa(toolIndex)
addNode(picoMemoryGraphNode{
ID: toolID,
Label: name,
Kind: "tool",
Group: "tool",
Preview: summarizeGraphText(toolCall.Function.Arguments, 120),
Weight: 2,
})
addEdge(picoMemoryGraphEdge{Source: messageID, Target: toolID, Kind: "tool"})
}
for attachmentIndex, attachment := range message.Attachments {
name := strings.TrimSpace(attachment.Filename)
if name == "" {
name = attachment.Type
}
if name == "" {
name = "attachment"
}
attachmentID := messageID + ":attachment:" + strconv.Itoa(attachmentIndex)
addNode(picoMemoryGraphNode{
ID: attachmentID,
Label: summarizeGraphText(name, 34),
Kind: "attachment",
Group: "media",
Preview: summarizeGraphText(attachment.URL, 120),
Weight: 1,
})
addEdge(picoMemoryGraphEdge{Source: messageID, Target: attachmentID, Kind: "attachment"})
}
}
}
func summarizeGraphText(text string, maxRunes int) string {
trimmed := strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if len(runes) <= maxRunes {
return trimmed
}
return string(runes[:maxRunes-1]) + "…"
}
// handleRegenPicoToken rotates the raw Pico WebSocket token and returns // handleRegenPicoToken rotates the raw Pico WebSocket token and returns
// non-secret connection info for the launcher UI. // non-secret connection info for the launcher UI.
// //

View file

@ -11,9 +11,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
ppid "github.com/sipeed/picoclaw/pkg/pid" ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func newPicoProxyRequest(method, path string) *http.Request { func newPicoProxyRequest(method, path string) *http.Request {
@ -821,6 +824,188 @@ func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) {
} }
} }
func TestHandleGetPicoMemoryGraph_BuildsWorkspaceAndSessionGraph(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
workspaceDir := filepath.Join(t.TempDir(), "workspace")
sessionsDir := filepath.Join(workspaceDir, "sessions")
memoryDir := filepath.Join(workspaceDir, "memory")
if err := os.MkdirAll(memoryDir, 0o755); err != nil {
t.Fatalf("MkdirAll(memoryDir) error = %v", err)
}
if err := os.MkdirAll(filepath.Join(memoryDir, time.Now().Format("200601")), 0o755); err != nil {
t.Fatalf("MkdirAll(dailyNoteDir) error = %v", err)
}
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = workspaceDir
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
store, err := memory.NewJSONLStore(sessionsDir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "graph-session"
for _, msg := range []providers.Message{
{Role: "user", Content: "Remember the Nairobi deployment notes."},
{Role: "assistant", Content: "Saved the deployment note and linked it to workspace memory."},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
if err := store.SetSummary(nil, sessionKey, "Graph session"); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(memoryDir, "MEMORY.md"),
[]byte("# Preferences\n- User prefers network graph views\n# Projects\n- PicoClaw cockpit integration"),
0o644,
); err != nil {
t.Fatalf("WriteFile(MEMORY.md) error = %v", err)
}
todayPath := filepath.Join(memoryDir, time.Now().Format("200601"), time.Now().Format("20060102")+".md")
if err := os.WriteFile(
todayPath,
[]byte("# Daily\n- Reviewed active session memory graph"),
0o644,
); err != nil {
t.Fatalf("WriteFile(todayPath) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/pico/memory-graph?session_id=graph-session", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp picoMemoryGraphResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if resp.SessionID != "graph-session" {
t.Fatalf("resp.SessionID = %q, want %q", resp.SessionID, "graph-session")
}
if len(resp.Nodes) < 5 {
t.Fatalf("len(resp.Nodes) = %d, want at least 5", len(resp.Nodes))
}
foundMemoryRoot := false
foundSessionMessage := false
for _, node := range resp.Nodes {
if node.ID == "memory-root" {
foundMemoryRoot = true
}
if strings.Contains(node.Label, "NAIROBI") || strings.Contains(node.Preview, "Nairobi") {
foundSessionMessage = true
}
}
if !foundMemoryRoot {
t.Fatal("expected memory-root node in graph")
}
if !foundSessionMessage {
t.Fatal("expected session content to appear in graph nodes")
}
}
func TestHandleGetPicoSubagents_UsesScopedGatewayStatus(t *testing.T) {
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/subagents/status" {
t.Fatalf("path = %q, want %q", r.URL.Path, "/internal/subagents/status")
}
if got := r.Header.Get("Authorization"); got != "Bearer gateway-auth-token" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer gateway-auth-token")
}
if got := r.URL.Query().Get("channel"); got != "pico" {
t.Fatalf("channel query = %q, want %q", got, "pico")
}
if got := r.URL.Query().Get("chat_id"); got != "session-42" {
t.Fatalf("chat_id query = %q, want %q", got, "session-42")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"channel": "pico",
"chat_id": "session-42",
"tasks": []map[string]any{
{
"id": "subagent-1",
"label": "Research",
"status": "running",
"created": int64(1710000000000),
"result": "This is a very long status summary that should still round-trip through the launcher API cleanly.",
},
},
})
}))
defer server.Close()
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
cmd := startGatewayLikeProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
origPidData := gateway.pidData
origCmd := gateway.cmd
t.Cleanup(func() {
gateway.mu.Lock()
gateway.pidData = origPidData
gateway.cmd = origCmd
gateway.mu.Unlock()
})
gateway.mu.Lock()
gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "gateway-auth-token"}
gateway.cmd = cmd
gateway.mu.Unlock()
req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/subagents?session_id=session-42", nil)
rec := httptest.NewRecorder()
h.handleGetPicoSubagents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp picoSubagentStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SessionID != "session-42" || resp.ChatID != "session-42" || resp.Channel != "pico" {
t.Fatalf("response = %#v, want scoped session metadata", resp)
}
if len(resp.Tasks) != 1 || resp.Tasks[0].ID != "subagent-1" {
t.Fatalf("tasks = %#v, want one proxied task", resp.Tasks)
}
}
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir) t.Setenv("HOME", tmpDir)

View file

@ -15,6 +15,43 @@ interface PicoSetupResponse {
changed: boolean changed: boolean
} }
export interface PicoSubagentStatusItem {
id: string
label?: string
status: "running" | "completed" | "failed" | "canceled" | string
created: number
result?: string
}
export interface PicoSubagentStatusResponse {
session_id: string
channel: string
chat_id: string
tasks: PicoSubagentStatusItem[]
}
export interface PicoMemoryGraphNode {
id: string
label: string
kind: string
group: string
preview?: string
weight?: number
}
export interface PicoMemoryGraphEdge {
source: string
target: string
kind: string
}
export interface PicoMemoryGraphResponse {
session_id: string
generated_at: string
nodes: PicoMemoryGraphNode[]
edges: PicoMemoryGraphEdge[]
}
const BASE_URL = "" const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> { async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -37,4 +74,22 @@ export async function setupPico(): Promise<PicoSetupResponse> {
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" }) return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
} }
export async function getPicoSubagents(
sessionId: string,
): Promise<PicoSubagentStatusResponse> {
const params = new URLSearchParams({ session_id: sessionId })
return request<PicoSubagentStatusResponse>(
`/api/pico/subagents?${params.toString()}`,
)
}
export async function getPicoMemoryGraph(
sessionId: string,
): Promise<PicoMemoryGraphResponse> {
const params = new URLSearchParams({ session_id: sessionId })
return request<PicoMemoryGraphResponse>(
`/api/pico/memory-graph?${params.toString()}`,
)
}
export type { PicoInfoResponse, PicoSetupResponse } export type { PicoInfoResponse, PicoSetupResponse }

View file

@ -0,0 +1,208 @@
import dayjs from "dayjs"
import { useMemo } from "react"
import { IconArrowRight } from "@tabler/icons-react"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { MemoryGraph } from "./memory-graph"
import { useAgentCockpit } from "./use-agent-cockpit"
function reasonLabel(reasonCode?: string) {
switch (reasonCode) {
case "requires_subagent":
return "Requires subagent runtime"
case "requires_skills":
return "Requires skills support"
case "requires_mcp_discovery":
return "Requires MCP discovery"
case "requires_linux":
return "Linux only"
case "requires_serial_platform":
return "Unsupported serial platform"
default:
return reasonCode ?? ""
}
}
export function CockpitPage() {
const { activeSessionId } = usePicoChat()
const {
groupedTools,
pendingToolName,
sessionSubagents,
sessionMemoryGraph,
toggleTool,
} = useAgentCockpit(activeSessionId)
const filteredToolCount = useMemo(
() => groupedTools.reduce((total, [, items]) => total + items.length, 0),
[groupedTools],
)
return (
<div className="flex h-full flex-col overflow-hidden bg-[#050505] text-[#F2F2F2] selection:bg-[#F27D26] selection:text-black font-sans relative">
{/* Ghost Background Typography */}
<div className="absolute inset-0 overflow-hidden pointer-events-none select-none opacity-[0.05]">
<span className="absolute -left-20 -top-10 text-[35vw] font-black leading-none uppercase">COCKPIT</span>
<span className="absolute -right-20 -bottom-20 text-[25vw] font-black leading-none uppercase">SYSTEM</span>
</div>
{/* Header */}
<header className="flex justify-between items-start border-b border-white/10 p-6 md:px-12 md:py-8 z-10">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Terminal Status</span>
<span className="text-xs opacity-60 font-mono tracking-tighter">CONNECTED / {dayjs().format('DD.MM.YYYY')}</span>
</div>
<div className="flex flex-col gap-1 text-right">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Runtime Epoch</span>
<span className="text-xs opacity-60 font-mono tracking-tighter">{dayjs().format('HH:mm')} GMT+1</span>
</div>
</header>
<div className="flex-1 overflow-auto px-6 py-6 md:px-12 md:py-10 z-10">
<div className="mx-auto grid w-full max-w-[1600px] gap-12 xl:grid-cols-[1fr_380px]">
{/* Main Workspace */}
<div className="space-y-16">
{/* Hero Section */}
<div className="relative">
<h1 className="text-[12vw] leading-[0.85] font-black tracking-[-0.07em] uppercase m-0 p-0 text-[#F2F2F2]">
AGENT<br/>INTERFACE
</h1>
<p className="mt-8 text-xl font-light max-w-xl opacity-60 leading-relaxed border-l-2 border-[#F27D26] pl-6">
Active control node for autonomous agents. Managing tool surfaces, memory networks.
</p>
</div>
<section className="grid gap-12">
{/* Tool Grid */}
<div className="space-y-8">
<div className="flex items-end justify-between border-b border-white/10 pb-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Active Modules</span>
<h2 className="text-4xl font-black uppercase tracking-tight">Tool Grid</h2>
</div>
<div className="flex items-center gap-6">
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">{filteredToolCount} Visible</span>
<a href="/agent/tools" className="text-xs font-bold uppercase tracking-widest hover:text-[#F27D26] transition-colors border-b border-white/20 pb-1">
Configuration
</a>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{groupedTools.flatMap(([, items]) =>
items.map((tool) => (
<div
key={tool.name}
className="group relative border border-white/10 bg-[#0A0A0A] p-4 hover:border-[#F27D26]/50 transition-all duration-300"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="space-y-1 flex-1 min-w-0">
<h4 className="font-bold text-sm tracking-tight uppercase truncate group-hover:text-[#F27D26] transition-colors">
{tool.name}
</h4>
<Badge
className={cn(
"rounded-none text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5",
tool.status === "enabled" ? "bg-[#F27D26] text-black" : "bg-white/10 text-white/40"
)}
>
{tool.status}
</Badge>
</div>
<Switch
checked={tool.status !== "disabled"}
disabled={pendingToolName === tool.name}
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
/>
</div>
<p className="text-xs text-[#F2F2F2]/60 leading-relaxed font-light mb-4 line-clamp-2">
{tool.description}
</p>
<div className="flex items-center justify-between text-[8px] font-mono uppercase tracking-widest text-white/30 pt-3 border-t border-white/5">
<span className="truncate">{tool.config_key}</span>
{(tool as any).reason_code && (
<span className="text-[#F27D26]/60">{reasonLabel((tool as any).reason_code)}</span>
)}
</div>
</div>
))
)}
</div>
</div>
{/* Memory Network */}
<div className="space-y-8">
<div className="flex items-end justify-between border-b border-white/10 pb-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Relational Map</span>
<h2 className="text-4xl font-black uppercase tracking-tight">Memory Network</h2>
</div>
</div>
<div className="p-8 border border-white/10 bg-[#0A0A0A] relative overflow-hidden">
<MemoryGraph
nodes={sessionMemoryGraph?.nodes ?? []}
edges={sessionMemoryGraph?.edges ?? []}
/>
</div>
</div>
</section>
</div>
{/* Right Sidebar */}
<aside className="space-y-12">
{/* Subagents */}
<div className="space-y-8">
<div className="border-b border-white/10 pb-2">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
</div>
<div className="space-y-4">
{sessionSubagents.length === 0 ? (
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
No subagents have been created in this session yet.
</div>
) : (
sessionSubagents.map((task) => (
<div key={task.id} className="group border border-white/10 p-5 bg-[#0A0A0A] hover:border-white/30 transition-all">
<div className="flex justify-between items-start mb-2">
<span className="font-bold text-sm uppercase tracking-tight">{task.label || task.id}</span>
<span className={cn(
"text-[9px] uppercase font-mono px-1.5 py-0.5",
task.status === "completed" ? "bg-green-500/20 text-green-400" : "bg-[#F27D26]/20 text-[#F27D26]"
)}>
{task.status}
</span>
</div>
<div className="text-[9px] font-mono text-white/30 truncate">
{dayjs(task.created).format("HH:mm:ss [UTC]")}
</div>
</div>
))
)}
</div>
</div>
</aside>
</div>
</div>
{/* Footer */}
<footer className="h-20 border-t border-white/10 flex items-center justify-between px-6 md:px-12 z-10 bg-[#050505]">
<nav className="flex gap-10 text-[10px] font-bold uppercase tracking-[0.3em]">
<a href="#" className="hover:text-[#F27D26] transition-colors">Portfolio</a>
<a href="#" className="hover:text-[#F27D26] transition-colors">Documentation</a>
<a href="#" className="hover:text-[#F27D26] transition-colors">Gateway</a>
</nav>
<div className="flex items-center gap-6">
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">System Ref: BOLD-UX-01</span>
<div className="h-10 w-10 rounded-full border border-white/10 flex items-center justify-center hover:bg-white hover:text-black cursor-pointer transition-all">
<IconArrowRight size={16} />
</div>
</div>
</footer>
</div>
)
}

View file

@ -0,0 +1,231 @@
import { useMemo, useState } from "react"
import type {
PicoMemoryGraphEdge,
PicoMemoryGraphNode,
} from "@/api/pico"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const VIEWBOX_WIDTH = 1000
const VIEWBOX_HEIGHT = 560
type PositionedNode = PicoMemoryGraphNode & { x: number; y: number }
interface MemoryGraphProps {
nodes: PicoMemoryGraphNode[]
edges: PicoMemoryGraphEdge[]
}
const GROUP_COLUMNS: Record<string, number> = {
memory: 170,
"daily-1": 295,
"daily-2": 295,
"daily-3": 295,
session: 700,
tool: 840,
media: 920,
}
function groupColor(group: string) {
if (group === "memory") return "#72f0a0"
if (group.startsWith("daily-")) return "#4dd0e1"
if (group === "tool") return "#f8d66d"
if (group === "media") return "#f395d6"
return "#90e89f"
}
function computeLayout(nodes: PicoMemoryGraphNode[]): PositionedNode[] {
const groups = new Map<string, PicoMemoryGraphNode[]>()
for (const node of nodes) {
const items = groups.get(node.group) ?? []
items.push(node)
groups.set(node.group, items)
}
const layout: PositionedNode[] = []
for (const [group, items] of groups.entries()) {
const x = GROUP_COLUMNS[group] ?? 500
const count = items.length
const gap = VIEWBOX_HEIGHT / (count + 1)
items.forEach((node, index) => {
const offset =
group === "session" && node.kind === "root"
? 0
: Math.sin((index + 1) * 1.7) * 12
layout.push({
...node,
x,
y: Math.max(52, Math.min(VIEWBOX_HEIGHT - 52, gap * (index + 1) + offset)),
})
})
}
return layout.sort((left, right) => left.x - right.x || left.y - right.y)
}
export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
const [selectedId, setSelectedId] = useState<string | null>(nodes[0]?.id ?? null)
const positionedNodes = useMemo(() => computeLayout(nodes), [nodes])
const nodeMap = useMemo(
() => new Map(positionedNodes.map((node) => [node.id, node])),
[positionedNodes],
)
const selectedNode =
positionedNodes.find((node) => node.id === selectedId) ?? positionedNodes[0]
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="overflow-hidden rounded-xl border border-[#173621] bg-[#041008]">
<svg
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
className="h-[420px] w-full"
role="img"
aria-label="PicoClaw memory graph"
>
<defs>
<linearGradient id="memoryGrid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#0c2014" />
<stop offset="100%" stopColor="#050d08" />
</linearGradient>
</defs>
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#memoryGrid)" />
{Array.from({ length: 10 }).map((_, index) => (
<line
key={`v-${index}`}
x1={(VIEWBOX_WIDTH / 10) * index}
y1="0"
x2={(VIEWBOX_WIDTH / 10) * index}
y2={VIEWBOX_HEIGHT}
stroke="#0d2817"
strokeWidth="1"
/>
))}
{Array.from({ length: 8 }).map((_, index) => (
<line
key={`h-${index}`}
x1="0"
y1={(VIEWBOX_HEIGHT / 8) * index}
x2={VIEWBOX_WIDTH}
y2={(VIEWBOX_HEIGHT / 8) * index}
stroke="#0d2817"
strokeWidth="1"
/>
))}
{edges.map((edge) => {
const source = nodeMap.get(edge.source)
const target = nodeMap.get(edge.target)
if (!source || !target) {
return null
}
const active =
selectedId != null &&
(selectedId === edge.source || selectedId === edge.target)
return (
<line
key={`${edge.source}-${edge.target}-${edge.kind}`}
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={active ? "#9dfdbb" : "#1f5c34"}
strokeOpacity={active ? 0.9 : 0.45}
strokeWidth={active ? 2.5 : 1.4}
/>
)
})}
{positionedNodes.map((node) => {
const active = node.id === selectedNode?.id
const color = groupColor(node.group)
const radius =
node.kind === "root" ? 24 : node.kind === "document" ? 18 : 14
return (
<g
key={node.id}
onMouseEnter={() => setSelectedId(node.id)}
onClick={() => setSelectedId(node.id)}
className="cursor-pointer"
>
<circle
cx={node.x}
cy={node.y}
r={radius + 10}
fill={active ? `${color}22` : "transparent"}
/>
<circle
cx={node.x}
cy={node.y}
r={radius}
fill="#07110a"
stroke={color}
strokeWidth={active ? 3 : 2}
/>
<text
x={node.x}
y={node.y + 4}
textAnchor="middle"
fill={color}
fontSize={node.kind === "root" ? "12" : "10"}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
>
{node.kind === "root" ? node.label : node.label.slice(0, 18)}
</text>
</g>
)
})}
</svg>
</div>
<div className="space-y-3 rounded-xl border border-[#173621] bg-[#050d08] p-4">
{selectedNode ? (
<>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-sm uppercase tracking-[0.22em] text-[#95d7a5]">
Node Focus
</p>
<h3 className="mt-2 text-sm font-semibold text-[#effff3]">
{selectedNode.label}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"border-[#2a6b3d] text-[#9fd8ae]",
selectedNode.kind === "tool" && "border-[#8c7931] text-[#f8d66d]",
)}
>
{selectedNode.kind}
</Badge>
</div>
<p className="text-sm leading-relaxed text-[#9cc8a8]">
{selectedNode.preview || "No extra preview for this node yet."}
</p>
<div className="grid gap-2 text-xs text-[#74a282]">
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<span>Cluster</span>
<span className="font-mono uppercase text-[#d7f9df]">
{selectedNode.group}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<span>Weight</span>
<span className="font-mono text-[#d7f9df]">
{selectedNode.weight ?? 1}
</span>
</div>
</div>
</>
) : (
<p className="text-sm text-[#8bb39a]">No graph data available.</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useDeferredValue, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { getPicoMemoryGraph, getPicoSubagents } from "@/api/pico"
import { getTools, getWebSearchConfig, setToolEnabled } from "@/api/tools"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
type ToolStatusFilter = "all" | "enabled" | "disabled" | "blocked"
export function useAgentCockpit(sessionId: string) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
const deferredSearchQuery = useDeferredValue(searchQuery)
const toolsQuery = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const webSearchQuery = useQuery({
queryKey: ["tools", "web-search-config"],
queryFn: getWebSearchConfig,
})
const subagentsQuery = useQuery({
queryKey: ["pico", "subagents", sessionId],
queryFn: () => getPicoSubagents(sessionId),
enabled: Boolean(sessionId),
refetchInterval: 3000,
})
const memoryGraphQuery = useQuery({
queryKey: ["pico", "memory-graph", sessionId],
queryFn: () => getPicoMemoryGraph(sessionId),
enabled: Boolean(sessionId),
refetchInterval: 10000,
})
const toggleToolMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: async (_, variables) => {
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
variables.enabled
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
: t(
"pages.agent.tools.disable_success",
"Tool disabled successfully",
),
"Agent Cockpit",
gateway?.restartRequired === true,
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("pages.agent.tools.toggle_error", "Failed to toggle tool"),
)
},
})
const tools = toolsQuery.data?.tools ?? []
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const groupedTools = useMemo(() => {
const groups = new Map<string, typeof tools>()
for (const tool of tools) {
if (statusFilter !== "all" && tool.status !== statusFilter) {
continue
}
if (normalizedSearchQuery) {
const haystack = `${tool.name} ${tool.description}`.toLowerCase()
if (!haystack.includes(normalizedSearchQuery)) {
continue
}
}
const items = groups.get(tool.category) ?? []
items.push(tool)
groups.set(tool.category, items)
}
return Array.from(groups.entries())
}, [normalizedSearchQuery, statusFilter, tools])
const categoryCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const tool of tools) {
counts.set(tool.category, (counts.get(tool.category) ?? 0) + 1)
}
return Array.from(counts.entries())
}, [tools])
const statusCounts = useMemo(() => {
return {
all: tools.length,
enabled: tools.filter((tool) => tool.status === "enabled").length,
disabled: tools.filter((tool) => tool.status === "disabled").length,
blocked: tools.filter((tool) => tool.status === "blocked").length,
}
}, [tools])
return {
categoryCounts,
groupedTools,
pendingToolName: toggleToolMutation.isPending
? (toggleToolMutation.variables?.name ?? null)
: null,
searchQuery,
sessionMemoryGraph: memoryGraphQuery.data ?? null,
sessionSubagents: subagentsQuery.data?.tasks ?? [],
statusCounts,
statusFilter,
tools,
hasMemoryGraphError: memoryGraphQuery.error != null,
webSearchConfig: webSearchQuery.data ?? null,
hasSubagentsError: subagentsQuery.error != null,
hasToolsError: toolsQuery.error != null,
isMemoryGraphLoading: memoryGraphQuery.isLoading,
isSubagentsLoading: subagentsQuery.isLoading,
isToolsLoading: toolsQuery.isLoading,
isWebSearchLoading: webSearchQuery.isLoading,
setSearchQuery,
setStatusFilter,
toggleTool: (name: string, enabled: boolean) =>
toggleToolMutation.mutate({ name, enabled }),
}
}

View file

@ -3,6 +3,7 @@ import {
IconAtom, IconAtom,
IconChevronsDown, IconChevronsDown,
IconChevronsUp, IconChevronsUp,
IconCpu,
IconKey, IconKey,
IconListDetails, IconListDetails,
IconMessageCircle, IconMessageCircle,
@ -132,6 +133,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{ {
...baseNavGroups[2], ...baseNavGroups[2],
items: [ items: [
{
title: "Agent Cockpit",
url: "/agent/cockpit",
icon: IconCpu,
translateTitle: false,
},
{ {
title: "navigation.hub", title: "navigation.hub",
url: "/agent/hub", url: "/agent/hub",

View file

@ -1,5 +1,6 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" import { IconArrowUp, IconMicrophone, IconMicrophoneOff, IconPhotoPlus, IconX } from "@tabler/icons-react"
import type { KeyboardEvent } from "react" import { type KeyboardEvent } from "react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from "react-textarea-autosize"
@ -13,6 +14,28 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { ChatAttachment, ContextUsage } from "@/store/chat" import type { ChatAttachment, ContextUsage } from "@/store/chat"
declare global {
interface Window {
SpeechRecognition?: new () => SpeechRecognitionLike
webkitSpeechRecognition?: new () => SpeechRecognitionLike
}
}
interface SpeechRecognitionLike {
continuous: boolean
interimResults: boolean
lang: string
onresult: ((event: SpeechRecognitionEventLike) => void) | null
onend: (() => void) | null
onerror: ((event: { error: string }) => void) | null
start(): void
stop(): void
}
interface SpeechRecognitionEventLike {
results: ArrayLike<ArrayLike<{ transcript: string }>>
}
export type ChatInputDisabledReason = export type ChatInputDisabledReason =
| "gatewayUnknown" | "gatewayUnknown"
| "gatewayStarting" | "gatewayStarting"
@ -51,6 +74,41 @@ export function ChatComposer({
contextUsage, contextUsage,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognitionLike | null>(null)
useEffect(() => {
const Recognition =
window.SpeechRecognition ?? window.webkitSpeechRecognition
if (!Recognition) return
const recognition = new Recognition()
recognition.continuous = false
recognition.interimResults = false
recognition.lang = "en-US"
recognition.onresult = (event) => {
const transcript = event.results[0]?.[0]?.transcript?.trim() ?? ""
if (transcript) onInputChange(transcript)
}
recognition.onend = () => setIsListening(false)
recognition.onerror = () => setIsListening(false)
recognitionRef.current = recognition
}, [onInputChange])
const toggleVoice = () => {
if (!recognitionRef.current) return
if (isListening) {
recognitionRef.current.stop()
setIsListening(false)
} else {
try {
recognitionRef.current.start()
setIsListening(true)
} catch {
setIsListening(false)
}
}
}
const canInput = inputDisabledReason === null const canInput = inputDisabledReason === null
const disabledMessage = const disabledMessage =
inputDisabledReason === null inputDisabledReason === null
@ -110,21 +168,37 @@ export function ChatComposer({
maxRows={8} maxRows={8}
/> />
<div className="mt-2 flex items-center justify-between px-1"> <div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full" className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
onClick={onAddImages} onClick={onAddImages}
disabled={!canInput} disabled={!canInput}
aria-label={t("chat.attachImage")} aria-label={t("chat.attachImage")}
title={t("chat.attachImage")} title={t("chat.attachImage")}
> >
<IconPhotoPlus className="size-4" /> <IconPhotoPlus className="size-4" />
</Button> </Button>
</div> <Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full",
isListening
? "text-red-500 bg-red-500/10 hover:bg-red-500/20"
: "text-muted-foreground hover:text-foreground"
)}
onClick={toggleVoice}
aria-label={isListening ? t("chat.stopListening") : t("chat.startListening")}
title={isListening ? t("chat.stopListening") : t("chat.startListening")}
>
{isListening ? <IconMicrophoneOff className="size-4" /> : <IconMicrophone className="size-4" />}
</Button>
</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{contextUsage && ( {contextUsage && (

View file

@ -23,6 +23,7 @@ import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools' import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills' import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
import { Route as AgentHubRouteImport } from './routes/agent/hub' import { Route as AgentHubRouteImport } from './routes/agent/hub'
import { Route as AgentCockpitRouteImport } from './routes/agent/cockpit'
const ModelsRoute = ModelsRouteImport.update({ const ModelsRoute = ModelsRouteImport.update({
id: '/models', id: '/models',
@ -94,6 +95,11 @@ const AgentHubRoute = AgentHubRouteImport.update({
path: '/hub', path: '/hub',
getParentRoute: () => AgentRoute, getParentRoute: () => AgentRoute,
} as any) } as any)
const AgentCockpitRoute = AgentCockpitRouteImport.update({
id: '/cockpit',
path: '/cockpit',
getParentRoute: () => AgentRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -105,6 +111,7 @@ export interface FileRoutesByFullPath {
'/launcher-setup': typeof LauncherSetupRoute '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
@ -121,6 +128,7 @@ export interface FileRoutesByTo {
'/launcher-setup': typeof LauncherSetupRoute '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
@ -138,6 +146,7 @@ export interface FileRoutesById {
'/launcher-setup': typeof LauncherSetupRoute '/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/models': typeof ModelsRoute '/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute '/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute '/agent/tools': typeof AgentToolsRoute
@ -156,6 +165,7 @@ export interface FileRouteTypes {
| '/launcher-setup' | '/launcher-setup'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/cockpit'
| '/agent/hub' | '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
@ -172,6 +182,7 @@ export interface FileRouteTypes {
| '/launcher-setup' | '/launcher-setup'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/cockpit'
| '/agent/hub' | '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
@ -188,6 +199,7 @@ export interface FileRouteTypes {
| '/launcher-setup' | '/launcher-setup'
| '/logs' | '/logs'
| '/models' | '/models'
| '/agent/cockpit'
| '/agent/hub' | '/agent/hub'
| '/agent/skills' | '/agent/skills'
| '/agent/tools' | '/agent/tools'
@ -307,6 +319,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentHubRouteImport preLoaderRoute: typeof AgentHubRouteImport
parentRoute: typeof AgentRoute parentRoute: typeof AgentRoute
} }
'/agent/cockpit': {
id: '/agent/cockpit'
path: '/cockpit'
fullPath: '/agent/cockpit'
preLoaderRoute: typeof AgentCockpitRouteImport
parentRoute: typeof AgentRoute
}
} }
} }
@ -323,12 +342,14 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
) )
interface AgentRouteChildren { interface AgentRouteChildren {
AgentCockpitRoute: typeof AgentCockpitRoute
AgentHubRoute: typeof AgentHubRoute AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute AgentToolsRoute: typeof AgentToolsRoute
} }
const AgentRouteChildren: AgentRouteChildren = { const AgentRouteChildren: AgentRouteChildren = {
AgentCockpitRoute: AgentCockpitRoute,
AgentHubRoute: AgentHubRoute, AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute, AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute, AgentToolsRoute: AgentToolsRoute,

View file

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { CockpitPage } from "@/components/agent/cockpit/cockpit-page"
export const Route = createFileRoute("/agent/cockpit")({
component: AgentCockpitRoute,
})
function AgentCockpitRoute() {
return <CockpitPage />
}