fix process manager

This commit is contained in:
Levi Neely 2026-02-10 21:18:15 +01:00
parent 9be10a14de
commit 20c6255b08
5 changed files with 152 additions and 79 deletions

BIN
acme-q

Binary file not shown.

View File

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

View File

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

View File

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

32
main.go
View File

@ -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: