696 lines
18 KiB
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
|
|
}
|
|
}
|
|
}
|