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