KAOS/code/internal/server/console.go
djuka 510b75c0bf Konzola: dinamičke task sesije sa PTY per task
- Zamena fiksnih 2 sesija sa taskSessionManager (map po task ID)
- "Pusti" pokreće interaktivni claude u PTY, šalje task prompt
- "Proveri" pokreće review claude sesiju za task u review/
- WS se konektuje na postojeću PTY sesiju po task ID-u
- Konzola stranica dinamički prikazuje terminale za aktivne sesije
- Replay buffer za reconnect na postojeće sesije
- Novi testovi za session manager, prompt buildere, review endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:32:34 +00:00

269 lines
6.8 KiB
Go

package server
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// taskSession represents a PTY session tied to a specific task.
type taskSession struct {
TaskID string
Type string // "work" or "review"
PTY *consolePTYSession
Started time.Time
}
// taskSessionResponse is the JSON representation of a task session.
type taskSessionResponse struct {
TaskID string `json:"task_id"`
Type string `json:"type"`
Status string `json:"status"` // "running" or "exited"
Started string `json:"started"`
}
// taskSessionManager manages dynamic PTY sessions per task.
type taskSessionManager struct {
mu sync.RWMutex
sessions map[string]*taskSession
}
func newTaskSessionManager() *taskSessionManager {
return &taskSessionManager{
sessions: make(map[string]*taskSession),
}
}
// sessionKey returns a unique key for a task session.
func sessionKey(taskID, sessionType string) string {
if sessionType == "review" {
return taskID + "-review"
}
return taskID
}
// startSession spawns an interactive claude PTY and sends the task prompt.
func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prompt string) (*taskSession, error) {
key := sessionKey(taskID, sessionType)
sm.mu.Lock()
if _, exists := sm.sessions[key]; exists {
sm.mu.Unlock()
return nil, fmt.Errorf("sesija %s već postoji", key)
}
sm.mu.Unlock()
ptySess, err := spawnTaskPTY(projectDir)
if err != nil {
return nil, err
}
sess := &taskSession{
TaskID: taskID,
Type: sessionType,
PTY: ptySess,
Started: time.Now(),
}
sm.mu.Lock()
sm.sessions[key] = sess
sm.mu.Unlock()
log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid)
// Send the task prompt after claude initializes
go func() {
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
ch := ptySess.Subscribe(subID)
timer := time.NewTimer(30 * time.Second)
select {
case <-ch:
// Claude is alive and producing output
case <-timer.C:
log.Printf("Session[%s]: timeout waiting for claude to start", key)
case <-ptySess.Done():
log.Printf("Session[%s]: claude exited before producing output", key)
ptySess.Unsubscribe(subID)
return
}
timer.Stop()
ptySess.Unsubscribe(subID)
// Let claude fully render its welcome screen
time.Sleep(2 * time.Second)
// Type the prompt
log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt))
ptySess.WriteInput([]byte(prompt + "\n"))
}()
return sess, nil
}
// getSessionByKey returns a session by its full key.
func (sm *taskSessionManager) getSessionByKey(key string) *taskSession {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.sessions[key]
}
// listSessions returns all active sessions.
func (sm *taskSessionManager) listSessions() []taskSessionResponse {
sm.mu.RLock()
defer sm.mu.RUnlock()
result := make([]taskSessionResponse, 0, len(sm.sessions))
for _, sess := range sm.sessions {
status := "running"
select {
case <-sess.PTY.Done():
status = "exited"
default:
}
result = append(result, taskSessionResponse{
TaskID: sess.TaskID,
Type: sess.Type,
Status: status,
Started: sess.Started.Format("15:04:05"),
})
}
return result
}
// killSession terminates a session and removes it.
func (sm *taskSessionManager) killSession(taskID, sessionType string) bool {
key := sessionKey(taskID, sessionType)
sm.mu.Lock()
sess, exists := sm.sessions[key]
if exists {
delete(sm.sessions, key)
}
sm.mu.Unlock()
if !exists {
return false
}
log.Printf("Session[%s]: killed", key)
sess.PTY.Close()
return true
}
// handleConsolePage serves the console HTML page.
func (s *Server) handleConsolePage(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderConsolePage())
}
// handleConsoleSessions returns all active task sessions as JSON.
func (s *Server) handleConsoleSessions(c *gin.Context) {
sessions := s.console.listSessions()
c.JSON(http.StatusOK, sessions)
}
// handleConsoleKill kills a task session.
func (s *Server) handleConsoleKill(c *gin.Context) {
taskID := c.Param("taskID")
sessionType := c.DefaultQuery("type", "work")
if s.console.killSession(taskID, sessionType) {
c.JSON(http.StatusOK, gin.H{"status": "killed", "task": taskID})
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
}
}
// buildWorkPrompt builds the prompt for a work session.
func buildWorkPrompt(taskID string, taskContent []byte) string {
return fmt.Sprintf(`Radiš na tasku %s. Evo sadržaj taska:
%s
PRAVILA:
1. Pročitaj agents/coder/CLAUDE.md za pravila kodiranja
2. Kod piši u code/ folderu
3. Svi testovi moraju proći: go test ./... -count=1
4. Build mora proći: go build ./...
5. go vet ./... mora proći
6. Commituj sa porukom: %s: Opis na srpskom
7. Napiši izveštaj u TASKS/reports/%s-report.md
8. Premesti task fajl: mv TASKS/active/%s.md TASKS/review/%s.md
9. Kada sve završiš, reci "GOTOVO"`, taskID, string(taskContent), taskID, taskID, taskID, taskID)
}
// buildReviewPrompt builds the prompt for a review session.
func buildReviewPrompt(taskID string, taskContent, reportContent []byte) string {
prompt := fmt.Sprintf(`Pregledaj task %s. Evo sadržaj taska:
%s
`, taskID, string(taskContent))
if len(reportContent) > 0 {
prompt += fmt.Sprintf(`
Izveštaj agenta:
%s
`, string(reportContent))
}
prompt += fmt.Sprintf(`
KORACI PREGLEDA:
1. Pročitaj agents/checker/CLAUDE.md za pravila
2. Proveri da li je kod napisan prema zadatku
3. Pokreni testove: go test ./... -count=1
4. Pokreni build: go build ./...
5. Pokreni vet: go vet ./...
6. Ako je SVE u redu:
- Premesti task: mv TASKS/review/%s.md TASKS/done/%s.md
- Reci "ODOBRENO"
7. Ako NIJE u redu:
- Zapiši šta treba popraviti u task fajl
- Premesti task: mv TASKS/review/%s.md TASKS/active/%s.md
- Reci "VRAĆENO NA DORADU"`, taskID, taskID, taskID, taskID)
return prompt
}
// handleReviewTask launches a review claude session for a task.
func (s *Server) handleReviewTask(c *gin.Context) {
id := c.Param("id")
// Read task content
taskPath := filepath.Join(s.Config.TasksDir, "review", id+".md")
taskContent, err := os.ReadFile(taskPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task nije pronađen u review/"})
return
}
// Read report if exists
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
reportContent, _ := os.ReadFile(reportPath)
// Build review prompt
prompt := buildReviewPrompt(id, taskContent, reportContent)
// Start review session
_, err = s.console.startSession(id, "review", s.projectRoot(), prompt)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "started", "task": id, "type": "review"})
}
// timeNow returns the current time formatted as HH:MM:SS.
func timeNow() string {
return time.Now().Format("15:04:05")
}