ScuttleBot

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

Keyboard Shortcuts

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