package session import ( "context" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" "github.com/fmartingr/ccrm/daemon/internal/claude" "github.com/fmartingr/ccrm/daemon/internal/tmux" ) // OnMessageFunc is called by the manager when a subprocess emits a message. // The session name is provided so the caller can route the message. type OnMessageFunc func(sessionName string, msg *claude.CLIMessage) // OnProcessExitFunc is called when a subprocess exits. The manager handles // respawning internally; this callback is for logging/relay purposes only. type OnProcessExitFunc func(sessionName string, willRespawn bool) // Manager manages the lifecycle of Claude Code sessions. type Manager struct { mu sync.RWMutex sessions map[string]*Session // keyed by session name prefix string claudeBin string dataDir string onMessage OnMessageFunc onProcessExit OnProcessExitFunc } // NewManager creates a new session manager. func NewManager(prefix, claudeBin, dataDir string, onMessage OnMessageFunc, onProcessExit OnProcessExitFunc) *Manager { return &Manager{ sessions: make(map[string]*Session), prefix: prefix, claudeBin: claudeBin, dataDir: dataDir, onMessage: onMessage, onProcessExit: onProcessExit, } } // Start creates a new session by creating a tmux session and spawning a Claude subprocess. func (m *Manager) Start(name, projectPath string, claudeArgs []string) (*Session, error) { m.mu.Lock() defer m.mu.Unlock() if _, exists := m.sessions[name]; exists { return nil, fmt.Errorf("session %q already exists", name) } tmuxName := m.prefix + name // Create tmux session if err := tmux.CreateSession(tmuxName, projectPath); err != nil { return nil, fmt.Errorf("creating tmux session: %w", err) } now := time.Now().UTC() // Create log file directory sessionsDir := filepath.Join(m.dataDir, "sessions") if err := os.MkdirAll(sessionsDir, 0755); err != nil { _ = tmux.KillSession(tmuxName) return nil, fmt.Errorf("creating sessions directory: %w", err) } logPath := filepath.Join(sessionsDir, name+".jsonl") // Create log file so tail -f works immediately if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { f.Close() } s := &Session{ Name: name, TmuxSession: tmuxName, ProjectPath: projectPath, LogPath: logPath, ClaudeArgs: claudeArgs, Status: StatusStarting, Mode: ModeSubprocess, CreatedAt: now, LastEventAt: now, } // Set tmux metadata options m.setTmuxMetadata(s) // Start monitoring in tmux pane m.restartMonitoring(s) // Spawn subprocess if err := m.spawnSubprocess(s); err != nil { _ = tmux.KillSession(tmuxName) return nil, fmt.Errorf("starting claude subprocess: %w", err) } m.sessions[name] = s return s, nil } // Stop gracefully stops a session. Marks it as explicitly stopped so it // won't be respawned. func (m *Manager) Stop(name string) error { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists { return fmt.Errorf("session %q not found", name) } s.ExplicitStop = true switch s.Mode { case ModeSubprocess: if s.Process != nil && s.Process.Alive() { _ = s.Process.Kill() <-s.Process.Done() } case ModeInteractive: _ = tmux.SendKeys(s.TmuxSession, "/exit") _ = tmux.SendEnter(s.TmuxSession) time.Sleep(500 * time.Millisecond) } s.Status = StatusStopped s.Process = nil m.syncToTmux(s) // Kill the tmux session _ = tmux.KillSession(s.TmuxSession) return nil } // Kill force-kills a session. func (m *Manager) Kill(name string) error { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists { return fmt.Errorf("session %q not found", name) } s.ExplicitStop = true if s.Process != nil { _ = s.Process.Kill() } s.Status = StatusStopped s.Process = nil m.syncToTmux(s) // Kill the tmux session _ = tmux.KillSession(s.TmuxSession) return nil } // Get returns a session by name. func (m *Manager) Get(name string) (*Session, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.sessions[name] return s, ok } // List returns all tracked sessions. func (m *Manager) List() []*Session { m.mu.RLock() defer m.mu.RUnlock() result := make([]*Session, 0, len(m.sessions)) for _, s := range m.sessions { result = append(result, s) } return result } // UpdateStatus changes the status of a session. func (m *Manager) UpdateStatus(name string, status Status) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[name]; ok { s.Status = status s.LastEventAt = time.Now().UTC() m.syncToTmux(s) } } // UpdateClaudeSession updates the Claude session ID and transcript path. func (m *Manager) UpdateClaudeSession(name, claudeSessionID, transcriptPath string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[name]; ok { if claudeSessionID != "" { s.ClaudeSessionID = claudeSessionID } if transcriptPath != "" { s.TranscriptPath = transcriptPath } m.syncToTmux(s) } } // SendPrompt sends a prompt to a session. If the subprocess has exited // (normal for --print mode), it respawns with --resume before sending. func (m *Manager) SendPrompt(name, prompt string) error { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists { return fmt.Errorf("session %q not found", name) } if s.Mode == ModeInteractive { return fmt.Errorf("session is in interactive mode, use the terminal") } if s.ExplicitStop { return fmt.Errorf("session is stopped") } switch s.Status { case StatusActive: s.PendingPrompts = append(s.PendingPrompts, prompt) return nil case StatusWaitingPermission: return fmt.Errorf("session is waiting for permission, cannot send prompt") } // Respawn subprocess if needed (--print exits after each turn) if s.Process == nil || !s.Process.Alive() { if err := m.spawnSubprocess(s); err != nil { return fmt.Errorf("respawning subprocess: %w", err) } } if err := s.Process.WriteMessage(&claude.UserMessage{ Type: "user_message", Content: prompt, }); err != nil { return fmt.Errorf("writing prompt to subprocess: %w", err) } s.Status = StatusActive s.LastEventAt = time.Now().UTC() m.syncToTmux(s) return nil } // DrainPendingPrompts sends the next queued prompt to a session. func (m *Manager) DrainPendingPrompts(name string) { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists || len(s.PendingPrompts) == 0 || s.ExplicitStop { return } // Respawn subprocess if needed (--print exits after each turn) if s.Process == nil || !s.Process.Alive() { if err := m.spawnSubprocess(s); err != nil { log.Printf("failed to respawn subprocess for pending prompt %s: %v", name, err) return } } prompt := s.PendingPrompts[0] s.PendingPrompts = s.PendingPrompts[1:] if err := s.Process.WriteMessage(&claude.UserMessage{ Type: "user_message", Content: prompt, }); err != nil { log.Printf("failed to drain pending prompt for %s: %v", name, err) return } s.Status = StatusActive s.LastEventAt = time.Now().UTC() m.syncToTmux(s) } // RespondToPermission responds to a tool permission request. func (m *Manager) RespondToPermission(name, requestID string, allow bool, reason string) error { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists { return fmt.Errorf("session %q not found", name) } if s.Mode != ModeSubprocess || s.Process == nil || !s.Process.Alive() { return fmt.Errorf("session is not in subprocess mode or process is not running") } return s.Process.WriteMessage(&claude.ControlResponse{ Type: "tool_use_permission_response", RequestID: requestID, Allow: allow, Reason: reason, }) } // Attach switches a session from subprocess mode to interactive mode. // Returns the tmux session name for the CLI to attach to. func (m *Manager) Attach(name string) (string, error) { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists { return "", fmt.Errorf("session %q not found", name) } if s.Mode == ModeInteractive { // Already in interactive mode, return existing tmux session return s.TmuxSession, nil } if s.ClaudeSessionID == "" { return "", fmt.Errorf("session %q has no Claude session ID yet (send a prompt first)", name) } // Kill subprocess if alive if s.Process != nil && s.Process.Alive() { _ = s.Process.Kill() <-s.Process.Done() } s.Process = nil // Interrupt the monitoring command in the tmux pane _ = tmux.SendCtrlC(s.TmuxSession) time.Sleep(100 * time.Millisecond) // Send claude --resume into the existing tmux pane cmd := fmt.Sprintf("%s --resume %s", m.claudeBin, s.ClaudeSessionID) if err := tmux.SendKeys(s.TmuxSession, cmd); err != nil { // Recover: restart monitoring and respawn subprocess m.restartMonitoring(s) _ = m.spawnSubprocess(s) return "", fmt.Errorf("sending resume command: %w", err) } if err := tmux.SendEnter(s.TmuxSession); err != nil { m.restartMonitoring(s) _ = m.spawnSubprocess(s) return "", fmt.Errorf("sending enter: %w", err) } s.Mode = ModeInteractive s.Status = StatusActive s.LastEventAt = time.Now().UTC() m.syncToTmux(s) // Monitor the interactive session in the background go m.watchInteractiveSession(name) return s.TmuxSession, nil } // Detach switches a session from interactive mode back to subprocess mode. func (m *Manager) Detach(name string) error { m.mu.Lock() defer m.mu.Unlock() s, exists := m.sessions[name] if !exists { return fmt.Errorf("session %q not found", name) } if s.Mode != ModeInteractive { return nil // already in subprocess mode } // Send /exit to claude in the tmux pane _ = tmux.SendKeys(s.TmuxSession, "/exit") _ = tmux.SendEnter(s.TmuxSession) time.Sleep(500 * time.Millisecond) // Ensure clean state _ = tmux.SendCtrlC(s.TmuxSession) time.Sleep(100 * time.Millisecond) // Restart monitoring in the tmux pane m.restartMonitoring(s) // Switch back to subprocess mode. Don't spawn yet — it will be // spawned on demand when the next prompt arrives. s.Process = nil s.Mode = ModeSubprocess s.Status = StatusIdle s.LastEventAt = time.Now().UTC() m.syncToTmux(s) return nil } // Remove removes a session from the registry. func (m *Manager) Remove(name string) { m.mu.Lock() defer m.mu.Unlock() delete(m.sessions, name) } // FindByClaudeSessionID finds a session by its Claude session ID. func (m *Manager) FindByClaudeSessionID(claudeSessionID string) (*Session, bool) { m.mu.RLock() defer m.mu.RUnlock() for _, s := range m.sessions { if s.ClaudeSessionID == claudeSessionID { return s, true } } return nil, false } // RecoverSessions scans tmux for existing ccrm sessions and recovers them // into the in-memory session map. Called on daemon startup. func (m *Manager) RecoverSessions() error { m.mu.Lock() defer m.mu.Unlock() tmuxSessions, err := tmux.ListSessionsWithPrefix(m.prefix) if err != nil { return fmt.Errorf("listing tmux sessions: %w", err) } for _, tmuxName := range tmuxSessions { name := strings.TrimPrefix(tmuxName, m.prefix) // Skip if already tracked if _, exists := m.sessions[name]; exists { continue } // Read metadata from tmux options status, _ := tmux.GetOption(tmuxName, "@ccrm_status") if status == string(StatusStopped) { // Clean up stopped sessions _ = tmux.KillSession(tmuxName) continue } mode, _ := tmux.GetOption(tmuxName, "@ccrm_mode") projectPath, _ := tmux.GetOption(tmuxName, "@ccrm_project_path") claudeSessionID, _ := tmux.GetOption(tmuxName, "@ccrm_claude_session_id") createdAtStr, _ := tmux.GetOption(tmuxName, "@ccrm_created_at") claudeArgsJSON, _ := tmux.GetOption(tmuxName, "@ccrm_claude_args") createdAt, _ := time.Parse(time.RFC3339, createdAtStr) if createdAt.IsZero() { createdAt = time.Now().UTC() } var claudeArgs []string if claudeArgsJSON != "" { _ = json.Unmarshal([]byte(claudeArgsJSON), &claudeArgs) } sessionsDir := filepath.Join(m.dataDir, "sessions") logPath := filepath.Join(sessionsDir, name+".jsonl") s := &Session{ Name: name, TmuxSession: tmuxName, ProjectPath: projectPath, ClaudeSessionID: claudeSessionID, LogPath: logPath, ClaudeArgs: claudeArgs, Mode: Mode(mode), CreatedAt: createdAt, LastEventAt: time.Now().UTC(), } if s.Mode == ModeInteractive { s.Status = StatusActive m.sessions[name] = s go m.watchInteractiveSession(name) log.Printf("Recovered interactive session %q from tmux", name) } else { s.Mode = ModeSubprocess s.Status = StatusIdle // Restart monitoring in case it was interrupted m.restartMonitoring(s) m.sessions[name] = s log.Printf("Recovered subprocess session %q from tmux (idle, will spawn on next prompt)", name) } } return nil } // spawnSubprocess spawns a new Claude subprocess for the session. // Uses --resume if a ClaudeSessionID is available. // Must be called with m.mu held. func (m *Manager) spawnSubprocess(s *Session) error { cfg := claude.ProcessConfig{ ClaudeBin: m.claudeBin, WorkDir: s.ProjectPath, ExtraArgs: s.ClaudeArgs, } if s.ClaudeSessionID != "" { cfg.ResumeSessionID = s.ClaudeSessionID } proc := claude.NewProcess(cfg) name := s.Name if err := proc.Start(context.Background(), func(msg *claude.CLIMessage) { // Append raw JSON line to session log m.appendToLog(s.LogPath, msg.Raw) if m.onMessage != nil { m.onMessage(name, msg) } }); err != nil { return err } s.Process = proc s.Mode = ModeSubprocess s.LastEventAt = time.Now().UTC() go m.watchProcessExit(name) return nil } // appendToLog appends a raw JSON line to the session log file. func (m *Manager) appendToLog(logPath string, raw json.RawMessage) { if logPath == "" || len(raw) == 0 { return } f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Printf("Failed to open log file %s: %v", logPath, err) return } defer f.Close() line := append(raw, '\n') if _, err := f.Write(line); err != nil { log.Printf("Failed to write to log file %s: %v", logPath, err) } } // syncToTmux writes session state to tmux options. // Must be called with m.mu held. func (m *Manager) syncToTmux(s *Session) { if s.TmuxSession == "" || !tmux.HasSession(s.TmuxSession) { return } _ = tmux.SetOption(s.TmuxSession, "@ccrm_status", string(s.Status)) _ = tmux.SetOption(s.TmuxSession, "@ccrm_mode", string(s.Mode)) if s.ClaudeSessionID != "" { _ = tmux.SetOption(s.TmuxSession, "@ccrm_claude_session_id", s.ClaudeSessionID) } } // setTmuxMetadata writes all initial metadata to tmux options. // Must be called with m.mu held. func (m *Manager) setTmuxMetadata(s *Session) { if s.TmuxSession == "" { return } _ = tmux.SetOption(s.TmuxSession, "@ccrm_name", s.Name) _ = tmux.SetOption(s.TmuxSession, "@ccrm_project_path", s.ProjectPath) _ = tmux.SetOption(s.TmuxSession, "@ccrm_created_at", s.CreatedAt.Format(time.RFC3339)) _ = tmux.SetOption(s.TmuxSession, "@ccrm_status", string(s.Status)) _ = tmux.SetOption(s.TmuxSession, "@ccrm_mode", string(s.Mode)) if len(s.ClaudeArgs) > 0 { argsJSON, _ := json.Marshal(s.ClaudeArgs) _ = tmux.SetOption(s.TmuxSession, "@ccrm_claude_args", string(argsJSON)) } } // restartMonitoring restarts the tail -f command in the tmux pane. // Must be called with m.mu held. func (m *Manager) restartMonitoring(s *Session) { if s.TmuxSession == "" || s.LogPath == "" { return } _ = tmux.RespawnPane(s.TmuxSession, fmt.Sprintf("tail -f %s", s.LogPath)) } // watchProcessExit waits for the subprocess to exit and handles the lifecycle. // With --print mode, the process exits after each turn. This is normal — // the session stays alive and a new process is spawned on the next prompt. func (m *Manager) watchProcessExit(name string) { m.mu.RLock() s, exists := m.sessions[name] if !exists || s.Process == nil { m.mu.RUnlock() return } proc := s.Process m.mu.RUnlock() <-proc.Done() m.mu.RLock() s, exists = m.sessions[name] if !exists { m.mu.RUnlock() return } // Only act if this is still the same process (not replaced by respawn/attach) if s.Process != proc { m.mu.RUnlock() return } explicitStop := s.ExplicitStop mode := s.Mode m.mu.RUnlock() if explicitStop || mode == ModeInteractive { // User explicitly stopped or session switched to interactive mode. // Don't respawn. return } // Normal --print exit: set idle, notify, and let next SendPrompt respawn. log.Printf("Session %s: subprocess exited (normal turn completion)", name) m.mu.Lock() if s, ok := m.sessions[name]; ok && s.Process == proc { s.Process = nil if s.Status != StatusIdle { s.Status = StatusIdle } m.syncToTmux(s) } m.mu.Unlock() if m.onProcessExit != nil { m.onProcessExit(name, true) } // Drain pending prompts if any (will respawn subprocess) m.DrainPendingPrompts(name) } // watchInteractiveSession periodically checks if the interactive tmux session // still has attached clients. When the user detaches or the session ends, // it switches back to subprocess mode. func (m *Manager) watchInteractiveSession(name string) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for range ticker.C { m.mu.RLock() s, exists := m.sessions[name] if !exists || s.Mode != ModeInteractive { m.mu.RUnlock() return } tmuxName := s.TmuxSession m.mu.RUnlock() if tmuxName == "" { return } // Check if tmux session still exists if !tmux.HasSession(tmuxName) { log.Printf("Interactive session %s ended, switching back to subprocess mode", name) _ = m.Detach(name) return } // Check if any clients are attached numClients, err := tmux.SessionAttachedClients(tmuxName) if err != nil { continue } if numClients == 0 { log.Printf("User detached from interactive session %s, switching back to subprocess mode", name) _ = m.Detach(name) return } } }