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") }