// Package server — submit.go handles task submission in two modes: // client (simple form) and operator (chat via claude CLI). package server import ( "bufio" "fmt" "io" "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 and streams output to chat listeners. func (s *Server) runChatCommand(chat *chatState, prompt string) { cmd := exec.Command("claude", "--permission-mode", "bypassPermissions", "-p", prompt) cmd.Dir = s.projectRoot() cmd.Env = cleanEnv() stdout, err := cmd.StdoutPipe() if err != nil { sendChatLine(chat, "[greška: "+err.Error()+"]") finishChat(chat) return } stderr, err := cmd.StderrPipe() if err != nil { sendChatLine(chat, "[greška: "+err.Error()+"]") finishChat(chat) return } chat.mu.Lock() chat.cmd = cmd chat.mu.Unlock() if err := cmd.Start(); err != nil { sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]") finishChat(chat) return } // Read stdout and stderr concurrently var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() chatStreamReader(chat, stdout) }() go func() { defer wg.Done() chatStreamReader(chat, stderr) }() wg.Wait() cmd.Wait() finishChat(chat) } // chatStreamReader reads from a reader line by line and sends to chat listeners. func chatStreamReader(chat *chatState, reader io.Reader) { scanner := bufio.NewScanner(reader) scanner.Buffer(make([]byte, 64*1024), 256*1024) for scanner.Scan() { sendChatLine(chat, scanner.Text()) } } // 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() }