From 94476a347bc25d4dc65627d6ed21dff0644c51d8 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 04:12:45 +0100 Subject: [PATCH 1/6] refactor: consolidate 6 per-session maps into sessionHandle struct Replaces sessions, ptys, cmds, doneChans, frameDetectors, and responders maps with a single handles map[string]*sessionHandle. Eliminates the risk of map synchronization drift across handlers. --- internal/daemon/server.go | 317 +++++++++++++++++++------------------- 1 file changed, 155 insertions(+), 162 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 8a8225c..f64aec9 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -50,17 +50,22 @@ type SessionInfo struct { StoppedAt string `json:"stopped_at,omitempty"` } +type sessionHandle struct { + session *Session + pty *ptyHandle + cmd *exec.Cmd + done chan struct{} + frameDetector *ansi.FrameDetector + responder *ansi.TerminalResponder +} + type Server struct { - mu sync.Mutex - sessions map[string]*Session - ptys map[string]*ptyHandle - cmds map[string]*exec.Cmd - doneChans map[string]chan struct{} - frameDetectors map[string]*ansi.FrameDetector - responders map[string]*ansi.TerminalResponder - socketDir string - storage OutputStorage - listener net.Listener + mu sync.Mutex + handles map[string]*sessionHandle + + socketDir string + storage OutputStorage + listener net.Listener stoppedTTL time.Duration cleanupStopChan chan struct{} @@ -101,12 +106,7 @@ func NewServer(opts ...ServerOption) (*Server, error) { } s := &Server{ - sessions: make(map[string]*Session), - ptys: make(map[string]*ptyHandle), - cmds: make(map[string]*exec.Cmd), - doneChans: make(map[string]chan struct{}), - frameDetectors: make(map[string]*ansi.FrameDetector), - responders: make(map[string]*ansi.TerminalResponder), + handles: make(map[string]*sessionHandle), socketDir: socketDir, storage: NewMemoryStorage(DefaultMaxOutputSize), cleanupStopChan: make(chan struct{}), @@ -142,13 +142,15 @@ func (s *Server) recoverSessions() error { s.storage.SaveMeta(name, meta) } - s.sessions[name] = &Session{ - Name: meta.Name, - PID: meta.PID, - Command: meta.Command, - State: meta.State, - CreatedAt: meta.CreatedAt, - StoppedAt: meta.StoppedAt, + s.handles[name] = &sessionHandle{ + session: &Session{ + Name: meta.Name, + PID: meta.PID, + Command: meta.Command, + State: meta.State, + CreatedAt: meta.CreatedAt, + StoppedAt: meta.StoppedAt, + }, } } @@ -208,11 +210,11 @@ func (s *Server) cleanupExpiredSessions() { defer s.mu.Unlock() now := time.Now() - for name, sess := range s.sessions { - if sess.State == StateStopped && sess.StoppedAt != nil { - if now.Sub(*sess.StoppedAt) > s.stoppedTTL { + for name, h := range s.handles { + if h.session.State == StateStopped && h.session.StoppedAt != nil { + if now.Sub(*h.session.StoppedAt) > s.stoppedTTL { s.storage.Delete(name) - delete(s.sessions, name) + delete(s.handles, name) } } } @@ -224,17 +226,17 @@ func (s *Server) Shutdown() { close(s.cleanupStopChan) - for name, sess := range s.sessions { - if sess.State == StateRunning { - if done, ok := s.doneChans[name]; ok { - close(done) + for _, h := range s.handles { + if h.session.State == StateRunning { + if h.done != nil { + close(h.done) } - if handle, ok := s.ptys[name]; ok { - handle.Close() + if h.pty != nil { + h.pty.Close() } - if cmd, ok := s.cmds[name]; ok { - cmd.Process.Kill() - cmd.Wait() + if h.cmd != nil { + h.cmd.Process.Kill() + h.cmd.Wait() } } } @@ -331,7 +333,7 @@ func (s *Server) handleCreate(req Request) Response { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.sessions[req.Name]; exists { + if _, exists := s.handles[req.Name]; exists { return Response{Success: false, Error: fmt.Sprintf("session %q already exists", req.Name)} } @@ -390,45 +392,52 @@ func (s *Server) handleCreate(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("create storage: %v", err)} } - sess := &Session{ - Name: req.Name, - PID: cmd.Process.Pid, - Command: command, - State: StateRunning, - CreatedAt: now, + h := &sessionHandle{ + session: &Session{ + Name: req.Name, + PID: cmd.Process.Pid, + Command: command, + State: StateRunning, + CreatedAt: now, + }, + pty: &ptyHandle{f: ptmx}, + cmd: cmd, + done: make(chan struct{}), } - - handle := &ptyHandle{f: ptmx} - s.sessions[req.Name] = sess - s.ptys[req.Name] = handle - s.cmds[req.Name] = cmd - s.doneChans[req.Name] = make(chan struct{}) if req.TUIMode { - s.frameDetectors[req.Name] = ansi.NewFrameDetector(ansi.DefaultTUIStrategy()) - s.responders[req.Name] = ansi.NewTerminalResponder(ptmx) + h.frameDetector = ansi.NewFrameDetector(ansi.DefaultTUIStrategy()) + h.responder = ansi.NewTerminalResponder(ptmx) } - go s.captureOutput(req.Name, handle, cmd) + s.handles[req.Name] = h + + go s.captureOutput(req.Name, h) return Response{Success: true, Data: map[string]interface{}{ - "name": sess.Name, - "pid": sess.PID, - "command": sess.Command, - "created_at": sess.CreatedAt, + "name": h.session.Name, + "pid": h.session.PID, + "command": h.session.Command, + "created_at": h.session.CreatedAt, "cols": cols, "rows": rows, }} } -func (s *Server) captureOutput(name string, handle *ptyHandle, cmd *exec.Cmd) { +func (s *Server) captureOutput(name string, h *sessionHandle) { s.mu.Lock() - done := s.doneChans[name] - detector := s.frameDetectors[name] - responder := s.responders[name] + done := h.done + p := h.pty + cmd := h.cmd + detector := h.frameDetector + responder := h.responder storage := s.storage s.mu.Unlock() - f := handle.File() + if p == nil { + return + } + + f := p.File() if detector != nil { defer func() { @@ -471,27 +480,25 @@ func (s *Server) captureOutput(name string, handle *ptyHandle, cmd *exec.Cmd) { } cmd.Wait() - handle.Close() + p.Close() s.mu.Lock() defer s.mu.Unlock() - delete(s.ptys, name) - delete(s.cmds, name) - delete(s.doneChans, name) - delete(s.frameDetectors, name) - delete(s.responders, name) + h.pty = nil + h.cmd = nil + h.done = nil + h.frameDetector = nil + h.responder = nil - if sess, ok := s.sessions[name]; ok { - sess.State = StateStopped - now := time.Now() - sess.StoppedAt = &now + h.session.State = StateStopped + now := time.Now() + h.session.StoppedAt = &now - if meta, err := s.storage.LoadMeta(name); err == nil { - meta.State = StateStopped - meta.StoppedAt = &now - s.storage.SaveMeta(name, meta) - } + if meta, err := s.storage.LoadMeta(name); err == nil { + meta.State = StateStopped + meta.StoppedAt = &now + s.storage.SaveMeta(name, meta) } } @@ -506,17 +513,17 @@ func (s *Server) handleList() Response { s.mu.Lock() defer s.mu.Unlock() - result := make([]SessionInfo, 0, len(s.sessions)) - for _, sess := range s.sessions { + result := make([]SessionInfo, 0, len(s.handles)) + for _, h := range s.handles { info := SessionInfo{ - Name: sess.Name, - PID: sess.PID, - Command: sess.Command, - CreatedAt: sess.CreatedAt.Format(time.RFC3339), - State: string(sess.State), + Name: h.session.Name, + PID: h.session.PID, + Command: h.session.Command, + CreatedAt: h.session.CreatedAt.Format(time.RFC3339), + State: string(h.session.State), } - if sess.StoppedAt != nil { - info.StoppedAt = sess.StoppedAt.Format(time.RFC3339) + if h.session.StoppedAt != nil { + info.StoppedAt = h.session.StoppedAt.Format(time.RFC3339) } result = append(result, info) } @@ -530,12 +537,12 @@ func (s *Server) handleRead(req Request) Response { } s.mu.Lock() - sess, exists := s.sessions[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - sessState := sess.State + sessState := h.session.State storage := s.storage s.mu.Unlock() @@ -633,27 +640,25 @@ func LimitLines(output string, head, tail int) string { func (s *Server) handleSnapshot(req Request) Response { s.mu.Lock() - sess, exists := s.sessions[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if sess.State != StateRunning { + if h.session.State != StateRunning { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is not running (snapshot requires a running TUI session)", req.Name)} } - _, hasFD := s.frameDetectors[req.Name] - if !hasFD { + if h.frameDetector == nil { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is not in TUI mode (snapshot requires --tui)", req.Name)} } - handle, ok := s.ptys[req.Name] - if !ok { + if h.pty == nil { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q PTY not available", req.Name)} } - ptmx := handle.File() - cmd := s.cmds[req.Name] + ptmx := h.pty.File() + cmd := h.cmd storage := s.storage s.mu.Unlock() @@ -662,8 +667,6 @@ func (s *Server) handleSnapshot(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("load meta: %v", err)} } - // Cold start: if storage is empty, wait up to 2s for the app to produce initial content. - // This handles the case where snapshot is called before the app has rendered anything. if sz, _ := storage.Size(req.Name); sz == 0 { coldDeadline := time.Now().Add(2 * time.Second) for time.Now().Before(coldDeadline) { @@ -674,20 +677,17 @@ func (s *Server) handleSnapshot(req Request) Response { } } - // Clear storage and reset frame detector before resize cycle. - // This ensures the settle loop starts from size=0 and waits for fresh data, - // preventing races where captureOutput's Clear+Append can be seen as empty. storage.Clear(req.Name) s.mu.Lock() - if fd, ok := s.frameDetectors[req.Name]; ok { - fd.Reset() - fd.SetSnapshotMode(true) + if h.frameDetector != nil { + h.frameDetector.Reset() + h.frameDetector.SetSnapshotMode(true) } s.mu.Unlock() defer func() { s.mu.Lock() - if fd, ok := s.frameDetectors[req.Name]; ok { - fd.SetSnapshotMode(false) + if h.frameDetector != nil { + h.frameDetector.SetSnapshotMode(false) } s.mu.Unlock() }() @@ -753,7 +753,6 @@ func (s *Server) handleSnapshot(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("read output: %v", err)} } - // Retry once if empty and time remains: some apps need a second SIGWINCH nudge if len(output) == 0 && time.Now().Before(deadline) { if cmd != nil && cmd.Process != nil { cmd.Process.Signal(syscall.SIGWINCH) @@ -800,25 +799,25 @@ func (s *Server) handleSnapshot(req Request) Response { return Response{Success: true, Data: map[string]interface{}{ "output": result, "position": totalLen, - "state": sess.State, + "state": h.session.State, }} } func (s *Server) handleSend(req Request) Response { s.mu.Lock() - sess, ok := s.sessions[req.Name] + h, ok := s.handles[req.Name] if !ok { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if sess.State != StateRunning { + if h.session.State != StateRunning { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is stopped", req.Name)} } - handle, ok := s.ptys[req.Name] + p := h.pty s.mu.Unlock() - if !ok { + if p == nil { return Response{Success: false, Error: fmt.Sprintf("session %q not running", req.Name)} } @@ -827,7 +826,7 @@ func (s *Server) handleSend(req Request) Response { data += "\n" } - if _, err := handle.File().WriteString(data); err != nil { + if _, err := p.File().WriteString(data); err != nil { return Response{Success: false, Error: err.Error()} } @@ -838,41 +837,42 @@ func (s *Server) handleStop(req Request) Response { s.mu.Lock() defer s.mu.Unlock() - sess, exists := s.sessions[req.Name] + h, exists := s.handles[req.Name] if !exists { return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if sess.State == StateStopped { + if h.session.State == StateStopped { return Response{Success: true, Data: "already stopped"} } - if done, ok := s.doneChans[req.Name]; ok { - close(done) - delete(s.doneChans, req.Name) + if h.done != nil { + close(h.done) + h.done = nil } - if handle, ok := s.ptys[req.Name]; ok { - handle.Close() - delete(s.ptys, req.Name) + if h.pty != nil { + h.pty.Close() + h.pty = nil } - if cmd, ok := s.cmds[req.Name]; ok { + if h.cmd != nil { + cmd := h.cmd proc := cmd.Process + h.cmd = nil proc.Signal(syscall.SIGTERM) go func() { time.Sleep(KillGracePeriod) proc.Signal(syscall.SIGKILL) cmd.Wait() }() - delete(s.cmds, req.Name) } - delete(s.frameDetectors, req.Name) - delete(s.responders, req.Name) + h.frameDetector = nil + h.responder = nil - sess.State = StateStopped + h.session.State = StateStopped now := time.Now() - sess.StoppedAt = &now + h.session.StoppedAt = &now if meta, err := s.storage.LoadMeta(req.Name); err == nil { meta.State = StateStopped @@ -886,32 +886,27 @@ func (s *Server) handleStop(req Request) Response { func (s *Server) handleKill(req Request) Response { s.mu.Lock() - sess, exists := s.sessions[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } var proc *os.Process - if sess.State == StateRunning { - if done, ok := s.doneChans[req.Name]; ok { - close(done) - delete(s.doneChans, req.Name) + if h.session.State == StateRunning { + if h.done != nil { + close(h.done) } - if handle, ok := s.ptys[req.Name]; ok { - handle.Close() - delete(s.ptys, req.Name) + if h.pty != nil { + h.pty.Close() } - if cmd, ok := s.cmds[req.Name]; ok { - proc = cmd.Process - delete(s.cmds, req.Name) + if h.cmd != nil { + proc = h.cmd.Process } } s.storage.Delete(req.Name) - delete(s.sessions, req.Name) - delete(s.frameDetectors, req.Name) - delete(s.responders, req.Name) + delete(s.handles, req.Name) s.mu.Unlock() if proc != nil { @@ -928,7 +923,7 @@ func (s *Server) handleKill(req Request) Response { func (s *Server) handleSize(req Request) Response { s.mu.Lock() - _, exists := s.sessions[req.Name] + _, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} @@ -949,7 +944,7 @@ func (s *Server) handleSearch(req Request) Response { } s.mu.Lock() - _, exists := s.sessions[req.Name] + _, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} @@ -1012,7 +1007,7 @@ func (s *Server) handleSearch(req Request) Response { func (s *Server) handleInfo(req Request) Response { s.mu.Lock() - sess, exists := s.sessions[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} @@ -1031,11 +1026,11 @@ func (s *Server) handleInfo(req Request) Response { } result := map[string]interface{}{ - "name": sess.Name, - "state": string(sess.State), - "pid": sess.PID, - "command": sess.Command, - "created_at": sess.CreatedAt.Format(time.RFC3339), + "name": h.session.Name, + "state": string(h.session.State), + "pid": h.session.PID, + "command": h.session.Command, + "created_at": h.session.CreatedAt.Format(time.RFC3339), "bytes_buffered": size, "read_position": meta.ReadPos, "cols": meta.Cols, @@ -1043,12 +1038,12 @@ func (s *Server) handleInfo(req Request) Response { "tui_mode": meta.TUIMode, } - if sess.StoppedAt != nil { - result["stopped_at"] = sess.StoppedAt.Format(time.RFC3339) + if h.session.StoppedAt != nil { + result["stopped_at"] = h.session.StoppedAt.Format(time.RFC3339) } - if sess.State == StateRunning { - result["uptime_seconds"] = time.Since(sess.CreatedAt).Seconds() + if h.session.State == StateRunning { + result["uptime_seconds"] = time.Since(h.session.CreatedAt).Seconds() } return Response{Success: true, Data: result} @@ -1056,7 +1051,7 @@ func (s *Server) handleInfo(req Request) Response { func (s *Server) handleClear(req Request) Response { s.mu.Lock() - _, exists := s.sessions[req.Name] + _, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} @@ -1077,19 +1072,19 @@ func (s *Server) handleResize(req Request) Response { } s.mu.Lock() - sess, exists := s.sessions[req.Name] + h, exists := s.handles[req.Name] if !exists { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if sess.State != StateRunning { + if h.session.State != StateRunning { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is stopped", req.Name)} } - handle, ok := s.ptys[req.Name] - if !ok { + p := h.pty + if p == nil { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not running", req.Name)} } @@ -1110,15 +1105,13 @@ func (s *Server) handleResize(req Request) Response { rows = meta.Rows } - if err := pty.Setsize(handle.File(), &pty.Winsize{Cols: clampUint16(cols), Rows: clampUint16(rows)}); err != nil { + if err := pty.Setsize(p.File(), &pty.Winsize{Cols: clampUint16(cols), Rows: clampUint16(rows)}); err != nil { return Response{Success: false, Error: fmt.Sprintf("resize: %v", err)} } - // Send SIGWINCH explicitly to ensure the process receives it - // (pty.Setsize should trigger this via kernel, but explicit signal is more reliable) s.mu.Lock() - if cmd, ok := s.cmds[req.Name]; ok && cmd.Process != nil { - cmd.Process.Signal(syscall.SIGWINCH) + if h.cmd != nil && h.cmd.Process != nil { + h.cmd.Process.Signal(syscall.SIGWINCH) } s.mu.Unlock() From b6a58ea435ae5869e7a1232d98034e2fbc27a668 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 04:14:21 +0100 Subject: [PATCH 2/6] refactor: remove Session struct, promote fields into sessionHandle Eliminates the intermediate Session struct that duplicated SessionMeta fields. All session state now lives directly in sessionHandle, removing one layer of indirection and a source of state drift. --- internal/daemon/server.go | 117 ++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index f64aec9..da40956 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -32,15 +32,6 @@ func (p *ptyHandle) File() *os.File { return p.f } -type Session struct { - Name string `json:"name"` - PID int `json:"pid"` - Command string `json:"command"` - State SessionState `json:"state"` - CreatedAt time.Time `json:"created_at"` - StoppedAt *time.Time `json:"stopped_at,omitempty"` -} - type SessionInfo struct { Name string `json:"name"` PID int `json:"pid"` @@ -51,7 +42,13 @@ type SessionInfo struct { } type sessionHandle struct { - session *Session + name string + pid int + command string + state SessionState + createdAt time.Time + stoppedAt *time.Time + pty *ptyHandle cmd *exec.Cmd done chan struct{} @@ -143,14 +140,12 @@ func (s *Server) recoverSessions() error { } s.handles[name] = &sessionHandle{ - session: &Session{ - Name: meta.Name, - PID: meta.PID, - Command: meta.Command, - State: meta.State, - CreatedAt: meta.CreatedAt, - StoppedAt: meta.StoppedAt, - }, + name: meta.Name, + pid: meta.PID, + command: meta.Command, + state: meta.State, + createdAt: meta.CreatedAt, + stoppedAt: meta.StoppedAt, } } @@ -211,8 +206,8 @@ func (s *Server) cleanupExpiredSessions() { now := time.Now() for name, h := range s.handles { - if h.session.State == StateStopped && h.session.StoppedAt != nil { - if now.Sub(*h.session.StoppedAt) > s.stoppedTTL { + if h.state == StateStopped && h.stoppedAt != nil { + if now.Sub(*h.stoppedAt) > s.stoppedTTL { s.storage.Delete(name) delete(s.handles, name) } @@ -227,7 +222,7 @@ func (s *Server) Shutdown() { close(s.cleanupStopChan) for _, h := range s.handles { - if h.session.State == StateRunning { + if h.state == StateRunning { if h.done != nil { close(h.done) } @@ -393,16 +388,14 @@ func (s *Server) handleCreate(req Request) Response { } h := &sessionHandle{ - session: &Session{ - Name: req.Name, - PID: cmd.Process.Pid, - Command: command, - State: StateRunning, - CreatedAt: now, - }, - pty: &ptyHandle{f: ptmx}, - cmd: cmd, - done: make(chan struct{}), + name: req.Name, + pid: cmd.Process.Pid, + command: command, + state: StateRunning, + createdAt: now, + pty: &ptyHandle{f: ptmx}, + cmd: cmd, + done: make(chan struct{}), } if req.TUIMode { h.frameDetector = ansi.NewFrameDetector(ansi.DefaultTUIStrategy()) @@ -414,10 +407,10 @@ func (s *Server) handleCreate(req Request) Response { go s.captureOutput(req.Name, h) return Response{Success: true, Data: map[string]interface{}{ - "name": h.session.Name, - "pid": h.session.PID, - "command": h.session.Command, - "created_at": h.session.CreatedAt, + "name": h.name, + "pid": h.pid, + "command": h.command, + "created_at": h.createdAt, "cols": cols, "rows": rows, }} @@ -491,9 +484,9 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { h.frameDetector = nil h.responder = nil - h.session.State = StateStopped + h.state = StateStopped now := time.Now() - h.session.StoppedAt = &now + h.stoppedAt = &now if meta, err := s.storage.LoadMeta(name); err == nil { meta.State = StateStopped @@ -516,14 +509,14 @@ func (s *Server) handleList() Response { result := make([]SessionInfo, 0, len(s.handles)) for _, h := range s.handles { info := SessionInfo{ - Name: h.session.Name, - PID: h.session.PID, - Command: h.session.Command, - CreatedAt: h.session.CreatedAt.Format(time.RFC3339), - State: string(h.session.State), + Name: h.name, + PID: h.pid, + Command: h.command, + CreatedAt: h.createdAt.Format(time.RFC3339), + State: string(h.state), } - if h.session.StoppedAt != nil { - info.StoppedAt = h.session.StoppedAt.Format(time.RFC3339) + if h.stoppedAt != nil { + info.StoppedAt = h.stoppedAt.Format(time.RFC3339) } result = append(result, info) } @@ -542,7 +535,7 @@ func (s *Server) handleRead(req Request) Response { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - sessState := h.session.State + sessState := h.state storage := s.storage s.mu.Unlock() @@ -645,7 +638,7 @@ func (s *Server) handleSnapshot(req Request) Response { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if h.session.State != StateRunning { + if h.state != StateRunning { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is not running (snapshot requires a running TUI session)", req.Name)} } @@ -799,7 +792,7 @@ func (s *Server) handleSnapshot(req Request) Response { return Response{Success: true, Data: map[string]interface{}{ "output": result, "position": totalLen, - "state": h.session.State, + "state": h.state, }} } @@ -810,7 +803,7 @@ func (s *Server) handleSend(req Request) Response { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if h.session.State != StateRunning { + if h.state != StateRunning { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is stopped", req.Name)} } @@ -842,7 +835,7 @@ func (s *Server) handleStop(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if h.session.State == StateStopped { + if h.state == StateStopped { return Response{Success: true, Data: "already stopped"} } @@ -870,9 +863,9 @@ func (s *Server) handleStop(req Request) Response { h.frameDetector = nil h.responder = nil - h.session.State = StateStopped + h.state = StateStopped now := time.Now() - h.session.StoppedAt = &now + h.stoppedAt = &now if meta, err := s.storage.LoadMeta(req.Name); err == nil { meta.State = StateStopped @@ -893,7 +886,7 @@ func (s *Server) handleKill(req Request) Response { } var proc *os.Process - if h.session.State == StateRunning { + if h.state == StateRunning { if h.done != nil { close(h.done) } @@ -1026,11 +1019,11 @@ func (s *Server) handleInfo(req Request) Response { } result := map[string]interface{}{ - "name": h.session.Name, - "state": string(h.session.State), - "pid": h.session.PID, - "command": h.session.Command, - "created_at": h.session.CreatedAt.Format(time.RFC3339), + "name": h.name, + "state": string(h.state), + "pid": h.pid, + "command": h.command, + "created_at": h.createdAt.Format(time.RFC3339), "bytes_buffered": size, "read_position": meta.ReadPos, "cols": meta.Cols, @@ -1038,12 +1031,12 @@ func (s *Server) handleInfo(req Request) Response { "tui_mode": meta.TUIMode, } - if h.session.StoppedAt != nil { - result["stopped_at"] = h.session.StoppedAt.Format(time.RFC3339) + if h.stoppedAt != nil { + result["stopped_at"] = h.stoppedAt.Format(time.RFC3339) } - if h.session.State == StateRunning { - result["uptime_seconds"] = time.Since(h.session.CreatedAt).Seconds() + if h.state == StateRunning { + result["uptime_seconds"] = time.Since(h.createdAt).Seconds() } return Response{Success: true, Data: result} @@ -1078,7 +1071,7 @@ func (s *Server) handleResize(req Request) Response { return Response{Success: false, Error: fmt.Sprintf("session %q not found", req.Name)} } - if h.session.State != StateRunning { + if h.state != StateRunning { s.mu.Unlock() return Response{Success: false, Error: fmt.Sprintf("session %q is stopped", req.Name)} } From a90b63cd9d841d26798484e4997d51349b7e5d89 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 04:15:05 +0100 Subject: [PATCH 3/6] refactor: extract waitForSettle helper from handleSnapshot Deduplicates the two near-identical settle polling loops in handleSnapshot into a shared helper function. --- internal/daemon/server.go | 65 +++++++++++++++------------------------ 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index da40956..df66253 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -719,26 +719,9 @@ func (s *Server) handleSnapshot(req Request) Response { } deadline := time.Now().Add(timeout) - lastChangeTime := time.Now() - lastSize := int64(-1) - - for time.Now().Before(deadline) { - time.Sleep(SnapshotPollInterval) - - size, err := storage.Size(req.Name) - if err != nil { - return Response{Success: false, Error: fmt.Sprintf("poll size: %v", err)} - } - - if size != lastSize { - lastSize = size - lastChangeTime = time.Now() - continue - } - if size > 0 && time.Since(lastChangeTime) >= settleDuration { - break - } + if _, err := waitForSettle(storage, req.Name, settleDuration, deadline); err != nil { + return Response{Success: false, Error: fmt.Sprintf("poll size: %v", err)} } output, err := storage.ReadAll(req.Name) @@ -751,28 +734,7 @@ func (s *Server) handleSnapshot(req Request) Response { cmd.Process.Signal(syscall.SIGWINCH) } - retrySettle := settleDuration * 2 - lastChangeTime = time.Now() - lastSize = int64(-1) - - for time.Now().Before(deadline) { - time.Sleep(SnapshotPollInterval) - - size, err := storage.Size(req.Name) - if err != nil { - break - } - - if size != lastSize { - lastSize = size - lastChangeTime = time.Now() - continue - } - - if size > 0 && time.Since(lastChangeTime) >= retrySettle { - break - } - } + waitForSettle(storage, req.Name, settleDuration*2, deadline) output, err = storage.ReadAll(req.Name) if err != nil { @@ -1120,6 +1082,27 @@ func (s *Server) handleResize(req Request) Response { }} } +func waitForSettle(storage OutputStorage, name string, settle time.Duration, deadline time.Time) (int64, error) { + lastChangeTime := time.Now() + lastSize := int64(-1) + for time.Now().Before(deadline) { + time.Sleep(SnapshotPollInterval) + size, err := storage.Size(name) + if err != nil { + return 0, err + } + if size != lastSize { + lastSize = size + lastChangeTime = time.Now() + continue + } + if size > 0 && time.Since(lastChangeTime) >= settle { + return size, nil + } + } + return lastSize, nil +} + func clampUint16(v int) uint16 { if v < 0 { return 0 From 0db5d8219fcd0160866bb781a815b89073b75023 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 04:23:02 +0100 Subject: [PATCH 4/6] refactor: consolidate send+wait+read into Client.Exec Extracts the duplicated "send input, wait for output, return new content" pattern from cmd/exec.go and mcp/tools.go into a single Client.Exec method. Callers now use daemon.ExecOptions instead of manually orchestrating Read/Send/ForOutput. Also refactors MCP ToolRegistry to use registration-based pattern: adding a tool is now a single register() call instead of coordinated edits to List() schema, Call() switch, and handler method. --- cmd/exec.go | 33 +- internal/daemon/client.go | 54 ++++ internal/mcp/tools.go | 615 +++++++++++++++++--------------------- 3 files changed, 345 insertions(+), 357 deletions(-) diff --git a/cmd/exec.go b/cmd/exec.go index 504157f..0c74f20 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -7,7 +7,6 @@ import ( "github.com/schovi/shelli/internal/ansi" "github.com/schovi/shelli/internal/daemon" - "github.com/schovi/shelli/internal/wait" "github.com/spf13/cobra" ) @@ -59,49 +58,35 @@ func runExec(cmd *cobra.Command, args []string) error { return fmt.Errorf("daemon: %w", err) } - _, startPos, err := client.Read(name, "all", 0, 0) - if err != nil { - return err - } - - if err := client.Send(name, input, true); err != nil { - return err - } - var pattern string var settleMs int if hasWait { pattern = execWaitFlag - settleMs = 0 } else { - pattern = "" settleMs = execSettleFlag } - output, pos, err := wait.ForOutput( - func() (string, int, error) { return client.Read(name, "all", 0, 0) }, - wait.Config{ - Pattern: pattern, - SettleMs: settleMs, - TimeoutSec: execTimeoutFlag, - StartPosition: startPos, - SizeFunc: func() (int, error) { return client.Size(name) }, - }, - ) + result, err := client.Exec(name, daemon.ExecOptions{ + Input: input, + SettleMs: settleMs, + WaitPattern: pattern, + TimeoutSec: execTimeoutFlag, + }) if err != nil { return err } + output := result.Output if execStripAnsiFlag { output = ansi.Strip(output) } if execJsonFlag { out := map[string]interface{}{ - "input": input, + "input": result.Input, "output": output, - "position": pos, + "position": result.Position, } data, err := json.MarshalIndent(out, "", " ") if err != nil { diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 09ffc0a..d56e993 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -7,6 +7,8 @@ import ( "os" "os/exec" "time" + + "github.com/schovi/shelli/internal/wait" ) type Client struct{} @@ -372,6 +374,56 @@ func (c *Client) Size(name string) (int, error) { return int(sizeFloat), nil } +type ExecOptions struct { + Input string + SettleMs int + WaitPattern string + TimeoutSec int +} + +type ExecResult struct { + Input string + Output string + Position int +} + +func (c *Client) Exec(name string, opts ExecOptions) (*ExecResult, error) { + _, startPos, err := c.Read(name, "all", 0, 0) + if err != nil { + return nil, err + } + + if err := c.Send(name, opts.Input, true); err != nil { + return nil, err + } + + settleMs := opts.SettleMs + if opts.WaitPattern == "" && settleMs == 0 { + settleMs = 500 + } + + timeoutSec := opts.TimeoutSec + if timeoutSec == 0 { + timeoutSec = 10 + } + + output, pos, err := wait.ForOutput( + func() (string, int, error) { return c.Read(name, "all", 0, 0) }, + wait.Config{ + Pattern: opts.WaitPattern, + SettleMs: settleMs, + TimeoutSec: timeoutSec, + StartPosition: startPos, + SizeFunc: func() (int, error) { return c.Size(name) }, + }, + ) + if err != nil { + return nil, err + } + + return &ExecResult{Input: opts.Input, Output: output, Position: pos}, nil +} + func (c *Client) send(req Request) (*Response, error) { conn, err := net.Dial("unix", SocketPath()) if err != nil { @@ -381,6 +433,8 @@ func (c *Client) send(req Request) (*Response, error) { conn.SetDeadline(time.Now().Add(ClientDeadline)) + req.Version = ProtocolVersion + if err := json.NewEncoder(conn).Encode(req); err != nil { return nil, err } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 99925d4..c7e5603 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -11,327 +11,301 @@ import ( "github.com/schovi/shelli/internal/wait" ) +type toolEntry struct { + def ToolDef + handler func(json.RawMessage) (*CallToolResult, error) +} + type ToolRegistry struct { - client *daemon.Client + client *daemon.Client + entries []toolEntry } -func NewToolRegistry() *ToolRegistry { - return &ToolRegistry{ - client: daemon.NewClient(), - } +func (r *ToolRegistry) register(name, description string, schema map[string]interface{}, handler func(json.RawMessage) (*CallToolResult, error)) { + r.entries = append(r.entries, toolEntry{ + def: ToolDef{Name: name, Description: description, InputSchema: schema}, + handler: handler, + }) } -func (r *ToolRegistry) List() []ToolDef { - return []ToolDef{ - { - Name: "create", - Description: "Create a new interactive shell session. Use for REPLs, SSH, database CLIs, or any stateful workflow.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Unique session name (alphanumeric start, may contain letters, numbers, dots, dashes, underscores; max 64 chars)", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", - }, - "command": map[string]interface{}{ - "type": "string", - "description": "Command to run (e.g., 'python3', 'ssh user@host', 'psql -d mydb'). Defaults to user's shell.", - }, - "env": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - "description": "Environment variables to set (KEY=VALUE format)", - }, - "cwd": map[string]interface{}{ - "type": "string", - "description": "Working directory for the session", - }, - "cols": map[string]interface{}{ - "type": "integer", - "description": "Terminal columns (default: 80)", - }, - "rows": map[string]interface{}{ - "type": "integer", - "description": "Terminal rows (default: 24)", - }, - "tui": map[string]interface{}{ - "type": "boolean", - "description": "Enable TUI mode for apps like vim, htop. Auto-truncates buffer on frame boundaries to reduce storage.", - }, - }, - "required": []string{"name"}, - }, +var createSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Unique session name (alphanumeric start, may contain letters, numbers, dots, dashes, underscores; max 64 chars)", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", }, - { - Name: "exec", - Description: "Send a command to a session and wait for output. Adds newline automatically, waits for output to settle or pattern match. Input is sent as literal text (no escape interpretation). For TUI apps or precise control, use 'send' with separate arguments: send session \"hello\" \"\\r\"", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - "input": map[string]interface{}{ - "type": "string", - "description": "Command to send (newline added automatically, sent as literal text)", - }, - "settle_ms": map[string]interface{}{ - "type": "integer", - "description": "Wait for N ms of silence (default: 500). Mutually exclusive with wait_pattern.", - }, - "wait_pattern": map[string]interface{}{ - "type": "string", - "description": "Wait for regex pattern match (e.g., '>>>' for Python prompt). Mutually exclusive with settle_ms.", - }, - "timeout_sec": map[string]interface{}{ - "type": "integer", - "description": "Max wait time in seconds (default: 10)", - }, - "strip_ansi": map[string]interface{}{ - "type": "boolean", - "description": "Remove ANSI escape codes from output (default: false)", - }, - }, - "required": []string{"name", "input"}, - }, + "command": map[string]interface{}{ + "type": "string", + "description": "Command to run (e.g., 'python3', 'ssh user@host', 'psql -d mydb'). Defaults to user's shell.", }, - { - Name: "send", - Description: "Send raw input to a session without waiting. Low-level command for precise control. Escape sequences (\\n, \\r, \\x03, etc.) are always interpreted. No newline added automatically.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - "input": map[string]interface{}{ - "type": "string", - "description": "Input to send (escape sequences interpreted). Mutually exclusive with inputs and input_base64.", - }, - "inputs": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - "description": "PREFERRED for sequences. Send multiple inputs in one call - each as separate PTY write. Use when sending message + Enter (e.g., [\"text\", \"\\r\"]), commands + confirmations, or any multi-step input. More efficient than multiple send calls. Mutually exclusive with input and input_base64.", - }, - "input_base64": map[string]interface{}{ - "type": "string", - "description": "Input as base64 (for binary data). Sent as single write, no escape interpretation. Mutually exclusive with input and inputs.", - }, - }, - "required": []string{"name"}, - }, + "env": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Environment variables to set (KEY=VALUE format)", }, - { - Name: "read", - Description: "Read output from a session. Can read new output, all output, or wait for specific patterns.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - "all": map[string]interface{}{ - "type": "boolean", - "description": "If true, read all output from session start. If false (default), read only new output since last read. Mutually exclusive with head/tail.", - }, - "head": map[string]interface{}{ - "type": "integer", - "description": "Return first N lines of buffer. Mutually exclusive with all/tail.", - }, - "tail": map[string]interface{}{ - "type": "integer", - "description": "Return last N lines of buffer. Mutually exclusive with all/head.", - }, - "wait_pattern": map[string]interface{}{ - "type": "string", - "description": "Wait for regex pattern match before returning", - }, - "settle_ms": map[string]interface{}{ - "type": "integer", - "description": "Wait for N ms of silence before returning", - }, - "timeout_sec": map[string]interface{}{ - "type": "integer", - "description": "Max wait time in seconds (default: 10, only used with wait_pattern or settle_ms)", - }, - "strip_ansi": map[string]interface{}{ - "type": "boolean", - "description": "Remove ANSI escape codes from output", - }, - "snapshot": map[string]interface{}{ - "type": "boolean", - "description": "Force TUI redraw via resize and read clean frame. Requires TUI mode (--tui on create). Incompatible with all, wait_pattern.", - }, - "cursor": map[string]interface{}{ - "type": "string", - "description": "Named cursor for per-consumer read tracking. Each cursor maintains its own position.", - }, - }, - "required": []string{"name"}, - }, + "cwd": map[string]interface{}{ + "type": "string", + "description": "Working directory for the session", }, - { - Name: "list", - Description: "List all active sessions with their status", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, + "cols": map[string]interface{}{ + "type": "integer", + "description": "Terminal columns (default: 80)", }, - { - Name: "stop", - Description: "Stop a running session but keep output accessible. Use this to preserve session output after process ends.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name to stop", - }, - }, - "required": []string{"name"}, - }, + "rows": map[string]interface{}{ + "type": "integer", + "description": "Terminal rows (default: 24)", }, - { - Name: "kill", - Description: "Kill/terminate a session and delete all output. Use 'stop' instead if you want to preserve output.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name to kill", - }, - }, - "required": []string{"name"}, - }, + "tui": map[string]interface{}{ + "type": "boolean", + "description": "Enable TUI mode for apps like vim, htop. Auto-truncates buffer on frame boundaries to reduce storage.", }, - { - Name: "info", - Description: "Get detailed information about a session including state, PID, command, buffer size, terminal dimensions, and uptime", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - }, - "required": []string{"name"}, - }, + }, + "required": []string{"name"}, +} + +var execSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", }, - { - Name: "clear", - Description: "Clear the output buffer of a session and reset the read position. The session continues running.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - }, - "required": []string{"name"}, - }, + "input": map[string]interface{}{ + "type": "string", + "description": "Command to send (newline added automatically, sent as literal text)", }, - { - Name: "resize", - Description: "Resize terminal dimensions of a running session. At least one of cols or rows must be specified.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - "cols": map[string]interface{}{ - "type": "integer", - "description": "Terminal columns (optional, keeps current if not specified)", - }, - "rows": map[string]interface{}{ - "type": "integer", - "description": "Terminal rows (optional, keeps current if not specified)", - }, - }, - "required": []string{"name"}, - }, + "settle_ms": map[string]interface{}{ + "type": "integer", + "description": "Wait for N ms of silence (default: 500). Mutually exclusive with wait_pattern.", }, - { - Name: "search", - Description: "Search session output buffer for regex patterns with context lines", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "Session name", - }, - "pattern": map[string]interface{}{ - "type": "string", - "description": "Regex pattern to search for", - }, - "before": map[string]interface{}{ - "type": "integer", - "description": "Lines of context before each match (default: 0)", - }, - "after": map[string]interface{}{ - "type": "integer", - "description": "Lines of context after each match (default: 0)", - }, - "around": map[string]interface{}{ - "type": "integer", - "description": "Lines of context before AND after (shorthand for before+after). Mutually exclusive with before/after.", - }, - "ignore_case": map[string]interface{}{ - "type": "boolean", - "description": "Case-insensitive search (default: false)", - }, - "strip_ansi": map[string]interface{}{ - "type": "boolean", - "description": "Strip ANSI escape codes before searching (default: false)", - }, - }, - "required": []string{"name", "pattern"}, - }, + "wait_pattern": map[string]interface{}{ + "type": "string", + "description": "Wait for regex pattern match (e.g., '>>>' for Python prompt). Mutually exclusive with settle_ms.", + }, + "timeout_sec": map[string]interface{}{ + "type": "integer", + "description": "Max wait time in seconds (default: 10)", + }, + "strip_ansi": map[string]interface{}{ + "type": "boolean", + "description": "Remove ANSI escape codes from output (default: false)", + }, + }, + "required": []string{"name", "input"}, +} + +var sendSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", + }, + "input": map[string]interface{}{ + "type": "string", + "description": "Input to send (escape sequences interpreted). Mutually exclusive with inputs and input_base64.", + }, + "inputs": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "PREFERRED for sequences. Send multiple inputs in one call - each as separate PTY write. Use when sending message + Enter (e.g., [\"text\", \"\\r\"]), commands + confirmations, or any multi-step input. More efficient than multiple send calls. Mutually exclusive with input and input_base64.", + }, + "input_base64": map[string]interface{}{ + "type": "string", + "description": "Input as base64 (for binary data). Sent as single write, no escape interpretation. Mutually exclusive with input and inputs.", + }, + }, + "required": []string{"name"}, +} + +var readSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", + }, + "all": map[string]interface{}{ + "type": "boolean", + "description": "If true, read all output from session start. If false (default), read only new output since last read. Mutually exclusive with head/tail.", + }, + "head": map[string]interface{}{ + "type": "integer", + "description": "Return first N lines of buffer. Mutually exclusive with all/tail.", + }, + "tail": map[string]interface{}{ + "type": "integer", + "description": "Return last N lines of buffer. Mutually exclusive with all/head.", + }, + "wait_pattern": map[string]interface{}{ + "type": "string", + "description": "Wait for regex pattern match before returning", + }, + "settle_ms": map[string]interface{}{ + "type": "integer", + "description": "Wait for N ms of silence before returning", + }, + "timeout_sec": map[string]interface{}{ + "type": "integer", + "description": "Max wait time in seconds (default: 10, only used with wait_pattern or settle_ms)", + }, + "strip_ansi": map[string]interface{}{ + "type": "boolean", + "description": "Remove ANSI escape codes from output", + }, + "snapshot": map[string]interface{}{ + "type": "boolean", + "description": "Force TUI redraw via resize and read clean frame. Requires TUI mode (--tui on create). Incompatible with all, wait_pattern.", + }, + "cursor": map[string]interface{}{ + "type": "string", + "description": "Named cursor for per-consumer read tracking. Each cursor maintains its own position.", + }, + }, + "required": []string{"name"}, +} + +var listSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, +} + +var stopSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name to stop", + }, + }, + "required": []string{"name"}, +} + +var killSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name to kill", + }, + }, + "required": []string{"name"}, +} + +var infoSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", + }, + }, + "required": []string{"name"}, +} + +var clearSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", + }, + }, + "required": []string{"name"}, +} + +var resizeSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", + }, + "cols": map[string]interface{}{ + "type": "integer", + "description": "Terminal columns (optional, keeps current if not specified)", + }, + "rows": map[string]interface{}{ + "type": "integer", + "description": "Terminal rows (optional, keeps current if not specified)", + }, + }, + "required": []string{"name"}, +} + +var searchSchema = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Session name", + }, + "pattern": map[string]interface{}{ + "type": "string", + "description": "Regex pattern to search for", + }, + "before": map[string]interface{}{ + "type": "integer", + "description": "Lines of context before each match (default: 0)", + }, + "after": map[string]interface{}{ + "type": "integer", + "description": "Lines of context after each match (default: 0)", + }, + "around": map[string]interface{}{ + "type": "integer", + "description": "Lines of context before AND after (shorthand for before+after). Mutually exclusive with before/after.", + }, + "ignore_case": map[string]interface{}{ + "type": "boolean", + "description": "Case-insensitive search (default: false)", }, + "strip_ansi": map[string]interface{}{ + "type": "boolean", + "description": "Strip ANSI escape codes before searching (default: false)", + }, + }, + "required": []string{"name", "pattern"}, +} + +func NewToolRegistry() *ToolRegistry { + r := &ToolRegistry{client: daemon.NewClient()} + r.register("create", "Create a new interactive shell session. Use for REPLs, SSH, database CLIs, or any stateful workflow.", createSchema, r.callCreate) + r.register("exec", "Send a command to a session and wait for output. Adds newline automatically, waits for output to settle or pattern match. Input is sent as literal text (no escape interpretation). For TUI apps or precise control, use 'send' with separate arguments: send session \"hello\" \"\\r\"", execSchema, r.callExec) + r.register("send", "Send raw input to a session without waiting. Low-level command for precise control. Escape sequences (\\n, \\r, \\x03, etc.) are always interpreted. No newline added automatically.", sendSchema, r.callSend) + r.register("read", "Read output from a session. Can read new output, all output, or wait for specific patterns.", readSchema, r.callRead) + r.register("list", "List all active sessions with their status", listSchema, func(_ json.RawMessage) (*CallToolResult, error) { + return r.callList() + }) + r.register("stop", "Stop a running session but keep output accessible. Use this to preserve session output after process ends.", stopSchema, r.callStop) + r.register("kill", "Kill/terminate a session and delete all output. Use 'stop' instead if you want to preserve output.", killSchema, r.callKill) + r.register("info", "Get detailed information about a session including state, PID, command, buffer size, terminal dimensions, and uptime", infoSchema, r.callInfo) + r.register("clear", "Clear the output buffer of a session and reset the read position. The session continues running.", clearSchema, r.callClear) + r.register("resize", "Resize terminal dimensions of a running session. At least one of cols or rows must be specified.", resizeSchema, r.callResize) + r.register("search", "Search session output buffer for regex patterns with context lines", searchSchema, r.callSearch) + return r +} + +func (r *ToolRegistry) List() []ToolDef { + defs := make([]ToolDef, len(r.entries)) + for i, e := range r.entries { + defs[i] = e.def } + return defs } func (r *ToolRegistry) Call(name string, args json.RawMessage) (*CallToolResult, error) { if err := r.client.EnsureDaemon(); err != nil { return nil, fmt.Errorf("daemon: %w", err) } - - switch name { - case "create": - return r.callCreate(args) - case "exec": - return r.callExec(args) - case "send": - return r.callSend(args) - case "read": - return r.callRead(args) - case "list": - return r.callList() - case "stop": - return r.callStop(args) - case "kill": - return r.callKill(args) - case "info": - return r.callInfo(args) - case "clear": - return r.callClear(args) - case "resize": - return r.callResize(args) - case "search": - return r.callSearch(args) - default: - return nil, fmt.Errorf("unknown tool: %s", name) + for _, e := range r.entries { + if e.def.Name == name { + return e.handler(args) + } } + return nil, fmt.Errorf("unknown tool: %s", name) } type CreateArgs struct { @@ -391,51 +365,26 @@ func (r *ToolRegistry) callExec(args json.RawMessage) (*CallToolResult, error) { return nil, fmt.Errorf("input is required") } - input := a.Input - - _, startPos, err := r.client.Read(a.Name, "all", 0, 0) - if err != nil { - return nil, err - } - - if err := r.client.Send(a.Name, input, true); err != nil { - return nil, err - } - - settleMs := a.SettleMs - if a.WaitPattern == "" && settleMs == 0 { - settleMs = 500 - } - - timeoutSec := a.TimeoutSec - if timeoutSec == 0 { - timeoutSec = 10 - } - - output, pos, err := wait.ForOutput( - func() (string, int, error) { return r.client.Read(a.Name, "all", 0, 0) }, - wait.Config{ - Pattern: a.WaitPattern, - SettleMs: settleMs, - TimeoutSec: timeoutSec, - StartPosition: startPos, - SizeFunc: func() (int, error) { return r.client.Size(a.Name) }, - }, - ) + result, err := r.client.Exec(a.Name, daemon.ExecOptions{ + Input: a.Input, + SettleMs: a.SettleMs, + WaitPattern: a.WaitPattern, + TimeoutSec: a.TimeoutSec, + }) if err != nil { return nil, err } + output := result.Output if a.StripAnsi { output = ansi.Strip(output) } - result := map[string]interface{}{ - "input": input, + data, _ := json.MarshalIndent(map[string]interface{}{ + "input": result.Input, "output": output, - "position": pos, - } - data, _ := json.MarshalIndent(result, "", " ") + "position": result.Position, + }, "", " ") return &CallToolResult{ Content: []ContentBlock{{Type: "text", Text: string(data)}}, }, nil From 6a71bb319bcbd268f3b5444cb586e088239b6934 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 04:23:09 +0100 Subject: [PATCH 5/6] refactor: add protocol versioning between daemon and client Client sends ProtocolVersion=1 with every request. Daemon rejects mismatched versions with a clear error message telling the user to restart the daemon. Version 0 (omitted) is accepted for backward compatibility during rollout. --- internal/daemon/constants.go | 2 ++ internal/daemon/server.go | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/internal/daemon/constants.go b/internal/daemon/constants.go index ab86ccf..d220c6f 100644 --- a/internal/daemon/constants.go +++ b/internal/daemon/constants.go @@ -3,6 +3,8 @@ package daemon import "time" const ( + ProtocolVersion = 1 + ReadBufferSize = 4096 KillGracePeriod = 100 * time.Millisecond ClientDeadline = 30 * time.Second diff --git a/internal/daemon/server.go b/internal/daemon/server.go index df66253..db2f657 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -244,6 +244,7 @@ func (s *Server) Shutdown() { } type Request struct { + Version int `json:"version,omitempty"` Action string `json:"action"` Name string `json:"name,omitempty"` Command string `json:"command,omitempty"` @@ -283,6 +284,14 @@ func (s *Server) handleConn(conn net.Conn) { return } + if req.Version != ProtocolVersion && req.Version != 0 { + s.sendResponse(conn, Response{ + Success: false, + Error: fmt.Sprintf("protocol version mismatch: client=%d, daemon=%d. Restart daemon with: shelli daemon --stop && shelli daemon", req.Version, ProtocolVersion), + }) + return + } + var resp Response switch req.Action { case "create": From e2eaac6dde7def890685a42c4b4f9b9ca5eb21b6 Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 18 Feb 2026 05:01:47 +0100 Subject: [PATCH 6/6] feat: add --log-file flag and panic recovery to daemon Daemon previously ran with stdout/stderr discarded, making crash debugging impossible. Now supports --log-file to capture logs and redirect stderr. Added panic recovery in handleConn and captureOutput goroutines to log stack traces instead of crashing silently. Also fixes unparam lint: waitForSettle return value was unused. --- .claude/skills/tui-test/SKILL.md | 2 +- cmd/daemon.go | 16 ++++++++++++++++ internal/daemon/server.go | 23 ++++++++++++++++++----- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.claude/skills/tui-test/SKILL.md b/.claude/skills/tui-test/SKILL.md index 675205d..9bdd000 100644 --- a/.claude/skills/tui-test/SKILL.md +++ b/.claude/skills/tui-test/SKILL.md @@ -38,7 +38,7 @@ The lead agent (you) does the following: ### Phase 2: Parallel Testing -Spawn one teammate per app (max 5-6 concurrent to avoid system overload). Use `general-purpose` agent type with `bypassPermissions` mode. +Spawn one teammate per app (max 5-6 concurrent to avoid system overload). Use `general-purpose` agent type with `bypassPermissions` mode and `model: "sonnet"` (cheaper, sufficient for mechanical test execution). Each teammate gets: - The **Teammate Test Protocol** section below (copy it fully into the prompt) diff --git a/cmd/daemon.go b/cmd/daemon.go index d1fd7d6..2d7437a 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "log" "os" "os/signal" "path/filepath" @@ -22,6 +23,7 @@ var ( daemonDataDirFlag string daemonMemoryBackend bool daemonStoppedTTLFlag string + daemonLogFileFlag string ) var daemonCmd = &cobra.Command{ @@ -42,6 +44,8 @@ func init() { "Use in-memory storage instead of file-based (no persistence)") daemonCmd.Flags().StringVar(&daemonStoppedTTLFlag, "stopped-ttl", "", "Auto-cleanup stopped sessions after duration (e.g., 5m, 1h, 24h)") + daemonCmd.Flags().StringVar(&daemonLogFileFlag, "log-file", "", + "Write daemon logs to file (default: discard)") } func runDaemon(cmd *cobra.Command, args []string) error { @@ -49,6 +53,18 @@ func runDaemon(cmd *cobra.Command, args []string) error { return runMCPServer() } + if daemonLogFileFlag != "" { + f, err := os.OpenFile(daemonLogFileFlag, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("open log file: %w", err) + } + defer f.Close() + log.SetOutput(f) + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds) + os.Stderr = f + log.Println("daemon starting") + } + var opts []daemon.ServerOption if daemonDataDirFlag == "" { diff --git a/internal/daemon/server.go b/internal/daemon/server.go index db2f657..b098d13 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -3,11 +3,13 @@ package daemon import ( "encoding/json" "fmt" + "log" "net" "os" "os/exec" "path/filepath" "regexp" + "runtime/debug" "strings" "sync" "syscall" @@ -277,6 +279,11 @@ type Response struct { func (s *Server) handleConn(conn net.Conn) { defer conn.Close() + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in handleConn: %v\n%s", r, debug.Stack()) + } + }() var req Request if err := json.NewDecoder(conn).Decode(&req); err != nil { @@ -426,6 +433,12 @@ func (s *Server) handleCreate(req Request) Response { } func (s *Server) captureOutput(name string, h *sessionHandle) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in captureOutput[%s]: %v\n%s", name, r, debug.Stack()) + } + }() + s.mu.Lock() done := h.done p := h.pty @@ -729,7 +742,7 @@ func (s *Server) handleSnapshot(req Request) Response { deadline := time.Now().Add(timeout) - if _, err := waitForSettle(storage, req.Name, settleDuration, deadline); err != nil { + if err := waitForSettle(storage, req.Name, settleDuration, deadline); err != nil { return Response{Success: false, Error: fmt.Sprintf("poll size: %v", err)} } @@ -1091,14 +1104,14 @@ func (s *Server) handleResize(req Request) Response { }} } -func waitForSettle(storage OutputStorage, name string, settle time.Duration, deadline time.Time) (int64, error) { +func waitForSettle(storage OutputStorage, name string, settle time.Duration, deadline time.Time) error { lastChangeTime := time.Now() lastSize := int64(-1) for time.Now().Before(deadline) { time.Sleep(SnapshotPollInterval) size, err := storage.Size(name) if err != nil { - return 0, err + return err } if size != lastSize { lastSize = size @@ -1106,10 +1119,10 @@ func waitForSettle(storage OutputStorage, name string, settle time.Duration, dea continue } if size > 0 && time.Since(lastChangeTime) >= settle { - return size, nil + return nil } } - return lastSize, nil + return nil } func clampUint16(v int) uint16 {