This repository has been archived on 2026-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
anvillm/internal/backends/kirocli.go

211 lines
5.9 KiB
Go

// Package backends provides concrete backend implementations
package backends
import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"9fans.net/go/plan9/client"
)
// NewKiroCLI creates a kiro-cli backend
// Runs with --trust-all-tools because landrun provides the actual sandboxing
func NewKiroCLI(nsSuffix string) backend.Backend {
return tmux.New(tmux.Config{
Name: "kiro-cli",
Command: []string{"kiro-cli", "chat", "--agent", "anvillm-agent", "--trust-all-tools"},
Environment: map[string]string{
"TERM": "xterm-256color",
"NO_COLOR": "1",
"COLUMNS": "999",
"NAMESPACE": client.Namespace(),
},
TmuxSize: tmux.TmuxSize{
Rows: 40,
Cols: 120,
},
StartupTime: 30 * time.Second,
StateInspector: &kiroStateInspector{},
ClearHandler: kiroClearHandler,
ResumeHandler: kiroResumeHandler,
CompactHandler: kiroCompactHandler,
NsSuffix: nsSuffix,
})
}
// kiroClearHandler sends /clear and confirms the "y" prompt for kiro-cli
func kiroClearHandler(target string) error {
if err := tmux.SendKeysTo(target, "/clear", "C-m"); err != nil {
return err
}
time.Sleep(250 * time.Millisecond)
return tmux.SendKeysTo(target, "y", "C-m")
}
// kiroResumeHandler sends /chat resume and confirms selection for kiro-cli
func kiroResumeHandler(target string) error {
if err := tmux.SendKeysTo(target, "/chat resume", "C-m"); err != nil {
return err
}
time.Sleep(250 * time.Millisecond)
return tmux.SendKeysTo(target, "C-m")
}
// kiroCompactHandler sends /compact for kiro-cli
func kiroCompactHandler(target string) error {
return tmux.SendKeysTo(target, "/compact", "C-m")
}
// kiroStateInspector implements StateInspector for kiro-cli
type kiroStateInspector struct{}
func (i *kiroStateInspector) IsBusy(panePID int) bool {
// Find kiro-cli-chat PID in the process tree: pane -> bash -> kiro-cli -> kiro-cli-chat
chatPID := tmux.FindKiroChatPID(panePID)
if chatPID == 0 {
return false
}
// Check for done marker from Kiro's stop hook
doneMarker := fmt.Sprintf("/tmp/anvillm-kiro-done-%d", chatPID)
if _, err := os.Stat(doneMarker); err == nil {
// Done marker exists - agent is truly idle
return false
}
// Check if kiro-cli-chat has any children (tool executions)
cmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", chatPID))
if cmd.Run() == nil {
return true // has children = busy
}
// No children - check if process is actively running (not sleeping waiting for input)
return isProcessActive(chatPID)
}
// isProcessActive checks if a process is doing work (not blocked waiting for input)
func isProcessActive(pid int) bool {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return false
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return false
}
state := fields[2]
// R = running, D = disk sleep (I/O), both indicate active work
// S = interruptible sleep (waiting for input) = idle
return state == "R" || state == "D"
}
// Helper functions
func stripANSI(s string) string {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
return ansiRegex.ReplaceAllString(s, "")
}
// SessionsDir returns the path to the sessions storage directory
func SessionsDir() string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "anvillm", "sessions")
os.MkdirAll(dir, 0755)
return dir
}
// GenerateSmartFilename creates a descriptive filename from saved conversation JSON
func GenerateSmartFilename(jsonPath string, windowBody string) (string, error) {
data, err := os.ReadFile(jsonPath)
if err != nil {
return "", err
}
// Extract user prompt text - try JSON first, then window body
var summaryText string
// Try to find user prompts in the JSON
promptRe := regexp.MustCompile(`"prompt"\s*:\s*"([^"]{10,200})"`)
if matches := promptRe.FindAllStringSubmatch(string(data), 5); len(matches) > 0 {
for _, m := range matches {
text := m[1]
// Skip if it looks like JSON/tool output
if !strings.Contains(text, "exit_status") && !strings.Contains(text, "\\n\\t") {
summaryText = text
break
}
}
}
// If no prompt in JSON, try to extract from window body
if summaryText == "" && windowBody != "" {
if idx := strings.Index(windowBody, "USER:"); idx >= 0 {
userText := strings.TrimSpace(windowBody[idx+5:])
// Take up to next section marker
if end := strings.Index(userText, "\n\nA:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n\nASSISTANT:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n---"); end > 0 {
userText = userText[:end]
}
userText = strings.TrimSpace(userText)
if len(userText) > 200 {
userText = userText[:200]
}
if len(userText) > 5 {
summaryText = userText
}
}
}
// Extract words for filename
var parts []string
if summaryText != "" {
summaryText = strings.ToLower(summaryText)
wordRe := regexp.MustCompile(`[a-z]+`)
words := wordRe.FindAllString(summaryText, 10)
stopWords := []string{
"a", "an", "and", "are", "as", "at", "be", "by", "can", "do",
"for", "from", "has", "have", "he", "her", "his", "how", "if",
"in", "into", "is", "it", "its", "just", "me", "my", "no", "not",
"of", "on", "or", "our", "out", "so", "some", "than", "that",
"the", "their", "them", "then", "there", "these", "they", "this",
"to", "up", "us", "was", "we", "what", "when", "which", "who",
"will", "with", "would", "you", "your",
}
for _, w := range words {
if len(w) > 2 && !contains(stopWords, w) {
parts = append(parts, w)
if len(parts) >= 5 {
break
}
}
}
}
if len(parts) == 0 {
parts = append(parts, "chat")
}
filename := time.Now().Format("20060102T150405") + "-" + strings.Join(parts, "-") + ".json"
return filepath.Join(SessionsDir(), filename), nil
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}