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/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/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 8a8225c..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" @@ -32,15 +34,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"` @@ -50,17 +43,28 @@ type SessionInfo struct { StoppedAt string `json:"stopped_at,omitempty"` } +type sessionHandle struct { + name string + pid int + command string + state SessionState + createdAt time.Time + stoppedAt *time.Time + + 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 +105,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 +141,13 @@ 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{ + name: meta.Name, + pid: meta.PID, + command: meta.Command, + state: meta.State, + createdAt: meta.CreatedAt, + stoppedAt: meta.StoppedAt, } } @@ -208,11 +207,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.state == StateStopped && h.stoppedAt != nil { + if now.Sub(*h.stoppedAt) > s.stoppedTTL { s.storage.Delete(name) - delete(s.sessions, name) + delete(s.handles, name) } } } @@ -224,17 +223,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.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() } } } @@ -247,6 +246,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"` @@ -279,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 { @@ -286,6 +291,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": @@ -331,7 +344,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 +403,56 @@ 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{ + 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.name, + "pid": h.pid, + "command": h.command, + "created_at": h.createdAt, "cols": cols, "rows": rows, }} } -func (s *Server) captureOutput(name string, handle *ptyHandle, cmd *exec.Cmd) { +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 := 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 +495,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.state = StateStopped + now := time.Now() + h.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 +528,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.name, + PID: h.pid, + Command: h.command, + CreatedAt: h.createdAt.Format(time.RFC3339), + State: string(h.state), } - if sess.StoppedAt != nil { - info.StoppedAt = sess.StoppedAt.Format(time.RFC3339) + if h.stoppedAt != nil { + info.StoppedAt = h.stoppedAt.Format(time.RFC3339) } result = append(result, info) } @@ -530,12 +552,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.state storage := s.storage s.mu.Unlock() @@ -633,27 +655,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.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 +682,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 +692,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() }() @@ -726,26 +741,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) @@ -753,34 +751,12 @@ 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) } - 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 { @@ -800,25 +776,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.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.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 +803,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 +814,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.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.state = StateStopped now := time.Now() - sess.StoppedAt = &now + h.stoppedAt = &now if meta, err := s.storage.LoadMeta(req.Name); err == nil { meta.State = StateStopped @@ -886,32 +863,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.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 +900,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 +921,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 +984,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 +1003,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.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, @@ -1043,12 +1015,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.stoppedAt != nil { + result["stopped_at"] = h.stoppedAt.Format(time.RFC3339) } - if sess.State == StateRunning { - result["uptime_seconds"] = time.Since(sess.CreatedAt).Seconds() + if h.state == StateRunning { + result["uptime_seconds"] = time.Since(h.createdAt).Seconds() } return Response{Success: true, Data: result} @@ -1056,7 +1028,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 +1049,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.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 +1082,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() @@ -1134,6 +1104,27 @@ func (s *Server) handleResize(req Request) Response { }} } +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 err + } + if size != lastSize { + lastSize = size + lastChangeTime = time.Now() + continue + } + if size > 0 && time.Since(lastChangeTime) >= settle { + return nil + } + } + return nil +} + func clampUint16(v int) uint16 { if v < 0 { return 0 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