ScuttleBot

scuttlebot / cmd / claude-relay / main.go
Blame History Raw 1285 lines
1
package main
2
3
import (
4
"bufio"
5
"context"
6
"encoding/json"
7
"errors"
8
"fmt"
9
"hash/crc32"
10
"io"
11
"os"
12
"os/exec"
13
"os/signal"
14
"path/filepath"
15
"regexp"
16
"sort"
17
"strings"
18
"sync"
19
"syscall"
20
"time"
21
22
"github.com/conflicthq/scuttlebot/pkg/ircagent"
23
"github.com/conflicthq/scuttlebot/pkg/relaymirror"
24
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
25
"github.com/creack/pty"
26
"github.com/google/uuid"
27
"golang.org/x/term"
28
"gopkg.in/yaml.v3"
29
)
30
31
const (
32
defaultRelayURL = "http://localhost:8080"
33
defaultIRCAddr = "127.0.0.1:6667"
34
defaultChannel = "general"
35
defaultTransport = sessionrelay.TransportIRC
36
defaultPollInterval = 2 * time.Second
37
defaultConnectWait = 30 * time.Second
38
defaultInjectDelay = 150 * time.Millisecond
39
defaultBusyWindow = 1500 * time.Millisecond
40
defaultHeartbeat = 60 * time.Second
41
defaultConfigFile = ".config/scuttlebot-relay.env"
42
defaultScanInterval = 250 * time.Millisecond
43
defaultDiscoverWait = 60 * time.Second
44
defaultMirrorLineMax = 360
45
)
46
47
var serviceBots = map[string]struct{}{
48
"bridge": {},
49
"oracle": {},
50
"sentinel": {},
51
"steward": {},
52
"scribe": {},
53
"warden": {},
54
"snitch": {},
55
"herald": {},
56
"scroll": {},
57
"systembot": {},
58
"auditbot": {},
59
}
60
61
var (
62
secretHexPattern = regexp.MustCompile(`\b[a-f0-9]{32,}\b`)
63
secretKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+\b`)
64
bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._:-]+)`)
65
assignTokenPattern = regexp.MustCompile(`(?i)\b([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSPHRASE)[A-Z0-9_]*=)([^ \t"'` + "`" + `]+)`)
66
)
67
68
type config struct {
69
ClaudeBin string
70
ConfigFile string
71
Transport sessionrelay.Transport
72
URL string
73
Token string
74
IRCAddr string
75
IRCPass string
76
IRCAgentType string
77
IRCDeleteOnClose bool
78
Channel string
79
Channels []string
80
ChannelStateFile string
81
SessionID string
82
ClaudeSessionID string // UUID passed to Claude Code via --session-id
83
Nick string
84
HooksEnabled bool
85
InterruptOnMessage bool
86
MirrorReasoning bool
87
PollInterval time.Duration
88
HeartbeatInterval time.Duration
89
TargetCWD string
90
Args []string
91
}
92
93
type message = sessionrelay.Message
94
95
// mirrorLine is a single line of relay output with optional structured metadata.
96
type mirrorLine struct {
97
Text string
98
Meta json.RawMessage // nil for plain text lines
99
}
100
101
type relayState struct {
102
mu sync.RWMutex
103
lastBusy time.Time
104
}
105
106
// Claude Code JSONL session entry.
107
type claudeSessionEntry struct {
108
Type string `json:"type"`
109
CWD string `json:"cwd"`
110
Message struct {
111
Role string `json:"role"`
112
Content []struct {
113
Type string `json:"type"`
114
Text string `json:"text"`
115
Name string `json:"name"`
116
Input json.RawMessage `json:"input"`
117
} `json:"content"`
118
} `json:"message"`
119
Timestamp string `json:"timestamp"`
120
SessionID string `json:"sessionId"`
121
}
122
123
func main() {
124
cfg, err := loadConfig(os.Args[1:])
125
if err != nil {
126
fmt.Fprintln(os.Stderr, "claude-relay:", err)
127
os.Exit(1)
128
}
129
130
if err := run(cfg); err != nil {
131
fmt.Fprintln(os.Stderr, "claude-relay:", err)
132
os.Exit(1)
133
}
134
}
135
136
func run(cfg config) error {
137
fmt.Fprintf(os.Stderr, "claude-relay: nick %s\n", cfg.Nick)
138
relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
139
140
ctx, cancel := context.WithCancel(context.Background())
141
defer cancel()
142
_ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
143
defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
144
145
var relay sessionrelay.Connector
146
relayActive := false
147
var onlineAt time.Time
148
if relayRequested {
149
conn, err := sessionrelay.New(sessionrelay.Config{
150
Transport: cfg.Transport,
151
URL: cfg.URL,
152
Token: cfg.Token,
153
Channel: cfg.Channel,
154
Channels: cfg.Channels,
155
Nick: cfg.Nick,
156
IRC: sessionrelay.IRCConfig{
157
Addr: cfg.IRCAddr,
158
Pass: cfg.IRCPass,
159
AgentType: cfg.IRCAgentType,
160
DeleteOnClose: cfg.IRCDeleteOnClose,
161
},
162
})
163
if err != nil {
164
fmt.Fprintf(os.Stderr, "claude-relay: relay disabled: %v\n", err)
165
} else {
166
connectCtx, connectCancel := context.WithTimeout(ctx, defaultConnectWait)
167
if err := conn.Connect(connectCtx); err != nil {
168
fmt.Fprintf(os.Stderr, "claude-relay: relay disabled: %v\n", err)
169
_ = conn.Close(context.Background())
170
} else {
171
relay = conn
172
relayActive = true
173
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
174
fmt.Fprintf(os.Stderr, "claude-relay: channel state disabled: %v\n", err)
175
}
176
onlineAt = time.Now()
177
_ = relay.Post(context.Background(), fmt.Sprintf(
178
"online in %s; mention %s to interrupt before the next action",
179
filepath.Base(cfg.TargetCWD), cfg.Nick,
180
))
181
}
182
connectCancel()
183
}
184
}
185
if relay != nil {
186
defer func() {
187
closeCtx, closeCancel := context.WithTimeout(context.Background(), defaultConnectWait)
188
defer closeCancel()
189
_ = relay.Close(closeCtx)
190
}()
191
}
192
193
startedAt := time.Now()
194
// If resuming, extract the session ID from --resume arg. Otherwise use
195
// our generated UUID via --session-id for new sessions.
196
if resumeID := extractResumeID(cfg.Args); resumeID != "" {
197
cfg.ClaudeSessionID = resumeID
198
fmt.Fprintf(os.Stderr, "claude-relay: resuming session %s\n", resumeID)
199
} else {
200
// New session — inject --session-id so the file name is deterministic.
201
cfg.Args = append([]string{"--session-id", cfg.ClaudeSessionID}, cfg.Args...)
202
fmt.Fprintf(os.Stderr, "claude-relay: new session %s\n", cfg.ClaudeSessionID)
203
}
204
cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
205
cmd.Env = append(os.Environ(),
206
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
207
"SCUTTLEBOT_URL="+cfg.URL,
208
"SCUTTLEBOT_TOKEN="+cfg.Token,
209
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
210
"SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
211
"SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
212
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
213
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
214
"SCUTTLEBOT_NICK="+cfg.Nick,
215
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
216
)
217
// PTY mirror: only used for busy signal detection and dedup, NOT for
218
// posting to IRC. The session file mirror handles all IRC output with
219
// clean structured data. PTY output is too noisy (spinners, partial
220
// renders, ANSI fragments) for direct IRC posting.
221
var ptyMirror *relaymirror.PTYMirror
222
if relayActive {
223
ptyMirror = relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
224
// no-op: session file mirror handles IRC output
225
})
226
go mirrorSessionLoop(ctx, relay, cfg, startedAt, ptyMirror)
227
go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
228
}
229
230
if !isInteractiveTTY() {
231
cmd.Stdin = os.Stdin
232
cmd.Stdout = os.Stdout
233
cmd.Stderr = os.Stderr
234
err := cmd.Run()
235
if err != nil {
236
exitCode := exitStatus(err)
237
if relayActive {
238
_ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
239
}
240
return err
241
}
242
if relayActive {
243
_ = relay.Post(context.Background(), "offline (exit 0)")
244
}
245
return nil
246
}
247
248
ptmx, err := pty.Start(cmd)
249
if err != nil {
250
return err
251
}
252
defer func() { _ = ptmx.Close() }()
253
254
state := &relayState{}
255
256
if err := pty.InheritSize(os.Stdin, ptmx); err == nil {
257
resizeCh := make(chan os.Signal, 1)
258
signal.Notify(resizeCh, syscall.SIGWINCH)
259
defer signal.Stop(resizeCh)
260
go func() {
261
for range resizeCh {
262
_ = pty.InheritSize(os.Stdin, ptmx)
263
}
264
}()
265
resizeCh <- syscall.SIGWINCH
266
}
267
268
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
269
if err != nil {
270
return err
271
}
272
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }()
273
274
go func() {
275
_, _ = io.Copy(ptmx, os.Stdin)
276
}()
277
// Wire PTY mirror for dual-path: real-time text to IRC + session file for metadata.
278
if ptyMirror != nil {
279
ptyMirror.BusyCallback = func(now time.Time) {
280
state.mu.Lock()
281
state.lastBusy = now
282
state.mu.Unlock()
283
}
284
go func() {
285
_ = ptyMirror.Copy(ptmx, os.Stdout)
286
}()
287
} else {
288
go func() {
289
copyPTYOutput(ptmx, os.Stdout, state)
290
}()
291
}
292
if relayActive {
293
go relayInputLoop(ctx, relay, cfg, state, ptmx, onlineAt)
294
go handleReconnectSignal(ctx, &relay, cfg, state, ptmx, startedAt)
295
}
296
297
err = cmd.Wait()
298
cancel()
299
300
exitCode := exitStatus(err)
301
if relayActive {
302
_ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
303
}
304
return err
305
}
306
307
// --- Session mirroring ---
308
309
func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time, ptyDedup *relaymirror.PTYMirror) {
310
for {
311
if ctx.Err() != nil {
312
return
313
}
314
sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
315
if err != nil {
316
if ctx.Err() != nil {
317
return
318
}
319
fmt.Fprintf(os.Stderr, "claude-relay: session discovery failed: %v (retrying in 10s)\n", err)
320
time.Sleep(10 * time.Second)
321
continue
322
}
323
fmt.Fprintf(os.Stderr, "claude-relay: session file discovered: %s\n", sessionPath)
324
if err := tailSessionFile(ctx, sessionPath, cfg.MirrorReasoning, func(ml mirrorLine) {
325
for _, line := range splitMirrorText(ml.Text) {
326
if line == "" {
327
continue
328
}
329
// Mark as seen so PTY mirror deduplicates.
330
if ptyDedup != nil {
331
ptyDedup.MarkSeen(line)
332
}
333
if len(ml.Meta) > 0 {
334
_ = relay.PostWithMeta(ctx, line, ml.Meta)
335
} else {
336
_ = relay.Post(ctx, line)
337
}
338
}
339
}); err != nil && ctx.Err() == nil {
340
// Tail lost — retry discovery.
341
time.Sleep(5 * time.Second)
342
continue
343
}
344
return
345
}
346
}
347
348
func discoverSessionPath(ctx context.Context, cfg config, _ time.Time) (string, error) {
349
root, err := claudeSessionsRoot(cfg.TargetCWD)
350
if err != nil {
351
return "", err
352
}
353
354
// We passed --session-id to Claude Code, so the file name is deterministic.
355
target := filepath.Join(root, cfg.ClaudeSessionID+".jsonl")
356
fmt.Fprintf(os.Stderr, "claude-relay: waiting for session file %s\n", target)
357
358
ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
359
defer cancel()
360
361
ticker := time.NewTicker(defaultScanInterval)
362
defer ticker.Stop()
363
364
for {
365
if _, err := os.Stat(target); err == nil {
366
fmt.Fprintf(os.Stderr, "claude-relay: found session file %s\n", target)
367
return target, nil
368
}
369
select {
370
case <-ctx.Done():
371
return "", fmt.Errorf("session file %s not found after %v", target, defaultDiscoverWait)
372
case <-ticker.C:
373
}
374
}
375
}
376
377
// extractResumeID finds --resume or -r in args and returns the session UUID
378
// that follows it. Returns "" if not resuming or if the value isn't a UUID.
379
func extractResumeID(args []string) string {
380
for i := 0; i < len(args)-1; i++ {
381
if args[i] == "--resume" || args[i] == "-r" || args[i] == "--continue" {
382
val := args[i+1]
383
// Must look like a UUID (contains dashes, right length)
384
if len(val) >= 32 && strings.Contains(val, "-") {
385
return val
386
}
387
}
388
}
389
return ""
390
}
391
392
// claudeSessionsRoot returns ~/.claude/projects/<sanitized-cwd>/
393
func claudeSessionsRoot(cwd string) (string, error) {
394
home, err := os.UserHomeDir()
395
if err != nil {
396
return "", err
397
}
398
sanitized := strings.ReplaceAll(cwd, "/", "-")
399
sanitized = strings.TrimLeft(sanitized, "-")
400
return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
401
}
402
403
func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
404
file, err := os.Open(path)
405
if err != nil {
406
return err
407
}
408
defer file.Close()
409
410
if _, err := file.Seek(0, io.SeekEnd); err != nil {
411
return err
412
}
413
414
reader := bufio.NewReader(file)
415
for {
416
line, err := reader.ReadBytes('\n')
417
if len(line) > 0 {
418
for _, ml := range sessionMessages(line, mirrorReasoning) {
419
if ml.Text != "" {
420
emit(ml)
421
}
422
}
423
}
424
if err == nil {
425
continue
426
}
427
if errors.Is(err, io.EOF) {
428
select {
429
case <-ctx.Done():
430
return nil
431
case <-time.After(defaultScanInterval):
432
}
433
// Reset the buffered reader so it retries the underlying
434
// file descriptor. bufio.Reader caches EOF and won't see
435
// new bytes appended to the file without a reset.
436
reader.Reset(file)
437
continue
438
}
439
return err
440
}
441
}
442
443
// sessionMessages parses a Claude Code JSONL line and returns mirror lines
444
// with optional structured metadata for rich rendering in the web UI.
445
// If mirrorReasoning is true, thinking blocks are included prefixed with "💭 ".
446
func sessionMessages(line []byte, mirrorReasoning bool) []mirrorLine {
447
var entry claudeSessionEntry
448
if err := json.Unmarshal(line, &entry); err != nil {
449
return nil
450
}
451
if entry.Type != "assistant" || entry.Message.Role != "assistant" {
452
return nil
453
}
454
455
var out []mirrorLine
456
for _, block := range entry.Message.Content {
457
switch block.Type {
458
case "text":
459
for _, l := range splitMirrorText(block.Text) {
460
if l != "" {
461
out = append(out, mirrorLine{Text: sanitizeSecrets(l)})
462
}
463
}
464
case "tool_use":
465
if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
466
out = append(out, mirrorLine{
467
Text: msg,
468
Meta: toolMeta(block.Name, block.Input),
469
})
470
}
471
case "thinking":
472
if mirrorReasoning {
473
for _, l := range splitMirrorText(block.Text) {
474
if l != "" {
475
out = append(out, mirrorLine{Text: "💭 " + sanitizeSecrets(l)})
476
}
477
}
478
}
479
}
480
}
481
return out
482
}
483
484
// toolMeta builds a JSON metadata envelope for a tool_use block.
485
func toolMeta(name string, inputRaw json.RawMessage) json.RawMessage {
486
var input map[string]json.RawMessage
487
_ = json.Unmarshal(inputRaw, &input)
488
489
data := map[string]string{"tool": name}
490
491
str := func(key string) string {
492
v, ok := input[key]
493
if !ok {
494
return ""
495
}
496
var s string
497
if err := json.Unmarshal(v, &s); err != nil {
498
return strings.Trim(string(v), `"`)
499
}
500
return s
501
}
502
503
switch name {
504
case "Bash":
505
if cmd := str("command"); cmd != "" {
506
data["command"] = sanitizeSecrets(cmd)
507
}
508
case "Edit", "Write", "Read":
509
if p := str("file_path"); p != "" {
510
data["file"] = p
511
}
512
case "Glob":
513
if p := str("pattern"); p != "" {
514
data["pattern"] = p
515
}
516
case "Grep":
517
if p := str("pattern"); p != "" {
518
data["pattern"] = p
519
}
520
case "WebFetch":
521
if u := str("url"); u != "" {
522
data["url"] = sanitizeSecrets(u)
523
}
524
case "WebSearch":
525
if q := str("query"); q != "" {
526
data["query"] = q
527
}
528
}
529
530
meta := map[string]any{
531
"type": "tool_result",
532
"data": data,
533
}
534
b, _ := json.Marshal(meta)
535
return b
536
}
537
538
func summarizeToolUse(name string, inputRaw json.RawMessage) string {
539
var input map[string]json.RawMessage
540
_ = json.Unmarshal(inputRaw, &input)
541
542
str := func(key string) string {
543
v, ok := input[key]
544
if !ok {
545
return ""
546
}
547
var s string
548
if err := json.Unmarshal(v, &s); err != nil {
549
return strings.Trim(string(v), `"`)
550
}
551
return s
552
}
553
554
switch name {
555
case "Bash":
556
cmd := sanitizeSecrets(compactCommand(str("command")))
557
if cmd != "" {
558
return "› " + cmd
559
}
560
return "› bash"
561
case "Edit":
562
if p := str("file_path"); p != "" {
563
return "edit " + p
564
}
565
return "edit"
566
case "Write":
567
if p := str("file_path"); p != "" {
568
return "write " + p
569
}
570
return "write"
571
case "Read":
572
if p := str("file_path"); p != "" {
573
return "read " + p
574
}
575
return "read"
576
case "Glob":
577
if p := str("pattern"); p != "" {
578
return "glob " + p
579
}
580
return "glob"
581
case "Grep":
582
if p := str("pattern"); p != "" {
583
return "grep " + p
584
}
585
return "grep"
586
case "Agent":
587
return "spawn agent"
588
case "WebFetch":
589
if u := str("url"); u != "" {
590
return "fetch " + sanitizeSecrets(u)
591
}
592
return "fetch"
593
case "WebSearch":
594
if q := str("query"); q != "" {
595
return "search " + q
596
}
597
return "search"
598
case "TodoWrite":
599
return "update todos"
600
case "NotebookEdit":
601
if p := str("notebook_path"); p != "" {
602
return "edit notebook " + p
603
}
604
return "edit notebook"
605
default:
606
if name == "" {
607
return ""
608
}
609
return name
610
}
611
}
612
613
func compactCommand(cmd string) string {
614
trimmed := strings.TrimSpace(cmd)
615
trimmed = strings.Join(strings.Fields(trimmed), " ")
616
if strings.HasPrefix(trimmed, "cd ") {
617
if idx := strings.Index(trimmed, " && "); idx > 0 {
618
trimmed = strings.TrimSpace(trimmed[idx+4:])
619
}
620
}
621
if len(trimmed) > 140 {
622
return trimmed[:140] + "..."
623
}
624
return trimmed
625
}
626
627
func sanitizeSecrets(text string) string {
628
if text == "" {
629
return ""
630
}
631
text = bearerPattern.ReplaceAllString(text, "${1}[redacted]")
632
text = assignTokenPattern.ReplaceAllString(text, "${1}[redacted]")
633
text = secretKeyPattern.ReplaceAllString(text, "[redacted]")
634
text = secretHexPattern.ReplaceAllString(text, "[redacted]")
635
return text
636
}
637
638
func splitMirrorText(text string) []string {
639
clean := strings.ReplaceAll(text, "\r\n", "\n")
640
clean = strings.ReplaceAll(clean, "\r", "\n")
641
raw := strings.Split(clean, "\n")
642
var out []string
643
for _, line := range raw {
644
line = strings.TrimSpace(line)
645
if line == "" {
646
continue
647
}
648
for len(line) > defaultMirrorLineMax {
649
cut := strings.LastIndex(line[:defaultMirrorLineMax], " ")
650
if cut <= 0 {
651
cut = defaultMirrorLineMax
652
}
653
out = append(out, line[:cut])
654
line = strings.TrimSpace(line[cut:])
655
}
656
if line != "" {
657
out = append(out, line)
658
}
659
}
660
return out
661
}
662
663
// --- Relay input (operator → Claude) ---
664
665
func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File, since time.Time) {
666
lastSeen := since
667
ticker := time.NewTicker(cfg.PollInterval)
668
defer ticker.Stop()
669
670
for {
671
select {
672
case <-ctx.Done():
673
return
674
case <-ticker.C:
675
messages, err := relay.MessagesSince(ctx, lastSeen)
676
if err != nil {
677
continue
678
}
679
batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
680
if len(batch) == 0 {
681
continue
682
}
683
lastSeen = newest
684
pending := make([]message, 0, len(batch))
685
for _, msg := range batch {
686
handled, err := handleRelayCommand(ctx, relay, cfg, msg)
687
if err != nil {
688
if ctx.Err() == nil {
689
_ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
690
}
691
return
692
}
693
if handled {
694
continue
695
}
696
pending = append(pending, msg)
697
}
698
if len(pending) == 0 {
699
continue
700
}
701
if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
702
if ctx.Err() == nil {
703
_ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
704
}
705
return
706
}
707
}
708
}
709
}
710
711
// handleReconnectSignal listens for SIGUSR1 and tears down/rebuilds
712
// the IRC connection. The relay-watchdog sidecar sends this signal
713
// when it detects the server restarted or the network is down.
714
func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config, state *relayState, ptmx *os.File, startedAt time.Time) {
715
sigCh := make(chan os.Signal, 1)
716
signal.Notify(sigCh, syscall.SIGUSR1)
717
defer signal.Stop(sigCh)
718
719
for {
720
select {
721
case <-ctx.Done():
722
return
723
case <-sigCh:
724
}
725
726
fmt.Fprintf(os.Stderr, "claude-relay: received SIGUSR1, reconnecting IRC...\n")
727
old := *relayPtr
728
if old != nil {
729
_ = old.Close(context.Background())
730
}
731
732
// Retry with backoff.
733
wait := 2 * time.Second
734
for attempt := 0; attempt < 10; attempt++ {
735
if ctx.Err() != nil {
736
return
737
}
738
time.Sleep(wait)
739
740
conn, err := sessionrelay.New(sessionrelay.Config{
741
Transport: cfg.Transport,
742
URL: cfg.URL,
743
Token: cfg.Token,
744
Channel: cfg.Channel,
745
Channels: cfg.Channels,
746
Nick: cfg.Nick,
747
IRC: sessionrelay.IRCConfig{
748
Addr: cfg.IRCAddr,
749
Pass: "", // force re-registration
750
AgentType: cfg.IRCAgentType,
751
DeleteOnClose: cfg.IRCDeleteOnClose,
752
},
753
})
754
if err != nil {
755
wait = min(wait*2, 30*time.Second)
756
continue
757
}
758
759
connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
760
if err := conn.Connect(connectCtx); err != nil {
761
_ = conn.Close(context.Background())
762
cancel()
763
wait = min(wait*2, 30*time.Second)
764
continue
765
}
766
cancel()
767
768
*relayPtr = conn
769
now := time.Now()
770
_ = conn.Post(context.Background(), fmt.Sprintf(
771
"reconnected in %s; mention %s to interrupt",
772
filepath.Base(cfg.TargetCWD), cfg.Nick,
773
))
774
fmt.Fprintf(os.Stderr, "claude-relay: reconnected, restarting mirror and input loops\n")
775
776
// Restart mirror and input loops with the new connector.
777
// Use epoch time for mirror so it finds the existing session file
778
// regardless of when it was last modified.
779
go mirrorSessionLoop(ctx, conn, cfg, time.Time{}, nil)
780
go relayInputLoop(ctx, conn, cfg, state, ptmx, now)
781
break
782
}
783
}
784
}
785
786
func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) {
787
if interval <= 0 {
788
return
789
}
790
ticker := time.NewTicker(interval)
791
defer ticker.Stop()
792
for {
793
select {
794
case <-ctx.Done():
795
return
796
case <-ticker.C:
797
if r := *relayPtr; r != nil {
798
_ = r.Touch(ctx)
799
}
800
}
801
}
802
}
803
804
func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
805
lines := make([]string, 0, len(batch))
806
for _, msg := range batch {
807
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
808
if text == "" {
809
text = strings.TrimSpace(msg.Text)
810
}
811
channelPrefix := ""
812
if msg.Channel != "" {
813
channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
814
}
815
if msg.Channel == "" || msg.Channel == controlChannel {
816
channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
817
}
818
lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
819
}
820
821
var block strings.Builder
822
block.WriteString("[IRC operator messages]\n")
823
for _, line := range lines {
824
block.WriteString(line)
825
block.WriteByte('\n')
826
}
827
828
notice := "\r\n" + block.String() + "\r\n"
829
_, _ = os.Stdout.WriteString(notice)
830
831
if cfg.InterruptOnMessage && state.shouldInterrupt(time.Now()) {
832
if _, err := writer.Write([]byte{3}); err != nil {
833
return err
834
}
835
time.Sleep(defaultInjectDelay)
836
}
837
838
if _, err := writer.Write([]byte(block.String())); err != nil {
839
return err
840
}
841
_, err := writer.Write([]byte{'\r'})
842
return err
843
}
844
845
func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
846
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
847
if text == "" {
848
text = strings.TrimSpace(msg.Text)
849
}
850
851
cmd, ok := sessionrelay.ParseBrokerCommand(text)
852
if !ok {
853
return false, nil
854
}
855
856
postStatus := func(channel, text string) error {
857
if channel == "" {
858
channel = relay.ControlChannel()
859
}
860
return relay.PostTo(ctx, channel, text)
861
}
862
863
switch cmd.Name {
864
case "channels":
865
return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
866
case "join":
867
if cmd.Channel == "" {
868
return true, postStatus(msg.Channel, "usage: /join #channel")
869
}
870
if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
871
return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
872
}
873
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
874
return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
875
}
876
return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
877
case "part":
878
if cmd.Channel == "" {
879
return true, postStatus(msg.Channel, "usage: /part #channel")
880
}
881
if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
882
return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
883
}
884
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
885
return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
886
}
887
replyChannel := msg.Channel
888
if sameChannel(replyChannel, cmd.Channel) {
889
replyChannel = relay.ControlChannel()
890
}
891
return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
892
default:
893
return false, nil
894
}
895
}
896
897
func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
898
buf := make([]byte, 4096)
899
for {
900
n, err := src.Read(buf)
901
if n > 0 {
902
state.observeOutput(buf[:n], time.Now())
903
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
904
return
905
}
906
}
907
if err != nil {
908
return
909
}
910
}
911
}
912
913
func (s *relayState) observeOutput(data []byte, now time.Time) {
914
if s == nil {
915
return
916
}
917
// Claude Code uses "esc to interrupt" as its busy signal.
918
if strings.Contains(strings.ToLower(string(data)), "esc to interrupt") {
919
s.mu.Lock()
920
s.lastBusy = now
921
s.mu.Unlock()
922
}
923
}
924
925
func (s *relayState) shouldInterrupt(now time.Time) bool {
926
if s == nil {
927
return false
928
}
929
s.mu.RLock()
930
lastBusy := s.lastBusy
931
s.mu.RUnlock()
932
return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
933
}
934
935
func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
936
filtered := make([]message, 0, len(messages))
937
newest := since
938
for _, msg := range messages {
939
if msg.At.IsZero() || !msg.At.After(since) {
940
continue
941
}
942
if msg.At.After(newest) {
943
newest = msg.At
944
}
945
if msg.Nick == nick {
946
continue
947
}
948
if _, ok := serviceBots[msg.Nick]; ok {
949
continue
950
}
951
if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
952
continue
953
}
954
if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
955
continue
956
}
957
filtered = append(filtered, msg)
958
}
959
sort.Slice(filtered, func(i, j int) bool {
960
return filtered[i].At.Before(filtered[j].At)
961
})
962
return filtered, newest
963
}
964
965
// --- Config loading ---
966
967
func loadConfig(args []string) (config, error) {
968
fileConfig := readEnvFile(configFilePath())
969
970
cfg := config{
971
ClaudeBin: getenvOr(fileConfig, "CLAUDE_BIN", "claude"),
972
ConfigFile: getenvOr(fileConfig, "SCUTTLEBOT_CONFIG_FILE", configFilePath()),
973
Transport: sessionrelay.Transport(strings.ToLower(getenvOr(fileConfig, "SCUTTLEBOT_TRANSPORT", string(defaultTransport)))),
974
URL: getenvOr(fileConfig, "SCUTTLEBOT_URL", defaultRelayURL),
975
Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
976
IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
977
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
978
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
979
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
980
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
981
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
982
MirrorReasoning: getenvBoolOr(fileConfig, "SCUTTLEBOT_MIRROR_REASONING", true),
983
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
984
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
985
Args: append([]string(nil), args...),
986
}
987
988
controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
989
cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
990
if len(cfg.Channels) > 0 {
991
cfg.Channel = cfg.Channels[0]
992
}
993
994
target, err := targetCWD(args)
995
if err != nil {
996
return config{}, err
997
}
998
cfg.TargetCWD = target
999
1000
// Merge per-repo config if present.
1001
if rc, err := loadRepoConfig(target); err == nil && rc != nil {
1002
cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
1003
}
1004
1005
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
1006
if sessionID == "" {
1007
sessionID = defaultSessionID(target)
1008
}
1009
cfg.SessionID = sanitize(sessionID)
1010
cfg.ClaudeSessionID = uuid.New().String()
1011
1012
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
1013
if nick == "" {
1014
nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
1015
}
1016
cfg.Nick = sanitize(nick)
1017
cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
1018
1019
if cfg.Channel == "" {
1020
cfg.Channel = defaultChannel
1021
cfg.Channels = []string{defaultChannel}
1022
}
1023
if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
1024
cfg.HooksEnabled = false
1025
}
1026
return cfg, nil
1027
}
1028
1029
func defaultChannelStateFile(nick string) string {
1030
return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
1031
}
1032
1033
func sameChannel(a, b string) bool {
1034
return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
1035
}
1036
1037
func configFilePath() string {
1038
if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
1039
return value
1040
}
1041
home, err := os.UserHomeDir()
1042
if err != nil {
1043
return filepath.Join(".config", "scuttlebot-relay.env")
1044
}
1045
return filepath.Join(home, ".config", "scuttlebot-relay.env")
1046
}
1047
1048
func readEnvFile(path string) map[string]string {
1049
values := make(map[string]string)
1050
file, err := os.Open(path)
1051
if err != nil {
1052
return values
1053
}
1054
defer file.Close()
1055
1056
scanner := bufio.NewScanner(file)
1057
for scanner.Scan() {
1058
line := strings.TrimSpace(scanner.Text())
1059
if line == "" || strings.HasPrefix(line, "#") {
1060
continue
1061
}
1062
line = strings.TrimPrefix(line, "export ")
1063
key, value, ok := strings.Cut(line, "=")
1064
if !ok {
1065
continue
1066
}
1067
values[strings.TrimSpace(key)] = strings.TrimSpace(strings.Trim(value, `"'`))
1068
}
1069
return values
1070
}
1071
1072
func getenvOr(file map[string]string, key, fallback string) string {
1073
if value := os.Getenv(key); value != "" {
1074
return value
1075
}
1076
if value := file[key]; value != "" {
1077
return value
1078
}
1079
return fallback
1080
}
1081
1082
func getenvBoolOr(file map[string]string, key string, fallback bool) bool {
1083
value := getenvOr(file, key, "")
1084
if value == "" {
1085
return fallback
1086
}
1087
switch strings.ToLower(value) {
1088
case "0", "false", "no", "off":
1089
return false
1090
default:
1091
return true
1092
}
1093
}
1094
1095
func getenvDurationOr(file map[string]string, key string, fallback time.Duration) time.Duration {
1096
value := getenvOr(file, key, "")
1097
if value == "" {
1098
return fallback
1099
}
1100
if strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) == -1 {
1101
value += "s"
1102
}
1103
d, err := time.ParseDuration(value)
1104
if err != nil || d <= 0 {
1105
return fallback
1106
}
1107
return d
1108
}
1109
1110
func getenvDurationAllowZeroOr(file map[string]string, key string, fallback time.Duration) time.Duration {
1111
value := getenvOr(file, key, "")
1112
if value == "" {
1113
return fallback
1114
}
1115
if strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) == -1 {
1116
value += "s"
1117
}
1118
d, err := time.ParseDuration(value)
1119
if err != nil || d < 0 {
1120
return fallback
1121
}
1122
return d
1123
}
1124
1125
func targetCWD(args []string) (string, error) {
1126
cwd, err := os.Getwd()
1127
if err != nil {
1128
return "", err
1129
}
1130
target := cwd
1131
var prev string
1132
for _, arg := range args {
1133
switch {
1134
case prev == "-C" || prev == "--cd":
1135
target = arg
1136
prev = ""
1137
continue
1138
case arg == "-C" || arg == "--cd":
1139
prev = arg
1140
continue
1141
case strings.HasPrefix(arg, "-C="):
1142
target = strings.TrimPrefix(arg, "-C=")
1143
case strings.HasPrefix(arg, "--cd="):
1144
target = strings.TrimPrefix(arg, "--cd=")
1145
}
1146
}
1147
if filepath.IsAbs(target) {
1148
return target, nil
1149
}
1150
return filepath.Abs(target)
1151
}
1152
1153
func sanitize(value string) string {
1154
var b strings.Builder
1155
for _, r := range value {
1156
switch {
1157
case r >= 'a' && r <= 'z':
1158
b.WriteRune(r)
1159
case r >= 'A' && r <= 'Z':
1160
b.WriteRune(r)
1161
case r >= '0' && r <= '9':
1162
b.WriteRune(r)
1163
case r == '-' || r == '_':
1164
b.WriteRune(r)
1165
default:
1166
b.WriteRune('-')
1167
}
1168
}
1169
result := strings.Trim(b.String(), "-")
1170
if result == "" {
1171
return "session"
1172
}
1173
return result
1174
}
1175
1176
func defaultSessionID(target string) string {
1177
sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano())))
1178
return fmt.Sprintf("%08x", sum)
1179
}
1180
1181
func isInteractiveTTY() bool {
1182
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
1183
}
1184
1185
func boolString(v bool) string {
1186
if v {
1187
return "1"
1188
}
1189
return "0"
1190
}
1191
1192
func shouldRelaySession(args []string) bool {
1193
for _, arg := range args {
1194
switch arg {
1195
case "-h", "--help", "-V", "--version":
1196
return false
1197
}
1198
}
1199
1200
for _, arg := range args {
1201
if strings.HasPrefix(arg, "-") {
1202
continue
1203
}
1204
switch arg {
1205
case "help", "completion":
1206
return false
1207
default:
1208
return true
1209
}
1210
}
1211
1212
return true
1213
}
1214
1215
func exitStatus(err error) int {
1216
if err == nil {
1217
return 0
1218
}
1219
var exitErr *exec.ExitError
1220
if errors.As(err, &exitErr) {
1221
return exitErr.ExitCode()
1222
}
1223
return 1
1224
}
1225
1226
// repoConfig is the per-repo .scuttlebot.yaml format.
1227
type repoConfig struct {
1228
Channel string `yaml:"channel"`
1229
Channels []string `yaml:"channels"`
1230
}
1231
1232
// allChannels returns the singular channel (if set) prepended to the channels list.
1233
func (rc *repoConfig) allChannels() []string {
1234
if rc.Channel == "" {
1235
return rc.Channels
1236
}
1237
return append([]string{rc.Channel}, rc.Channels...)
1238
}
1239
1240
// loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
1241
// Stops at the git root (directory containing .git) or the filesystem root.
1242
// Returns nil, nil if no config file is found.
1243
func loadRepoConfig(dir string) (*repoConfig, error) {
1244
current := dir
1245
for {
1246
candidate := filepath.Join(current, ".scuttlebot.yaml")
1247
if data, err := os.ReadFile(candidate); err == nil {
1248
var rc repoConfig
1249
if err := yaml.Unmarshal(data, &rc); err != nil {
1250
return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
1251
}
1252
fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
1253
return &rc, nil
1254
}
1255
1256
// Stop if this directory is a git root.
1257
if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
1258
return nil, nil
1259
}
1260
1261
parent := filepath.Dir(current)
1262
if parent == current {
1263
return nil, nil
1264
}
1265
current = parent
1266
}
1267
}
1268
1269
// mergeChannels appends extra channels to existing, deduplicating.
1270
func mergeChannels(existing, extra []string) []string {
1271
seen := make(map[string]struct{}, len(existing))
1272
for _, ch := range existing {
1273
seen[ch] = struct{}{}
1274
}
1275
merged := append([]string(nil), existing...)
1276
for _, ch := range extra {
1277
if _, ok := seen[ch]; ok {
1278
continue
1279
}
1280
seen[ch] = struct{}{}
1281
merged = append(merged, ch)
1282
}
1283
return merged
1284
}
1285

Keyboard Shortcuts

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