diff --git a/acme-q b/acme-q index bfffcc9..9bbd0d7 100755 Binary files a/acme-q and b/acme-q differ diff --git a/internal/backend/tmux/helpers.go b/internal/backend/tmux/helpers.go index 38ef165..255542f 100644 --- a/internal/backend/tmux/helpers.go +++ b/internal/backend/tmux/helpers.go @@ -26,39 +26,60 @@ func createSession(name string, rows, cols uint16) error { return err } -// setEnvironment sets an environment variable in the tmux session -func setEnvironment(session, key, value string) error { - _, err := tmuxCmd("set-environment", "-t", session, key, value) +// createWindow creates a new window in an existing tmux session +func createWindow(session, windowName string) error { + _, err := tmuxCmd("new-window", "-t", session, "-n", windowName) return err } -// sendKeys sends keys to a tmux session +// killWindow kills a window in a tmux session +func killWindow(session, windowName string) error { + target := fmt.Sprintf("%s:%s", session, windowName) + cmd := exec.Command("tmux", "kill-window", "-t", target) + if err := cmd.Run(); err != nil { + return fmt.Errorf("tmux kill-window -t %s: %w", target, err) + } + return nil +} + +// windowTarget constructs a tmux target for a window in a session +func windowTarget(session, windowName string) string { + return fmt.Sprintf("%s:%s", session, windowName) +} + +// setEnvironment sets an environment variable in the tmux session or window +func setEnvironment(target, key, value string) error { + _, err := tmuxCmd("set-environment", "-t", target, key, value) + return err +} + +// sendKeys sends keys to a tmux target (session or session:window) // Special keys: C-m (Enter), C-c (Ctrl+C), etc. -func sendKeys(session string, keys ...string) error { - args := []string{"send-keys", "-t", session} +func sendKeys(target string, keys ...string) error { + args := []string{"send-keys", "-t", target} args = append(args, keys...) _, err := tmuxCmd(args...) return err } -// sendLiteral sends literal text to a tmux session (doesn't interpret special chars) -func sendLiteral(session, text string) error { - _, err := tmuxCmd("send-keys", "-t", session, "-l", text) +// sendLiteral sends literal text to a tmux target (doesn't interpret special chars) +func sendLiteral(target, text string) error { + _, err := tmuxCmd("send-keys", "-t", target, "-l", text) return err } // setupPipePane sets up pipe-pane to redirect output to a file/FIFO -func setupPipePane(session, target string) error { +func setupPipePane(tmuxTarget, fifoPath string) error { // -o flag: only capture stdout (not stdin) - cmd := fmt.Sprintf("cat >> %s", target) - _, err := tmuxCmd("pipe-pane", "-o", "-t", session, cmd) + cmd := fmt.Sprintf("cat >> %s", fifoPath) + _, err := tmuxCmd("pipe-pane", "-o", "-t", tmuxTarget, cmd) return err } -// closePipePane closes the pipe-pane for a session -func closePipePane(session string) error { +// closePipePane closes the pipe-pane for a target +func closePipePane(target string) error { // Calling pipe-pane with no command closes it - _, err := tmuxCmd("pipe-pane", "-t", session) + _, err := tmuxCmd("pipe-pane", "-t", target) return err } @@ -76,10 +97,10 @@ func sessionExists(name string) bool { return cmd.Run() == nil } -// getSessionPID returns the PID of the process running in the tmux session -func getSessionPID(session string) (int, error) { +// getPanePID returns the PID of the process running in a tmux pane +func getPanePID(target string) (int, error) { // Get the pane PID - out, err := tmuxCmd("display-message", "-t", session, "-p", "#{pane_pid}") + out, err := tmuxCmd("display-message", "-t", target, "-p", "#{pane_pid}") if err != nil { return 0, err } diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go index 905bd8b..be92520 100644 --- a/internal/backend/tmux/session.go +++ b/internal/backend/tmux/session.go @@ -16,7 +16,8 @@ import ( // Session implements backend.Session for tmux-based tools type Session struct { id string - sessionName string // tmux session name + tmuxSession string // persistent tmux session name (e.g., "anvillm-kiro-cli") + windowName string // window name within tmux session (same as id) cwd string alias string winID int @@ -38,6 +39,11 @@ type Session struct { mu sync.Mutex } +// target returns the tmux target for this window (session:window) +func (s *Session) target() string { + return windowTarget(s.tmuxSession, s.windowName) +} + func (s *Session) ID() string { return s.id } @@ -65,7 +71,8 @@ func (s *Session) Metadata() backend.SessionMetadata { WinID: s.winID, CreatedAt: s.createdAt, Extra: map[string]string{ - "tmux_session": s.sessionName, + "tmux_session": s.tmuxSession, + "tmux_window": s.windowName, }, } } @@ -141,7 +148,7 @@ func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error keys, done := s.startupHandler.HandleStartup(s.output.String()) if keys != "" { log.Printf("[session %s] startup handler sending keys: %q", s.id, keys) - if err := sendKeys(s.sessionName, keys); err != nil { + if err := sendKeys(s.target(), keys); err != nil { return fmt.Errorf("failed to send startup response: %w", err) } } @@ -182,7 +189,7 @@ func (s *Session) Send(ctx context.Context, prompt string) (string, error) { if strings.HasPrefix(prompt, "/") && s.commands != nil { if !s.commands.IsSupported(prompt) { cmd := strings.Fields(prompt)[0] - return "", fmt.Errorf("slash command not supported: %s (attach to tmux session to run: tmux attach -t %s)", cmd, s.sessionName) + return "", fmt.Errorf("slash command not supported: %s (attach to window: tmux select-window -t %s)", cmd, s.target()) } } @@ -199,13 +206,13 @@ func (s *Session) Send(ctx context.Context, prompt string) (string, error) { log.Printf("[session %s] sending: %q", s.id, prompt) // Send prompt using literal mode to avoid special character interpretation - if err := sendLiteral(s.sessionName, prompt); err != nil { + if err := sendLiteral(s.target(), prompt); err != nil { s.state = "idle" return "", fmt.Errorf("send literal failed: %w", err) } // Send Enter - if err := sendKeys(s.sessionName, "C-m"); err != nil { + if err := sendKeys(s.target(), "C-m"); err != nil { s.state = "idle" return "", fmt.Errorf("send enter failed: %w", err) } @@ -317,17 +324,22 @@ func (s *Session) Stop(ctx context.Context) error { } // Send CTRL+C - return sendKeys(s.sessionName, "C-c") + return sendKeys(s.target(), "C-c") } func (s *Session) Close() error { s.mu.Lock() defer s.mu.Unlock() - // 1. Kill tmux session first (closes pipe-pane writer) - if s.sessionName != "" { - killSession(s.sessionName) - s.sessionName = "" + log.Printf("[session %s] Close() called, killing window %s:%s", s.id, s.tmuxSession, s.windowName) + + // 1. Kill tmux window first (closes pipe-pane writer) + if s.tmuxSession != "" && s.windowName != "" { + if err := killWindow(s.tmuxSession, s.windowName); err != nil { + log.Printf("[session %s] killWindow failed: %v", s.id, err) + } else { + log.Printf("[session %s] Window %s:%s killed", s.id, s.tmuxSession, s.windowName) + } } // 2. Give writer time to close, which will send EOF to reader diff --git a/internal/backend/tmux/tmux.go b/internal/backend/tmux/tmux.go index cd6f495..a5f3bd2 100644 --- a/internal/backend/tmux/tmux.go +++ b/internal/backend/tmux/tmux.go @@ -4,8 +4,6 @@ package tmux import ( "acme-q/internal/backend" "context" - "crypto/rand" - "encoding/hex" "fmt" "log" "os" @@ -58,8 +56,9 @@ type Config struct { // Backend implements backend.Backend for tmux-based CLI tools type Backend struct { - cfg Config - counter uint64 + cfg Config + counter uint64 + tmuxSession string // Persistent tmux session name (e.g., "anvillm-kiro-cli") } // New creates a new tmux-based backend @@ -73,7 +72,31 @@ func New(cfg Config) backend.Backend { if cfg.TmuxSize.Cols == 0 { cfg.TmuxSize.Cols = 120 } - return &Backend{cfg: cfg} + return &Backend{ + cfg: cfg, + tmuxSession: fmt.Sprintf("anvillm-%s", cfg.Name), + } +} + +// ensureTmuxSession creates the persistent tmux session if it doesn't exist +func (b *Backend) ensureTmuxSession() error { + if sessionExists(b.tmuxSession) { + return nil + } + + log.Printf("[backend %s] creating persistent tmux session: %s", b.cfg.Name, b.tmuxSession) + // Keep window 0 alive to hold the session open + // It will be killed when the program exits + return createSession(b.tmuxSession, b.cfg.TmuxSize.Rows, b.cfg.TmuxSize.Cols) +} + +// Cleanup kills the persistent tmux session (call on program exit) +func (b *Backend) Cleanup() error { + if sessionExists(b.tmuxSession) { + log.Printf("[backend %s] cleaning up tmux session: %s", b.cfg.Name, b.tmuxSession) + return killSession(b.tmuxSession) + } + return nil } func (b *Backend) Name() string { @@ -82,45 +105,47 @@ func (b *Backend) Name() string { func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Session, error) { id := fmt.Sprintf("%d", atomic.AddUint64(&b.counter, 1)) + windowName := id // Use session ID as window name - // Generate unique session name with random suffix - randomBytes := make([]byte, 4) - rand.Read(randomBytes) - sessionName := fmt.Sprintf("%s-%d-%s", - b.cfg.Name, - time.Now().Unix(), - hex.EncodeToString(randomBytes)) + log.Printf("[session %s] creating window in tmux session %s", id, b.tmuxSession) - log.Printf("[session %s] creating tmux session %s", id, sessionName) - - // 1. Create tmux session - if err := createSession(sessionName, b.cfg.TmuxSize.Rows, b.cfg.TmuxSize.Cols); err != nil { - return nil, fmt.Errorf("failed to create tmux session: %w", err) + // 1. Ensure persistent tmux session exists + if err := b.ensureTmuxSession(); err != nil { + return nil, fmt.Errorf("failed to ensure tmux session: %w", err) } - // 2. Set environment variables + // 2. Create window in tmux session + if err := createWindow(b.tmuxSession, windowName); err != nil { + return nil, fmt.Errorf("failed to create window: %w", err) + } + + target := windowTarget(b.tmuxSession, windowName) + + // 3. Set environment variables for this window for k, v := range b.cfg.Environment { - if err := setEnvironment(sessionName, k, v); err != nil { - killSession(sessionName) + if err := setEnvironment(target, k, v); err != nil { + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to set environment: %w", err) } } - // 3. Create FIFO for output - fifoPath := fmt.Sprintf("/tmp/tmux-%s.fifo", sessionName) + // 4. Create FIFO for output + fifoPath := fmt.Sprintf("/tmp/tmux-%s-%s.fifo", b.tmuxSession, windowName) + // Remove existing FIFO if it exists (from previous crashed session) + os.Remove(fifoPath) if err := syscall.Mkfifo(fifoPath, 0600); err != nil { - killSession(sessionName) + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to create FIFO: %w", err) } - // 4. Setup pipe-pane - if err := setupPipePane(sessionName, fifoPath); err != nil { + // 5. Setup pipe-pane for this window + if err := setupPipePane(target, fifoPath); err != nil { os.Remove(fifoPath) - killSession(sessionName) + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to setup pipe-pane: %w", err) } - // 5. Open FIFO for reading (this blocks until writer connects) + // 6. Open FIFO for reading (this blocks until writer connects) // We need to do this in a goroutine to avoid blocking fifoOpenCh := make(chan *os.File, 1) fifoErrCh := make(chan error, 1) @@ -133,7 +158,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio fifoOpenCh <- f }() - // 6. Start the command in tmux + // 7. Start the command in tmux window cmdStr := "" for i, arg := range b.cfg.Command { if i > 0 { @@ -148,36 +173,36 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio } // Change to working directory first - if err := sendKeys(sessionName, "cd", fmt.Sprintf("\"%s\"", cwd), "C-m"); err != nil { + if err := sendKeys(target, "cd", fmt.Sprintf("\"%s\"", cwd), "C-m"); err != nil { os.Remove(fifoPath) - killSession(sessionName) + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to change directory: %w", err) } // Send the command - if err := sendKeys(sessionName, cmdStr, "C-m"); err != nil { + if err := sendKeys(target, cmdStr, "C-m"); err != nil { os.Remove(fifoPath) - killSession(sessionName) + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to start command: %w", err) } - // 7. Wait for FIFO to open (should happen quickly after command starts) + // 8. Wait for FIFO to open (should happen quickly after command starts) var fifo *os.File select { case fifo = <-fifoOpenCh: // Success case err := <-fifoErrCh: os.Remove(fifoPath) - killSession(sessionName) + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to open FIFO: %w", err) case <-time.After(5 * time.Second): os.Remove(fifoPath) - killSession(sessionName) + killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("timeout opening FIFO") } - // 8. Get PID - pid, err := getSessionPID(sessionName) + // 9. Get PID + pid, err := getPanePID(target) if err != nil { log.Printf("[session %s] warning: failed to get PID: %v", id, err) pid = 0 @@ -185,7 +210,8 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio sess := &Session{ id: id, - sessionName: sessionName, + tmuxSession: b.tmuxSession, + windowName: windowName, cwd: cwd, pid: pid, state: "starting", @@ -200,10 +226,10 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio startupHandler: b.cfg.StartupHandler, } - // 9. Start reader goroutine + // 10. Start reader goroutine go sess.reader() - // 10. Wait for ready + // 11. Wait for ready if err := sess.waitForReady(ctx, b.cfg.StartupTime); err != nil { sess.Close() return nil, err @@ -211,7 +237,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio sess.state = "idle" sess.output.Reset() - log.Printf("[session %s] ready (tmux=%s, pid=%d)", sess.ID(), sessionName, sess.pid) + log.Printf("[session %s] ready (tmux=%s:%s, pid=%d)", sess.ID(), b.tmuxSession, windowName, sess.pid) return sess, nil } diff --git a/main.go b/main.go index 06b32c9..c612196 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,13 @@ func main() { mgr := session.NewManager(backend) + // Cleanup tmux session on exit (if backend is tmux-based) + defer func() { + if tmuxBackend, ok := backend.(*tmux.Backend); ok { + tmuxBackend.Cleanup() + } + }() + srv, err := p9.NewServer(mgr) if err != nil { log.Fatal(err) @@ -169,6 +176,13 @@ func main() { if sess != nil { meta := sess.Metadata() if meta.Pid == pid { + // First kill the process + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.Printf("[session %s] Failed to kill PID %d: %v", id, pid, err) + } else { + log.Printf("[session %s] Killed PID %d", id, pid) + } + // Then close the tmux window sess.Close() mgr.Remove(id) break @@ -266,7 +280,7 @@ func openChatWindow(sess backend.Session) (*acme.Win, error) { return nil, err } w.Name(name) - w.Write("tag", []byte("Send Stop Attach Alias Save Kill Sessions ")) + w.Write("tag", []byte("Send Stop Attach Alias Save Sessions ")) w.Fprintf("body", "# Session %s\n# cwd: %s\n\nUSER:\n", sess.ID(), meta.Cwd) w.Ctl("clean") @@ -306,15 +320,19 @@ func handleChatWindow(w *acme.Win, sess backend.Session) { log.Printf("[session %s] Sent CTRL+C", sess.ID()) } case "Attach": - // Attach to tmux session (only works for tmux-based backends) + // Attach to tmux window (only works for tmux-based backends) meta := sess.Metadata() - if tmuxSession, ok := meta.Extra["tmux_session"]; ok { + tmuxSession, hasSession := meta.Extra["tmux_session"] + tmuxWindow, hasWindow := meta.Extra["tmux_window"] + if hasSession && hasWindow { go func() { - cmd := exec.Command(terminalCommand, "-e", "tmux", "attach", "-t", tmuxSession) + // Attach directly to the specific window + target := fmt.Sprintf("%s:%s", tmuxSession, tmuxWindow) + cmd := exec.Command(terminalCommand, "-e", "tmux", "attach", "-t", target) if err := cmd.Start(); err != nil { log.Printf("[session %s] Failed to launch terminal: %v", sess.ID(), err) } else { - log.Printf("[session %s] Launched terminal attached to tmux session: %s", sess.ID(), tmuxSession) + log.Printf("[session %s] Launched terminal attached to window: %s", sess.ID(), target) } }() } else { @@ -363,10 +381,6 @@ func handleChatWindow(w *acme.Win, sess backend.Session) { } log.Printf("[session %s] Saved to %s", sess.ID(), savePath) }() - case "Kill": - sess.Close() - log.Printf("[session %s] Session killed", sess.ID()) - return case "Sessions": openSessionsWindow(w, sess) default: