feat: initial scaffold kanban_cpp v0.1.0
C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar, Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry functions http_request, kpi_card, sparkline, agent_runs_timeline, dod_evidence_panel. Backend Go on :8403 (independent operations.db from kanban_web).
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
"fn-registry/functions/infra"
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
const chatSystemPrompt = `Asistente del tablero kanban. Modifica el tablero llamando a tools MCP cuando el usuario pida cambios. Responde texto en markdown cuando solo informe.
|
||||
|
||||
Tools (MCP server "kanban"):
|
||||
- Lectura: list_board, find_cards, card_history, list_users
|
||||
- Columnas: create_column, update_column, delete_column, reorder_columns
|
||||
- Tarjetas: create_card, update_card, delete_card, move_card, assign_card
|
||||
|
||||
El estado actual del tablero viene en <board_state> al final del mensaje. Usa esos IDs directamente — NO llames list_board si ya tienes lo que necesitas. NUNCA inventes IDs.
|
||||
|
||||
Cuando termines, responde texto natural sin mas llamadas — eso cierra la conversacion.`
|
||||
|
||||
const claudeTimeout = 300 * time.Second
|
||||
|
||||
func claudeBinary() string {
|
||||
if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" {
|
||||
return b
|
||||
}
|
||||
return "claude"
|
||||
}
|
||||
|
||||
func claudeModel() string {
|
||||
if m := os.Getenv("KANBAN_CLAUDE_MODEL"); m != "" {
|
||||
return m
|
||||
}
|
||||
return "claude-haiku-4-5-20251001"
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// wsEvent is the envelope sent to the browser. Type discriminates the payload.
|
||||
type wsEvent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ToolID string `json:"tool_id,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
BoardChanged bool `json:"board_changed,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleChatWS upgrades the request to WebSocket and streams claude events.
|
||||
//
|
||||
// Wire protocol:
|
||||
// client → server (one message): { "messages": [{role, content}, ...] }
|
||||
// server → client (many): wsEvent ndjson-style messages
|
||||
// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error"
|
||||
// server closes connection at end.
|
||||
func handleChatWS(db *DB, workdir string, logger *ChatLogger, internalToken string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := infra.WSUpgrader(w, r, []string{"*"})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusInternalError, "internal")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), claudeTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Read the initial chat request.
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req chatRequest
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
sendWS(ctx, conn, wsEvent{Type: "error", Error: "invalid chat request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
sendWS(ctx, conn, wsEvent{Type: "error", Error: "messages required"})
|
||||
return
|
||||
}
|
||||
|
||||
boardChanged, err := streamChat(ctx, conn, db, workdir, internalToken, req.Messages, logger)
|
||||
if err != nil {
|
||||
sendWS(ctx, conn, wsEvent{Type: "error", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(ctx, conn, wsEvent{Type: "done", BoardChanged: boardChanged})
|
||||
conn.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
}
|
||||
|
||||
func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) {
|
||||
binPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("locate kanban binary: %w", err)
|
||||
}
|
||||
|
||||
// Backend URL: trust X-Forwarded or fall back to localhost (kanban listens
|
||||
// on its main port). The MCP subprocess hits the loopback interface.
|
||||
backendURL := os.Getenv("KANBAN_PUBLIC_URL")
|
||||
if backendURL == "" {
|
||||
port := os.Getenv("KANBAN_LISTEN_PORT")
|
||||
if port == "" {
|
||||
port = "8095"
|
||||
}
|
||||
backendURL = "http://127.0.0.1:" + port
|
||||
}
|
||||
|
||||
mcpPath, err := writeMCPConfig(binPath, backendURL, token)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("write mcp config: %w", err)
|
||||
}
|
||||
defer os.Remove(mcpPath)
|
||||
|
||||
prompt := flattenMessages(msgs)
|
||||
if board, err := boardSnapshot(db); err == nil && board != "" {
|
||||
prompt += "\n\n<board_state>\n" + board + "\n</board_state>\n"
|
||||
}
|
||||
|
||||
stdin := strings.NewReader(prompt)
|
||||
events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{
|
||||
Bin: claudeBinary(),
|
||||
Args: []string{
|
||||
"--model", claudeModel(),
|
||||
"--no-session-persistence",
|
||||
"--mcp-config", mcpPath,
|
||||
"--strict-mcp-config",
|
||||
"--system-prompt", chatSystemPrompt,
|
||||
"--allowedTools",
|
||||
"mcp__kanban__list_board,mcp__kanban__create_column,mcp__kanban__update_column,mcp__kanban__rename_column,mcp__kanban__delete_column,mcp__kanban__reorder_columns,mcp__kanban__create_card,mcp__kanban__update_card,mcp__kanban__delete_card,mcp__kanban__move_card,mcp__kanban__card_history,mcp__kanban__find_cards,mcp__kanban__list_users,mcp__kanban__assign_card",
|
||||
},
|
||||
Stdin: stdin,
|
||||
Workdir: workdir,
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("spawn claude: %w", err)
|
||||
}
|
||||
|
||||
boardChanged := false
|
||||
for ev := range events {
|
||||
switch ev.Type {
|
||||
case core.ClaudeEventTextDelta:
|
||||
sendWS(ctx, conn, wsEvent{Type: "delta", Text: ev.Text})
|
||||
case core.ClaudeEventToolUse:
|
||||
toolName := stripMCPPrefix(ev.ToolName)
|
||||
sendWS(ctx, conn, wsEvent{
|
||||
Type: "tool_use",
|
||||
ToolID: ev.ToolUseID,
|
||||
Tool: toolName,
|
||||
Input: ev.ToolInput,
|
||||
})
|
||||
if toolMutates(toolName) {
|
||||
boardChanged = true
|
||||
}
|
||||
case core.ClaudeEventToolResult:
|
||||
sendWS(ctx, conn, wsEvent{
|
||||
Type: "tool_result",
|
||||
ToolID: ev.ToolResultID,
|
||||
Result: ev.ToolResultContent,
|
||||
IsError: ev.ToolResultIsError,
|
||||
})
|
||||
case core.ClaudeEventResult:
|
||||
sendWS(ctx, conn, wsEvent{
|
||||
Type: "result",
|
||||
Text: ev.Result,
|
||||
IsError: ev.IsError,
|
||||
})
|
||||
case core.ClaudeEventError:
|
||||
sendWS(ctx, conn, wsEvent{Type: "error", Error: ev.Error})
|
||||
}
|
||||
}
|
||||
return boardChanged, nil
|
||||
}
|
||||
|
||||
// stripMCPPrefix removes the "mcp__<server>__" prefix added by claude when
|
||||
// tools come from an MCP server, leaving the bare tool name.
|
||||
func stripMCPPrefix(name string) string {
|
||||
const pre = "mcp__kanban__"
|
||||
if strings.HasPrefix(name, pre) {
|
||||
return name[len(pre):]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func sendWS(ctx context.Context, conn *websocket.Conn, ev wsEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
wctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
_ = conn.Write(wctx, websocket.MessageText, b)
|
||||
}
|
||||
|
||||
// flattenMessages converts chat history into a single prompt for `claude -p`.
|
||||
func flattenMessages(msgs []chatMessage) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
role := "Usuario"
|
||||
if m.Role == "assistant" {
|
||||
role = "Asistente"
|
||||
}
|
||||
b.WriteString(role)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(m.Content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// boardSnapshot returns a JSON dump of columns + cards to inject in the
|
||||
// initial prompt, saving a list_board round-trip.
|
||||
func boardSnapshot(db *DB) (string, error) {
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cards, err := db.ListCardsWithTime()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := json.Marshal(map[string]any{"columns": cols, "cards": cards})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// chatWorkdir resolves an absolute working directory for `claude -p`.
|
||||
func chatWorkdir(dbPath string) string {
|
||||
abs, err := filepath.Abs(dbPath)
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(abs)
|
||||
}
|
||||
|
||||
// --- Legacy handleChat retained as a thin shim that returns 410 Gone. -------
|
||||
// Kept so existing clients see a clear error instead of a 404 while they
|
||||
// migrate to the WebSocket endpoint.
|
||||
|
||||
func handleChat(_ *DB, _ string, _ *ChatLogger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{
|
||||
Status: http.StatusGone,
|
||||
Code: "deprecated",
|
||||
Message: "POST /api/chat removed; use WebSocket at /api/chat/ws",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user