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/session/session.go

626 lines
16 KiB
Go

// Package session manages persistent kiro-cli PTY sessions.
package session
import (
"bytes"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/creack/pty"
)
var sessionCounter uint64
// Session represents a persistent kiro-cli chat session.
type Session struct {
ID string
Alias string
Cwd string
WinID int
Pid int
State string // "starting", "idle", "running", or "error: ..."
cmd *exec.Cmd
ptmx *os.File
mu sync.Mutex
// Channel to receive data from the reader goroutine
dataCh chan []byte
// Channel to signal stop
stopCh chan struct{}
// Accumulated output
output bytes.Buffer
}
// Manager holds all active sessions.
type Manager struct {
sessions map[string]*Session
mu sync.RWMutex
}
// NewManager creates a session manager.
func NewManager() *Manager {
return &Manager{sessions: make(map[string]*Session)}
}
// New creates a new kiro-cli session in the given working directory.
func (m *Manager) New(cwd string) (*Session, error) {
id := fmt.Sprintf("%d", atomic.AddUint64(&sessionCounter, 1))
cmd := exec.Command("kiro-cli", "chat", "--trust-all-tools")
cmd.Dir = cwd
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120})
if err != nil {
return nil, fmt.Errorf("failed to start pty: %w", err)
}
s := &Session{
ID: id,
Cwd: cwd,
Pid: cmd.Process.Pid,
State: "starting",
cmd: cmd,
ptmx: ptmx,
dataCh: make(chan []byte, 100),
stopCh: make(chan struct{}),
}
// Start single reader goroutine
go s.reader()
m.mu.Lock()
m.sessions[id] = s
m.mu.Unlock()
// Wait for initial prompt - look for "> " which appears at end of prompt
err = s.waitForPattern("> ", 30*time.Second)
if err != nil {
log.Printf("[session %s] warning: %v", s.ID, err)
}
s.State = "idle"
s.output.Reset()
log.Printf("[session %s] ready", s.ID)
return s, nil
}
// reader continuously reads from PTY and sends to dataCh
func (s *Session) reader() {
buf := make([]byte, 4096)
for {
n, err := s.ptmx.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("[session %s] reader error: %v", s.ID, err)
}
close(s.dataCh)
// Reap the process to avoid zombies
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Wait()
}
s.State = "exited"
s.Pid = 0
return
}
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
s.dataCh <- data
}
}
}
// waitForPattern waits until the output contains the pattern
func (s *Session) waitForPattern(pattern string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
for {
select {
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
// Just strip ANSI, don't filter
clean := ansiRegex.ReplaceAllString(s.output.String(), "")
if strings.Contains(clean, pattern) {
return nil
}
case <-time.After(time.Until(deadline)):
return fmt.Errorf("timeout waiting for %q", pattern)
}
}
}
// waitForSecondPrompt waits for the response to complete (normal prompts end with "Time:")
func (s *Session) waitForSecondPrompt() error {
for {
select {
case <-s.stopCh:
return fmt.Errorf("interrupted")
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
log.Printf("[session %s] +%d bytes, total %d", s.ID, len(data), s.output.Len())
// Kiro prints "Time:" at the end of every response
if strings.Contains(s.output.String(), "Time:") {
// Drain any remaining data in channel
for {
select {
case extra, ok := <-s.dataCh:
if ok {
s.output.Write(extra)
}
default:
log.Printf("[session %s] response complete, %d bytes", s.ID, s.output.Len())
return nil
}
}
}
}
}
}
// waitForCommandComplete waits for slash command output (ends with next prompt, not "Time:")
func (s *Session) waitForCommandComplete() error {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
var contentLen int
for {
select {
case <-s.stopCh:
return fmt.Errorf("interrupted")
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
out := s.output.String()
log.Printf("[session %s] +%d bytes, total %d", s.ID, len(data), len(out))
// Strip ANSI and \r for checking
clean := ansiRegex.ReplaceAllString(out, "")
clean = strings.ReplaceAll(clean, "\r", "")
// Wait for substantial content before looking for end prompt
if len(clean) > contentLen+50 {
contentLen = len(clean)
}
// Look for a new prompt line after we have content
// Prompt pattern: newline followed by [something] then >
if contentLen > 200 && strings.Contains(clean, "\n[") {
// Check if there's a prompt after content
lines := strings.Split(clean, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] ") && strings.Contains(lines[i], ">") {
// Found end prompt, drain and return
for {
select {
case extra, ok := <-s.dataCh:
if ok {
s.output.Write(extra)
}
default:
log.Printf("[session %s] command complete, %d bytes", s.ID, s.output.Len())
return nil
}
}
}
}
}
}
}
}
func (s *Session) cleanedOutput() string {
out := s.output.String()
// Strip ANSI
clean := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`).ReplaceAllString(out, "")
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
inResponse := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Check for response marker FIRST - it may be on same line as spinner
if idx := strings.Index(line, "> "); idx >= 0 {
// Make sure it's the response marker, not part of prompt (prompts have [agent])
if !strings.Contains(line, "[") || !strings.Contains(line, "]") {
inResponse = true
text := strings.TrimSpace(line[idx+2:])
if text != "" {
result = append(result, text)
}
continue
}
}
// Skip noise
if strings.Contains(line, "Thinking...") ||
strings.Contains(line, "▸ Time:") ||
(strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")) ||
strings.HasPrefix(trimmed, "⠋") ||
strings.HasPrefix(trimmed, "⠙") ||
strings.HasPrefix(trimmed, "⠹") ||
strings.HasPrefix(trimmed, "⠸") ||
strings.HasPrefix(trimmed, "⠼") ||
strings.HasPrefix(trimmed, "⠴") ||
strings.HasPrefix(trimmed, "⠦") ||
strings.HasPrefix(trimmed, "⠧") ||
strings.HasPrefix(trimmed, "⠇") ||
strings.HasPrefix(trimmed, "⠏") ||
strings.HasPrefix(trimmed, "⢀") ||
strings.HasPrefix(trimmed, "⡀") ||
strings.HasPrefix(trimmed, "✓") {
continue
}
// Keep hooks lines
if strings.Contains(line, "hooks finished") {
result = append(result, trimmed)
continue
}
if inResponse && trimmed != "" {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// cleanedCommandOutput extracts output from slash commands (no "> " marker)
func (s *Session) cleanedCommandOutput() string {
out := s.output.String()
// Strip ANSI
clean := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`).ReplaceAllString(out, "")
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
collecting := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Detect prompt lines: [agent] >
isPrompt := strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")
if isPrompt {
if collecting {
// Second prompt = end
break
}
// Skip prompt lines but don't start collecting yet
continue
}
// Skip command echo and noise
if strings.HasPrefix(trimmed, "/") ||
strings.Contains(trimmed, "Switching to") ||
strings.HasPrefix(trimmed, "⠋") ||
strings.HasPrefix(trimmed, "⠙") ||
strings.HasPrefix(trimmed, "⠹") ||
strings.HasPrefix(trimmed, "⠸") ||
strings.HasPrefix(trimmed, "⠼") ||
strings.HasPrefix(trimmed, "⠴") ||
strings.HasPrefix(trimmed, "⠦") ||
strings.HasPrefix(trimmed, "⠧") ||
strings.HasPrefix(trimmed, "⠇") ||
strings.HasPrefix(trimmed, "⠏") ||
strings.HasPrefix(trimmed, "⢀") ||
strings.HasPrefix(trimmed, "⡀") {
continue
}
// Collect any non-noise content
collecting = true
result = append(result, trimmed)
}
return strings.Join(result, "\n")
}
// Get returns a session by ID.
func (m *Manager) Get(id string) *Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[id]
}
// List returns all session IDs.
func (m *Manager) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
ids := make([]string, 0, len(m.sessions))
for id := range m.sessions {
ids = append(ids, id)
}
return ids
}
// Remove removes a session from the manager.
func (m *Manager) Remove(id string) {
m.mu.Lock()
delete(m.sessions, id)
m.mu.Unlock()
}
// supportedCommands lists non-interactive slash commands that work via pty.
var supportedCommands = []string{"/chat load", "/chat save", "/help", "/agent list"}
// Send writes a prompt to the session and waits for response.
func (s *Session) Send(prompt string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptmx == nil {
return "", fmt.Errorf("session not running")
}
if s.State == "starting" {
return "", fmt.Errorf("session still starting, please wait")
}
// Reset stopCh for this request
select {
case <-s.stopCh:
// was closed, need new one
s.stopCh = make(chan struct{})
default:
}
// Check if slash command is supported
if strings.HasPrefix(prompt, "/") {
supported := false
for _, cmd := range supportedCommands {
if strings.HasPrefix(prompt, cmd) {
supported = true
break
}
}
if !supported {
log.Printf("[session %s] unsupported command %q, supported: %v", s.ID, prompt, supportedCommands)
return "", fmt.Errorf("unsupported command, supported: %v", supportedCommands)
}
}
s.State = "running"
s.output.Reset()
isCommand := strings.HasPrefix(prompt, "/")
log.Printf("[session %s] sending: %q", s.ID, prompt)
if _, err := s.ptmx.Write([]byte(prompt + "\r")); err != nil {
s.State = "idle"
return "", fmt.Errorf("write failed: %w", err)
}
// Slash commands wait for next prompt, normal prompts wait for "Time:"
var err error
if isCommand {
err = s.waitForCommandComplete()
} else {
err = s.waitForSecondPrompt()
}
if err != nil {
s.State = "idle"
return "", err
}
s.State = "idle"
var result string
if isCommand {
result = s.cleanedCommandOutput()
} else {
result = s.cleanedOutput()
}
log.Printf("[session %s] response: %d bytes", s.ID, len(result))
return result, nil
}
// extractResponse extracts the actual response from the output
func (s *Session) extractResponse() string {
clean := s.cleanedOutput()
// Find content between first "] > ...\n" and last "] > "
lines := strings.Split(clean, "\n")
var result []string
inResponse := false
for _, line := range lines {
if strings.Contains(line, "] > ") {
if inResponse {
break // Second prompt, stop
}
inResponse = true
continue
}
if inResponse {
// Skip spinner and hook lines
if strings.Contains(line, "Thinking...") ||
strings.Contains(line, "hooks finished") ||
strings.HasPrefix(strings.TrimSpace(line), "▸") {
continue
}
if trimmed := strings.TrimSpace(line); trimmed != "" {
result = append(result, line)
}
}
}
return strings.Join(result, "\n")
}
// SendCtl sends a control command (like /chat save, /chat load).
func (s *Session) SendCtl(cmd string) error {
_, err := s.Send(cmd)
return err
}
// Stop sends SIGINT to interrupt the current operation.
func (s *Session) Stop() error {
if s.ptmx == nil {
return fmt.Errorf("session not running")
}
// Signal waiters to abort - close channel, will be recreated by Send()
select {
case <-s.stopCh:
// already closed
default:
close(s.stopCh)
}
// Write ETX (Ctrl+C) to PTY
_, err := s.ptmx.Write([]byte{0x03})
return err
}
// Kill terminates the session.
func (s *Session) Kill() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
}
if s.ptmx != nil {
s.ptmx.Close()
s.ptmx = nil
}
s.Pid = 0
s.State = "idle"
return nil
}
// Output returns the output from the last command.
func (s *Session) Output() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.cleanedOutput()
}
// Err returns accumulated error/debug output.
func (s *Session) Err() string {
return ""
}
// SessionsDir returns the path to the sessions storage directory.
func SessionsDir() string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "acme-q", "sessions")
os.MkdirAll(dir, 0755)
return dir
}
// SavePath returns the save file path for a session.
func (s *Session) SavePath() string {
return filepath.Join(SessionsDir(), s.ID+".json")
}
// GenerateSmartFilename creates a descriptive filename from saved conversation JSON.
// It extracts first few meaningful words from user prompts.
// windowBody is optional extra text (from chat window) to search for content.
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 (first USER: with content)
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 && !slices.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
}
// Save persists the session conversation.
func (s *Session) Save() error {
return s.SendCtl("/chat save " + s.SavePath())
}
// Load restores a session conversation from file.
func (s *Session) Load(path string) error {
return s.SendCtl("/chat load " + path)
}