ScuttleBot

scuttlebot / pkg / relaymirror / pty.go
Source Blame History 172 lines
3be3167… noreply 1 // Package relaymirror provides shared PTY output mirroring for relay binaries.
3be3167… noreply 2 //
3be3167… noreply 3 // PTYMirror reads from a PTY file descriptor and emits lines to a callback.
3be3167… noreply 4 // It handles ANSI escape stripping and line buffering for clean IRC output.
3be3167… noreply 5 package relaymirror
3be3167… noreply 6
3be3167… noreply 7 import (
3be3167… noreply 8 "bytes"
3be3167… noreply 9 "io"
3be3167… noreply 10 "regexp"
3be3167… noreply 11 "strings"
3be3167… noreply 12 "sync"
3be3167… noreply 13 "time"
3be3167… noreply 14 )
3be3167… noreply 15
46ba17d… lmata 16 // ansiRE matches ANSI escape sequences (including partial/split ones).
46ba17d… lmata 17 var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]|\x1b\].*?\x07|\x1b\(B|\[\?[0-9]+[hl]`)
3be3167… noreply 18
3be3167… noreply 19 // noiseRE matches common terminal noise: spinner chars, progress bars, cursor movement.
46ba17d… lmata 20 var noiseRE = regexp.MustCompile(`^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\-\\|/]+$|^\s*\d+%\s*$|^[.]+$|^\[?\?[0-9]+[hl]`)
3be3167… noreply 21
3be3167… noreply 22 // PTYMirror reads PTY output and emits clean text lines to IRC.
3be3167… noreply 23 // It includes rate limiting and noise filtering for clean IRC output.
3be3167… noreply 24 type PTYMirror struct {
3be3167… noreply 25 maxLineLen int
3be3167… noreply 26 minInterval time.Duration // minimum time between emitted lines
3be3167… noreply 27 mu sync.Mutex
3be3167… noreply 28 lastEmit time.Time
3be3167… noreply 29 recentLines map[string]time.Time // dedup: line hash → last seen
3be3167… noreply 30 onLine func(line string)
3be3167… noreply 31 // BusyCallback is called when PTY output suggests the agent is busy
3be3167… noreply 32 // (e.g. "esc to interrupt", "working..."). Optional.
3be3167… noreply 33 BusyCallback func(now time.Time)
3be3167… noreply 34 }
3be3167… noreply 35
3be3167… noreply 36 // NewPTYMirror creates a mirror that calls onLine for each output line.
3be3167… noreply 37 // maxLineLen truncates long lines (0 = no limit).
3be3167… noreply 38 // minInterval throttles output (0 = no throttle, recommended: 500ms for IRC).
3be3167… noreply 39 func NewPTYMirror(maxLineLen int, minInterval time.Duration, onLine func(line string)) *PTYMirror {
3be3167… noreply 40 return &PTYMirror{
3be3167… noreply 41 maxLineLen: maxLineLen,
3be3167… noreply 42 minInterval: minInterval,
3be3167… noreply 43 recentLines: make(map[string]time.Time),
3be3167… noreply 44 onLine: onLine,
3be3167… noreply 45 }
3be3167… noreply 46 }
3be3167… noreply 47
3be3167… noreply 48 // Copy reads from r (typically a PTY fd) and also writes to w (typically
3be3167… noreply 49 // os.Stdout for the interactive terminal). Lines are emitted via onLine.
3be3167… noreply 50 // Blocks until r returns EOF or error.
3be3167… noreply 51 func (m *PTYMirror) Copy(r io.Reader, w io.Writer) error {
3be3167… noreply 52 buf := make([]byte, 4096)
694a949… lmata 53 lineCh := make(chan []byte, 64) // buffered channel for async line processing
694a949… lmata 54 done := make(chan struct{})
694a949… lmata 55
694a949… lmata 56 // Process lines in a separate goroutine so terminal is never blocked.
694a949… lmata 57 go func() {
694a949… lmata 58 defer close(done)
694a949… lmata 59 var lineBuf bytes.Buffer
694a949… lmata 60 for chunk := range lineCh {
694a949… lmata 61 lineBuf.Write(chunk)
694a949… lmata 62 m.emitLines(&lineBuf)
694a949… lmata 63 }
694a949… lmata 64 if lineBuf.Len() > 0 {
694a949… lmata 65 m.emitLine(lineBuf.String())
694a949… lmata 66 }
694a949… lmata 67 }()
3be3167… noreply 68
3be3167… noreply 69 for {
3be3167… noreply 70 n, err := r.Read(buf)
3be3167… noreply 71 if n > 0 {
3be3167… noreply 72 // Detect busy signals for interrupt logic.
3be3167… noreply 73 if m.BusyCallback != nil {
3be3167… noreply 74 lower := strings.ToLower(string(buf[:n]))
3be3167… noreply 75 if strings.Contains(lower, "esc to interrupt") || strings.Contains(lower, "working...") {
3be3167… noreply 76 m.BusyCallback(time.Now())
3be3167… noreply 77 }
3be3167… noreply 78 }
694a949… lmata 79 // Pass through to terminal — ALWAYS immediate, never blocked.
3be3167… noreply 80 if w != nil {
3be3167… noreply 81 _, _ = w.Write(buf[:n])
3be3167… noreply 82 }
694a949… lmata 83 // Send to line processor (non-blocking with buffered channel).
694a949… lmata 84 chunk := make([]byte, n)
694a949… lmata 85 copy(chunk, buf[:n])
694a949… lmata 86 select {
694a949… lmata 87 case lineCh <- chunk:
694a949… lmata 88 default:
694a949… lmata 89 // Channel full — drop this chunk rather than block terminal.
694a949… lmata 90 }
3be3167… noreply 91 }
3be3167… noreply 92 if err != nil {
694a949… lmata 93 close(lineCh)
694a949… lmata 94 <-done
3be3167… noreply 95 if err == io.EOF {
3be3167… noreply 96 return nil
3be3167… noreply 97 }
3be3167… noreply 98 return err
3be3167… noreply 99 }
3be3167… noreply 100 }
3be3167… noreply 101 }
3be3167… noreply 102
3be3167… noreply 103 func (m *PTYMirror) emitLines(buf *bytes.Buffer) {
3be3167… noreply 104 for {
3be3167… noreply 105 line, err := buf.ReadString('\n')
3be3167… noreply 106 if err != nil {
3be3167… noreply 107 // No newline found — put back the partial line.
3be3167… noreply 108 buf.WriteString(line)
3be3167… noreply 109 return
3be3167… noreply 110 }
3be3167… noreply 111 m.emitLine(line)
3be3167… noreply 112 }
3be3167… noreply 113 }
3be3167… noreply 114
3be3167… noreply 115 func (m *PTYMirror) emitLine(raw string) {
3be3167… noreply 116 // Strip ANSI escapes and carriage returns.
3be3167… noreply 117 clean := ansiRE.ReplaceAllString(raw, "")
3be3167… noreply 118 clean = strings.ReplaceAll(clean, "\r", "")
3be3167… noreply 119 clean = strings.TrimRight(clean, "\n")
3be3167… noreply 120 clean = strings.TrimSpace(clean)
3be3167… noreply 121
3be3167… noreply 122 if clean == "" {
3be3167… noreply 123 return
3be3167… noreply 124 }
3be3167… noreply 125 // Skip terminal noise (spinners, progress bars, dots).
3be3167… noreply 126 if noiseRE.MatchString(clean) {
3be3167… noreply 127 return
3be3167… noreply 128 }
3be3167… noreply 129 if m.maxLineLen > 0 && len(clean) > m.maxLineLen {
3be3167… noreply 130 clean = clean[:m.maxLineLen-3] + "..."
3be3167… noreply 131 }
3be3167… noreply 132
3be3167… noreply 133 m.mu.Lock()
3be3167… noreply 134 defer m.mu.Unlock()
3be3167… noreply 135
3be3167… noreply 136 now := time.Now()
3be3167… noreply 137 // Rate limit.
3be3167… noreply 138 if m.minInterval > 0 && now.Sub(m.lastEmit) < m.minInterval {
3be3167… noreply 139 return
3be3167… noreply 140 }
3be3167… noreply 141 // Dedup: skip if we've seen this exact line in the last 5 seconds.
3be3167… noreply 142 if seen, ok := m.recentLines[clean]; ok && now.Sub(seen) < 5*time.Second {
3be3167… noreply 143 return
3be3167… noreply 144 }
3be3167… noreply 145 m.recentLines[clean] = now
3be3167… noreply 146 m.lastEmit = now
3be3167… noreply 147
3be3167… noreply 148 // Prune old dedup entries.
3be3167… noreply 149 if len(m.recentLines) > 200 {
3be3167… noreply 150 for k, v := range m.recentLines {
3be3167… noreply 151 if now.Sub(v) > 10*time.Second {
3be3167… noreply 152 delete(m.recentLines, k)
3be3167… noreply 153 }
3be3167… noreply 154 }
3be3167… noreply 155 }
3be3167… noreply 156
3be3167… noreply 157 m.onLine(clean)
3be3167… noreply 158 }
3be3167… noreply 159
3be3167… noreply 160 // MarkSeen records a line as recently seen for dedup purposes.
3be3167… noreply 161 // Call this when the session file mirror emits a line so the PTY mirror
3be3167… noreply 162 // won't duplicate it.
3be3167… noreply 163 func (m *PTYMirror) MarkSeen(line string) {
3be3167… noreply 164 m.mu.Lock()
3be3167… noreply 165 m.recentLines[strings.TrimSpace(line)] = time.Now()
3be3167… noreply 166 m.mu.Unlock()
3be3167… noreply 167 }
3be3167… noreply 168
3be3167… noreply 169 // StripANSI removes ANSI escape sequences from a string.
3be3167… noreply 170 func StripANSI(s string) string {
3be3167… noreply 171 return ansiRE.ReplaceAllString(s, "")
3be3167… noreply 172 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button