KAOS/code/internal/server/submit.go
djuka 003650df24 T24: PTY za konzolu i operater chat — real-time streaming
Konzola i operater chat sada koriste pseudo-terminal (PTY) umesto
pipe-a. Claude CLI detektuje terminal i šalje output odmah umesto
da bufferuje. ANSI escape sekvence se uklanjaju pre slanja kroz SSE.

Novi fajl: pty.go (startPTY, readPTY, stripAnsi)
Biblioteka: github.com/creack/pty v1.1.24
5 novih testova za PTY funkcionalnost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:13:13 +00:00

293 lines
6.5 KiB
Go

// Package server — submit.go handles task submission in two modes:
// client (simple form) and operator (chat via claude CLI).
package server
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/dal/kaos/internal/supervisor"
)
// chatState manages an operator chat session backed by a claude CLI process.
type chatState struct {
mu sync.Mutex
id string
cmd *exec.Cmd
output []string
done bool
listeners map[chan string]bool
}
// nextTaskNumber finds the highest T{XX} number across all tasks and returns the next one.
func nextTaskNumber(tasksDir string) (string, error) {
tasks, err := supervisor.ScanTasks(tasksDir)
if err != nil {
return "", err
}
maxNum := 0
re := regexp.MustCompile(`^T(\d+)$`)
for _, t := range tasks {
if matches := re.FindStringSubmatch(t.ID); matches != nil {
num, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
if num > maxNum {
maxNum = num
}
}
}
return fmt.Sprintf("T%02d", maxNum+1), nil
}
// handleSubmitPage serves the submission page.
func (s *Server) handleSubmitPage(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderSubmitPage())
}
// handleSimpleSubmit creates a task in backlog/ from the client form.
func (s *Server) handleSimpleSubmit(c *gin.Context) {
title := strings.TrimSpace(c.PostForm("title"))
desc := strings.TrimSpace(c.PostForm("description"))
priority := strings.TrimSpace(c.PostForm("priority"))
if title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "naslov je obavezan"})
return
}
if priority == "" {
priority = "Srednji"
}
taskID, err := nextTaskNumber(s.Config.TasksDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
now := time.Now().Format("2006-01-02 15:04")
content := fmt.Sprintf(`# %s: %s
**Kreirao:** klijent (prijava)
**Datum:** %s
**Agent:** —
**Model:** —
**Zavisi od:** —
**Prioritet:** %s
**Izvor:** klijent
---
## Opis
%s
## Originalna prijava
%s
`, taskID, title, now, priority, desc, desc)
path := filepath.Join(s.Config.TasksDir, "backlog", taskID+".md")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "task_id": taskID})
}
// handleChatSubmit spawns a claude CLI process with the operator's message.
func (s *Server) handleChatSubmit(c *gin.Context) {
var req struct {
Message string `json:"message"`
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "neispravan zahtev"})
return
}
if strings.TrimSpace(req.Message) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "poruka je obavezna"})
return
}
// Build prompt with context
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
context := buildTaskContext(tasks)
projectRoot := filepath.Dir(s.Config.TasksDir)
claudeMD, _ := os.ReadFile(filepath.Join(projectRoot, "CLAUDE.md"))
prompt := fmt.Sprintf("Kontekst (CLAUDE.md):\n%s\n\nTrenutni taskovi:\n%s\n\nOperater kaže:\n%s",
string(claudeMD), context, req.Message)
// Create chat session
chatID := s.console.nextExecID()
chat := &chatState{
id: chatID,
listeners: make(map[chan string]bool),
}
s.chatMu.Lock()
s.chats[chatID] = chat
s.chatMu.Unlock()
// Spawn claude CLI process in background
go s.runChatCommand(chat, prompt)
c.JSON(http.StatusOK, gin.H{"chat_id": chatID})
}
// runChatCommand executes claude CLI in a PTY and streams output to chat listeners.
func (s *Server) runChatCommand(chat *chatState, prompt string) {
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
cmd.Dir = s.projectRoot()
cmd.Env = cleanEnv()
ptmx, err := startPTY(cmd)
if err != nil {
sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]")
finishChat(chat)
return
}
defer ptmx.Close()
chat.mu.Lock()
chat.cmd = cmd
chat.mu.Unlock()
// Read PTY output and send to chat
readPTY(ptmx, func(line string) {
sendChatLine(chat, line)
})
cmd.Wait()
finishChat(chat)
}
// sendChatLine sends a line to all chat listeners and stores in output buffer.
func sendChatLine(chat *chatState, line string) {
chat.mu.Lock()
defer chat.mu.Unlock()
chat.output = append(chat.output, line)
for ch := range chat.listeners {
select {
case ch <- line:
default:
}
}
}
// finishChat marks a chat as done and signals all listeners.
func finishChat(chat *chatState) {
chat.mu.Lock()
defer chat.mu.Unlock()
chat.done = true
chat.cmd = nil
for ch := range chat.listeners {
select {
case ch <- "[DONE]":
default:
}
}
}
// handleChatStream serves an SSE stream for a chat session.
func (s *Server) handleChatStream(c *gin.Context) {
chatID := c.Param("id")
s.chatMu.RLock()
chat := s.chats[chatID]
s.chatMu.RUnlock()
if chat == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
ch := make(chan string, 100)
chat.mu.Lock()
// Replay buffered output
for _, line := range chat.output {
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
}
c.Writer.Flush()
// If already done, send done event and return
if chat.done {
chat.mu.Unlock()
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
c.Writer.Flush()
return
}
chat.listeners[ch] = true
chat.mu.Unlock()
defer func() {
chat.mu.Lock()
delete(chat.listeners, ch)
chat.mu.Unlock()
}()
notify := c.Request.Context().Done()
for {
select {
case <-notify:
return
case line := <-ch:
if line == "[DONE]" {
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
c.Writer.Flush()
return
}
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
c.Writer.Flush()
}
}
}
// buildTaskContext creates a text summary of current tasks for the system prompt.
func buildTaskContext(tasks []supervisor.Task) string {
var sb strings.Builder
for _, status := range []string{"backlog", "ready", "active", "review", "done"} {
sb.WriteString("### " + strings.ToUpper(status) + "\n")
found := false
for _, t := range tasks {
if t.Status == status {
sb.WriteString(fmt.Sprintf("- %s: %s (%s, %s)\n", t.ID, t.Title, t.Agent, t.Model))
found = true
}
}
if !found {
sb.WriteString("(prazno)\n")
}
sb.WriteString("\n")
}
return sb.String()
}