This repository has been archived on 2026-05-07. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
ccrm/daemon/internal/session/manager.go

696 lines
18 KiB
Go

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