ScuttleBot

feat: claude-relay broker with session mirroring and IRC-first transport - Session mirroring: tails ~/.claude/projects/<cwd>/\*.jsonl and posts assistant text and tool summaries to IRC in real time (same pattern as codex-relay) - Claude-specific tool summarization: Bash, Edit, Write, Read, Glob, Grep, Agent, WebFetch, WebSearch, TodoWrite, NotebookEdit - Thinking blocks intentionally skipped (too verbose) - Secret sanitization on all mirrored output - Default transport changed to IRC (HTTP kept as fallback via SCUTTLEBOT_TRANSPORT) - Installer builds compiled Go binary, not the bash shim

lmata 2026-04-01 13:40 trunk
Commit fdc70addefa6fe2e8975a7e32b24b44fd5e45f3b7527a72887ae6c7f5160cb52
1 file changed +358 -11
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -1,18 +1,20 @@
11
package main
22
33
import (
44
"bufio"
55
"context"
6
+ "encoding/json"
67
"errors"
78
"fmt"
89
"hash/crc32"
910
"io"
1011
"os"
1112
"os/exec"
1213
"os/signal"
1314
"path/filepath"
15
+ "regexp"
1416
"sort"
1517
"strings"
1618
"sync"
1719
"syscall"
1820
"time"
@@ -22,20 +24,23 @@
2224
"github.com/creack/pty"
2325
"golang.org/x/term"
2426
)
2527
2628
const (
27
- defaultRelayURL = "http://localhost:8080"
28
- defaultIRCAddr = "127.0.0.1:6667"
29
- defaultChannel = "general"
30
- defaultTransport = sessionrelay.TransportHTTP
31
- defaultPollInterval = 2 * time.Second
32
- defaultConnectWait = 10 * time.Second
33
- defaultInjectDelay = 150 * time.Millisecond
34
- defaultBusyWindow = 1500 * time.Millisecond
35
- defaultHeartbeat = 60 * time.Second
36
- defaultConfigFile = ".config/scuttlebot-relay.env"
29
+ defaultRelayURL = "http://localhost:8080"
30
+ defaultIRCAddr = "127.0.0.1:6667"
31
+ defaultChannel = "general"
32
+ defaultTransport = sessionrelay.TransportIRC
33
+ defaultPollInterval = 2 * time.Second
34
+ defaultConnectWait = 10 * time.Second
35
+ defaultInjectDelay = 150 * time.Millisecond
36
+ defaultBusyWindow = 1500 * time.Millisecond
37
+ defaultHeartbeat = 60 * time.Second
38
+ defaultConfigFile = ".config/scuttlebot-relay.env"
39
+ defaultScanInterval = 250 * time.Millisecond
40
+ defaultDiscoverWait = 20 * time.Second
41
+ defaultMirrorLineMax = 360
3742
)
3843
3944
var serviceBots = map[string]struct{}{
4045
"bridge": {},
4146
"oracle": {},
@@ -47,10 +52,17 @@
4752
"herald": {},
4853
"scroll": {},
4954
"systembot": {},
5055
"auditbot": {},
5156
}
57
+
58
+var (
59
+ secretHexPattern = regexp.MustCompile(`\b[a-f0-9]{32,}\b`)
60
+ secretKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+\b`)
61
+ bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._:-]+)`)
62
+ assignTokenPattern = regexp.MustCompile(`(?i)\b([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSPHRASE)[A-Z0-9_]*=)([^ \t"'` + "`" + `]+)`)
63
+)
5264
5365
type config struct {
5466
ClaudeBin string
5567
ConfigFile string
5668
Transport sessionrelay.Transport
@@ -75,10 +87,27 @@
7587
7688
type relayState struct {
7789
mu sync.RWMutex
7890
lastBusy time.Time
7991
}
92
+
93
+// Claude Code JSONL session entry.
94
+type claudeSessionEntry struct {
95
+ Type string `json:"type"`
96
+ CWD string `json:"cwd"`
97
+ Message struct {
98
+ Role string `json:"role"`
99
+ Content []struct {
100
+ Type string `json:"type"`
101
+ Text string `json:"text"`
102
+ Name string `json:"name"`
103
+ Input json.RawMessage `json:"input"`
104
+ } `json:"content"`
105
+ } `json:"message"`
106
+ Timestamp string `json:"timestamp"`
107
+ SessionID string `json:"sessionId"`
108
+}
80109
81110
func main() {
82111
cfg, err := loadConfig(os.Args[1:])
83112
if err != nil {
84113
fmt.Fprintln(os.Stderr, "claude-relay:", err)
@@ -138,10 +167,11 @@
138167
defer closeCancel()
139168
_ = relay.Close(closeCtx)
140169
}()
141170
}
142171
172
+ startedAt := time.Now()
143173
cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
144174
cmd.Env = append(os.Environ(),
145175
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
146176
"SCUTTLEBOT_URL="+cfg.URL,
147177
"SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -150,10 +180,11 @@
150180
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
151181
"SCUTTLEBOT_NICK="+cfg.Nick,
152182
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
153183
)
154184
if relayActive {
185
+ go mirrorSessionLoop(ctx, relay, cfg, startedAt)
155186
go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
156187
}
157188
158189
if !isInteractiveTTY() {
159190
cmd.Stdin = os.Stdin
@@ -217,10 +248,324 @@
217248
_ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
218249
}
219250
return err
220251
}
221252
253
+// --- Session mirroring ---
254
+
255
+func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
256
+ sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
257
+ if err != nil {
258
+ return
259
+ }
260
+ _ = tailSessionFile(ctx, sessionPath, func(text string) {
261
+ for _, line := range splitMirrorText(text) {
262
+ if line == "" {
263
+ continue
264
+ }
265
+ _ = relay.Post(ctx, line)
266
+ }
267
+ })
268
+}
269
+
270
+func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
271
+ root, err := claudeSessionsRoot(cfg.TargetCWD)
272
+ if err != nil {
273
+ return "", err
274
+ }
275
+
276
+ ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
277
+ defer cancel()
278
+
279
+ ticker := time.NewTicker(defaultScanInterval)
280
+ defer ticker.Stop()
281
+
282
+ for {
283
+ path, err := findLatestSessionPath(root, cfg.TargetCWD, startedAt.Add(-2*time.Second))
284
+ if err == nil && path != "" {
285
+ return path, nil
286
+ }
287
+ select {
288
+ case <-ctx.Done():
289
+ return "", ctx.Err()
290
+ case <-ticker.C:
291
+ }
292
+ }
293
+}
294
+
295
+// claudeSessionsRoot returns ~/.claude/projects/<sanitized-cwd>/
296
+func claudeSessionsRoot(cwd string) (string, error) {
297
+ home, err := os.UserHomeDir()
298
+ if err != nil {
299
+ return "", err
300
+ }
301
+ sanitized := strings.ReplaceAll(cwd, "/", "-")
302
+ sanitized = strings.TrimLeft(sanitized, "-")
303
+ return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
304
+}
305
+
306
+// findLatestSessionPath finds the most recently modified .jsonl file in root
307
+// that contains an entry with cwd matching targetCWD and timestamp after since.
308
+func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) {
309
+ entries, err := os.ReadDir(root)
310
+ if err != nil {
311
+ return "", err
312
+ }
313
+
314
+ type candidate struct {
315
+ path string
316
+ modTime time.Time
317
+ }
318
+ var candidates []candidate
319
+ for _, e := range entries {
320
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
321
+ continue
322
+ }
323
+ info, err := e.Info()
324
+ if err != nil {
325
+ continue
326
+ }
327
+ if info.ModTime().Before(since) {
328
+ continue
329
+ }
330
+ candidates = append(candidates, candidate{
331
+ path: filepath.Join(root, e.Name()),
332
+ modTime: info.ModTime(),
333
+ })
334
+ }
335
+ if len(candidates) == 0 {
336
+ return "", errors.New("no session files found")
337
+ }
338
+ // Sort newest first.
339
+ sort.Slice(candidates, func(i, j int) bool {
340
+ return candidates[i].modTime.After(candidates[j].modTime)
341
+ })
342
+ // Return the first file that has an entry matching our cwd.
343
+ for _, c := range candidates {
344
+ if matchesSession(c.path, targetCWD, since) {
345
+ return c.path, nil
346
+ }
347
+ }
348
+ return "", errors.New("no matching session found")
349
+}
350
+
351
+// matchesSession peeks at the first few lines of a JSONL file to verify cwd.
352
+func matchesSession(path, targetCWD string, since time.Time) bool {
353
+ f, err := os.Open(path)
354
+ if err != nil {
355
+ return false
356
+ }
357
+ defer f.Close()
358
+
359
+ scanner := bufio.NewScanner(f)
360
+ checked := 0
361
+ for scanner.Scan() && checked < 5 {
362
+ checked++
363
+ var entry claudeSessionEntry
364
+ if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
365
+ continue
366
+ }
367
+ if entry.CWD == "" {
368
+ continue
369
+ }
370
+ return entry.CWD == targetCWD
371
+ }
372
+ return false
373
+}
374
+
375
+func tailSessionFile(ctx context.Context, path string, emit func(string)) error {
376
+ file, err := os.Open(path)
377
+ if err != nil {
378
+ return err
379
+ }
380
+ defer file.Close()
381
+
382
+ if _, err := file.Seek(0, io.SeekEnd); err != nil {
383
+ return err
384
+ }
385
+
386
+ reader := bufio.NewReader(file)
387
+ for {
388
+ line, err := reader.ReadBytes('\n')
389
+ if len(line) > 0 {
390
+ for _, text := range sessionMessages(line) {
391
+ if text != "" {
392
+ emit(text)
393
+ }
394
+ }
395
+ }
396
+ if err == nil {
397
+ continue
398
+ }
399
+ if errors.Is(err, io.EOF) {
400
+ select {
401
+ case <-ctx.Done():
402
+ return nil
403
+ case <-time.After(defaultScanInterval):
404
+ }
405
+ continue
406
+ }
407
+ return err
408
+ }
409
+}
410
+
411
+// sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
412
+func sessionMessages(line []byte) []string {
413
+ var entry claudeSessionEntry
414
+ if err := json.Unmarshal(line, &entry); err != nil {
415
+ return nil
416
+ }
417
+ if entry.Type != "assistant" || entry.Message.Role != "assistant" {
418
+ return nil
419
+ }
420
+
421
+ var out []string
422
+ for _, block := range entry.Message.Content {
423
+ switch block.Type {
424
+ case "text":
425
+ for _, l := range splitMirrorText(block.Text) {
426
+ if l != "" {
427
+ out = append(out, sanitizeSecrets(l))
428
+ }
429
+ }
430
+ case "tool_use":
431
+ if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
432
+ out = append(out, msg)
433
+ }
434
+ // thinking blocks are intentionally skipped — too verbose for IRC
435
+ }
436
+ }
437
+ return out
438
+}
439
+
440
+func summarizeToolUse(name string, inputRaw json.RawMessage) string {
441
+ var input map[string]json.RawMessage
442
+ _ = json.Unmarshal(inputRaw, &input)
443
+
444
+ str := func(key string) string {
445
+ v, ok := input[key]
446
+ if !ok {
447
+ return ""
448
+ }
449
+ var s string
450
+ if err := json.Unmarshal(v, &s); err != nil {
451
+ return strings.Trim(string(v), `"`)
452
+ }
453
+ return s
454
+ }
455
+
456
+ switch name {
457
+ case "Bash":
458
+ cmd := sanitizeSecrets(compactCommand(str("command")))
459
+ if cmd != "" {
460
+ return "› " + cmd
461
+ }
462
+ return "› bash"
463
+ case "Edit":
464
+ if p := str("file_path"); p != "" {
465
+ return "edit " + p
466
+ }
467
+ return "edit"
468
+ case "Write":
469
+ if p := str("file_path"); p != "" {
470
+ return "write " + p
471
+ }
472
+ return "write"
473
+ case "Read":
474
+ if p := str("file_path"); p != "" {
475
+ return "read " + p
476
+ }
477
+ return "read"
478
+ case "Glob":
479
+ if p := str("pattern"); p != "" {
480
+ return "glob " + p
481
+ }
482
+ return "glob"
483
+ case "Grep":
484
+ if p := str("pattern"); p != "" {
485
+ return "grep " + p
486
+ }
487
+ return "grep"
488
+ case "Agent":
489
+ return "spawn agent"
490
+ case "WebFetch":
491
+ if u := str("url"); u != "" {
492
+ return "fetch " + sanitizeSecrets(u)
493
+ }
494
+ return "fetch"
495
+ case "WebSearch":
496
+ if q := str("query"); q != "" {
497
+ return "search " + q
498
+ }
499
+ return "search"
500
+ case "TodoWrite":
501
+ return "update todos"
502
+ case "NotebookEdit":
503
+ if p := str("notebook_path"); p != "" {
504
+ return "edit notebook " + p
505
+ }
506
+ return "edit notebook"
507
+ default:
508
+ if name == "" {
509
+ return ""
510
+ }
511
+ return name
512
+ }
513
+}
514
+
515
+func compactCommand(cmd string) string {
516
+ trimmed := strings.TrimSpace(cmd)
517
+ trimmed = strings.Join(strings.Fields(trimmed), " ")
518
+ if strings.HasPrefix(trimmed, "cd ") {
519
+ if idx := strings.Index(trimmed, " && "); idx > 0 {
520
+ trimmed = strings.TrimSpace(trimmed[idx+4:])
521
+ }
522
+ }
523
+ if len(trimmed) > 140 {
524
+ return trimmed[:140] + "..."
525
+ }
526
+ return trimmed
527
+}
528
+
529
+func sanitizeSecrets(text string) string {
530
+ if text == "" {
531
+ return ""
532
+ }
533
+ text = bearerPattern.ReplaceAllString(text, "${1}[redacted]")
534
+ text = assignTokenPattern.ReplaceAllString(text, "${1}[redacted]")
535
+ text = secretKeyPattern.ReplaceAllString(text, "[redacted]")
536
+ text = secretHexPattern.ReplaceAllString(text, "[redacted]")
537
+ return text
538
+}
539
+
540
+func splitMirrorText(text string) []string {
541
+ clean := strings.ReplaceAll(text, "\r\n", "\n")
542
+ clean = strings.ReplaceAll(clean, "\r", "\n")
543
+ raw := strings.Split(clean, "\n")
544
+ var out []string
545
+ for _, line := range raw {
546
+ line = strings.TrimSpace(line)
547
+ if line == "" {
548
+ continue
549
+ }
550
+ for len(line) > defaultMirrorLineMax {
551
+ cut := strings.LastIndex(line[:defaultMirrorLineMax], " ")
552
+ if cut <= 0 {
553
+ cut = defaultMirrorLineMax
554
+ }
555
+ out = append(out, line[:cut])
556
+ line = strings.TrimSpace(line[cut:])
557
+ }
558
+ if line != "" {
559
+ out = append(out, line)
560
+ }
561
+ }
562
+ return out
563
+}
564
+
565
+// --- Relay input (operator → Claude) ---
566
+
222567
func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
223568
lastSeen := time.Now()
224569
ticker := time.NewTicker(cfg.PollInterval)
225570
defer ticker.Stop()
226571
@@ -361,10 +706,12 @@
361706
sort.Slice(filtered, func(i, j int) bool {
362707
return filtered[i].At.Before(filtered[j].At)
363708
})
364709
return filtered, newest
365710
}
711
+
712
+// --- Config loading ---
366713
367714
func loadConfig(args []string) (config, error) {
368715
fileConfig := readEnvFile(configFilePath())
369716
370717
cfg := config{
@@ -416,11 +763,11 @@
416763
if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
417764
return value
418765
}
419766
home, err := os.UserHomeDir()
420767
if err != nil {
421
- return filepath.Join(".config", "scuttlebot-relay.env") // Fallback
768
+ return filepath.Join(".config", "scuttlebot-relay.env")
422769
}
423770
return filepath.Join(home, ".config", "scuttlebot-relay.env")
424771
}
425772
426773
func readEnvFile(path string) map[string]string {
427774
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -1,18 +1,20 @@
1 package main
2
3 import (
4 "bufio"
5 "context"
 
6 "errors"
7 "fmt"
8 "hash/crc32"
9 "io"
10 "os"
11 "os/exec"
12 "os/signal"
13 "path/filepath"
 
14 "sort"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
@@ -22,20 +24,23 @@
22 "github.com/creack/pty"
23 "golang.org/x/term"
24 )
25
26 const (
27 defaultRelayURL = "http://localhost:8080"
28 defaultIRCAddr = "127.0.0.1:6667"
29 defaultChannel = "general"
30 defaultTransport = sessionrelay.TransportHTTP
31 defaultPollInterval = 2 * time.Second
32 defaultConnectWait = 10 * time.Second
33 defaultInjectDelay = 150 * time.Millisecond
34 defaultBusyWindow = 1500 * time.Millisecond
35 defaultHeartbeat = 60 * time.Second
36 defaultConfigFile = ".config/scuttlebot-relay.env"
 
 
 
37 )
38
39 var serviceBots = map[string]struct{}{
40 "bridge": {},
41 "oracle": {},
@@ -47,10 +52,17 @@
47 "herald": {},
48 "scroll": {},
49 "systembot": {},
50 "auditbot": {},
51 }
 
 
 
 
 
 
 
52
53 type config struct {
54 ClaudeBin string
55 ConfigFile string
56 Transport sessionrelay.Transport
@@ -75,10 +87,27 @@
75
76 type relayState struct {
77 mu sync.RWMutex
78 lastBusy time.Time
79 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
81 func main() {
82 cfg, err := loadConfig(os.Args[1:])
83 if err != nil {
84 fmt.Fprintln(os.Stderr, "claude-relay:", err)
@@ -138,10 +167,11 @@
138 defer closeCancel()
139 _ = relay.Close(closeCtx)
140 }()
141 }
142
 
143 cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
144 cmd.Env = append(os.Environ(),
145 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
146 "SCUTTLEBOT_URL="+cfg.URL,
147 "SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -150,10 +180,11 @@
150 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
151 "SCUTTLEBOT_NICK="+cfg.Nick,
152 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
153 )
154 if relayActive {
 
155 go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
156 }
157
158 if !isInteractiveTTY() {
159 cmd.Stdin = os.Stdin
@@ -217,10 +248,324 @@
217 _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
218 }
219 return err
220 }
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222 func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
223 lastSeen := time.Now()
224 ticker := time.NewTicker(cfg.PollInterval)
225 defer ticker.Stop()
226
@@ -361,10 +706,12 @@
361 sort.Slice(filtered, func(i, j int) bool {
362 return filtered[i].At.Before(filtered[j].At)
363 })
364 return filtered, newest
365 }
 
 
366
367 func loadConfig(args []string) (config, error) {
368 fileConfig := readEnvFile(configFilePath())
369
370 cfg := config{
@@ -416,11 +763,11 @@
416 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
417 return value
418 }
419 home, err := os.UserHomeDir()
420 if err != nil {
421 return filepath.Join(".config", "scuttlebot-relay.env") // Fallback
422 }
423 return filepath.Join(home, ".config", "scuttlebot-relay.env")
424 }
425
426 func readEnvFile(path string) map[string]string {
427
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -1,18 +1,20 @@
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"
@@ -22,20 +24,23 @@
24 "github.com/creack/pty"
25 "golang.org/x/term"
26 )
27
28 const (
29 defaultRelayURL = "http://localhost:8080"
30 defaultIRCAddr = "127.0.0.1:6667"
31 defaultChannel = "general"
32 defaultTransport = sessionrelay.TransportIRC
33 defaultPollInterval = 2 * time.Second
34 defaultConnectWait = 10 * time.Second
35 defaultInjectDelay = 150 * time.Millisecond
36 defaultBusyWindow = 1500 * time.Millisecond
37 defaultHeartbeat = 60 * time.Second
38 defaultConfigFile = ".config/scuttlebot-relay.env"
39 defaultScanInterval = 250 * time.Millisecond
40 defaultDiscoverWait = 20 * time.Second
41 defaultMirrorLineMax = 360
42 )
43
44 var serviceBots = map[string]struct{}{
45 "bridge": {},
46 "oracle": {},
@@ -47,10 +52,17 @@
52 "herald": {},
53 "scroll": {},
54 "systembot": {},
55 "auditbot": {},
56 }
57
58 var (
59 secretHexPattern = regexp.MustCompile(`\b[a-f0-9]{32,}\b`)
60 secretKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+\b`)
61 bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._:-]+)`)
62 assignTokenPattern = regexp.MustCompile(`(?i)\b([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSPHRASE)[A-Z0-9_]*=)([^ \t"'` + "`" + `]+)`)
63 )
64
65 type config struct {
66 ClaudeBin string
67 ConfigFile string
68 Transport sessionrelay.Transport
@@ -75,10 +87,27 @@
87
88 type relayState struct {
89 mu sync.RWMutex
90 lastBusy time.Time
91 }
92
93 // Claude Code JSONL session entry.
94 type claudeSessionEntry struct {
95 Type string `json:"type"`
96 CWD string `json:"cwd"`
97 Message struct {
98 Role string `json:"role"`
99 Content []struct {
100 Type string `json:"type"`
101 Text string `json:"text"`
102 Name string `json:"name"`
103 Input json.RawMessage `json:"input"`
104 } `json:"content"`
105 } `json:"message"`
106 Timestamp string `json:"timestamp"`
107 SessionID string `json:"sessionId"`
108 }
109
110 func main() {
111 cfg, err := loadConfig(os.Args[1:])
112 if err != nil {
113 fmt.Fprintln(os.Stderr, "claude-relay:", err)
@@ -138,10 +167,11 @@
167 defer closeCancel()
168 _ = relay.Close(closeCtx)
169 }()
170 }
171
172 startedAt := time.Now()
173 cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
174 cmd.Env = append(os.Environ(),
175 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
176 "SCUTTLEBOT_URL="+cfg.URL,
177 "SCUTTLEBOT_TOKEN="+cfg.Token,
@@ -150,10 +180,11 @@
180 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
181 "SCUTTLEBOT_NICK="+cfg.Nick,
182 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
183 )
184 if relayActive {
185 go mirrorSessionLoop(ctx, relay, cfg, startedAt)
186 go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
187 }
188
189 if !isInteractiveTTY() {
190 cmd.Stdin = os.Stdin
@@ -217,10 +248,324 @@
248 _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
249 }
250 return err
251 }
252
253 // --- Session mirroring ---
254
255 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
256 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
257 if err != nil {
258 return
259 }
260 _ = tailSessionFile(ctx, sessionPath, func(text string) {
261 for _, line := range splitMirrorText(text) {
262 if line == "" {
263 continue
264 }
265 _ = relay.Post(ctx, line)
266 }
267 })
268 }
269
270 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
271 root, err := claudeSessionsRoot(cfg.TargetCWD)
272 if err != nil {
273 return "", err
274 }
275
276 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
277 defer cancel()
278
279 ticker := time.NewTicker(defaultScanInterval)
280 defer ticker.Stop()
281
282 for {
283 path, err := findLatestSessionPath(root, cfg.TargetCWD, startedAt.Add(-2*time.Second))
284 if err == nil && path != "" {
285 return path, nil
286 }
287 select {
288 case <-ctx.Done():
289 return "", ctx.Err()
290 case <-ticker.C:
291 }
292 }
293 }
294
295 // claudeSessionsRoot returns ~/.claude/projects/<sanitized-cwd>/
296 func claudeSessionsRoot(cwd string) (string, error) {
297 home, err := os.UserHomeDir()
298 if err != nil {
299 return "", err
300 }
301 sanitized := strings.ReplaceAll(cwd, "/", "-")
302 sanitized = strings.TrimLeft(sanitized, "-")
303 return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
304 }
305
306 // findLatestSessionPath finds the most recently modified .jsonl file in root
307 // that contains an entry with cwd matching targetCWD and timestamp after since.
308 func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) {
309 entries, err := os.ReadDir(root)
310 if err != nil {
311 return "", err
312 }
313
314 type candidate struct {
315 path string
316 modTime time.Time
317 }
318 var candidates []candidate
319 for _, e := range entries {
320 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
321 continue
322 }
323 info, err := e.Info()
324 if err != nil {
325 continue
326 }
327 if info.ModTime().Before(since) {
328 continue
329 }
330 candidates = append(candidates, candidate{
331 path: filepath.Join(root, e.Name()),
332 modTime: info.ModTime(),
333 })
334 }
335 if len(candidates) == 0 {
336 return "", errors.New("no session files found")
337 }
338 // Sort newest first.
339 sort.Slice(candidates, func(i, j int) bool {
340 return candidates[i].modTime.After(candidates[j].modTime)
341 })
342 // Return the first file that has an entry matching our cwd.
343 for _, c := range candidates {
344 if matchesSession(c.path, targetCWD, since) {
345 return c.path, nil
346 }
347 }
348 return "", errors.New("no matching session found")
349 }
350
351 // matchesSession peeks at the first few lines of a JSONL file to verify cwd.
352 func matchesSession(path, targetCWD string, since time.Time) bool {
353 f, err := os.Open(path)
354 if err != nil {
355 return false
356 }
357 defer f.Close()
358
359 scanner := bufio.NewScanner(f)
360 checked := 0
361 for scanner.Scan() && checked < 5 {
362 checked++
363 var entry claudeSessionEntry
364 if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
365 continue
366 }
367 if entry.CWD == "" {
368 continue
369 }
370 return entry.CWD == targetCWD
371 }
372 return false
373 }
374
375 func tailSessionFile(ctx context.Context, path string, emit func(string)) error {
376 file, err := os.Open(path)
377 if err != nil {
378 return err
379 }
380 defer file.Close()
381
382 if _, err := file.Seek(0, io.SeekEnd); err != nil {
383 return err
384 }
385
386 reader := bufio.NewReader(file)
387 for {
388 line, err := reader.ReadBytes('\n')
389 if len(line) > 0 {
390 for _, text := range sessionMessages(line) {
391 if text != "" {
392 emit(text)
393 }
394 }
395 }
396 if err == nil {
397 continue
398 }
399 if errors.Is(err, io.EOF) {
400 select {
401 case <-ctx.Done():
402 return nil
403 case <-time.After(defaultScanInterval):
404 }
405 continue
406 }
407 return err
408 }
409 }
410
411 // sessionMessages parses a Claude Code JSONL line and returns IRC-ready strings.
412 func sessionMessages(line []byte) []string {
413 var entry claudeSessionEntry
414 if err := json.Unmarshal(line, &entry); err != nil {
415 return nil
416 }
417 if entry.Type != "assistant" || entry.Message.Role != "assistant" {
418 return nil
419 }
420
421 var out []string
422 for _, block := range entry.Message.Content {
423 switch block.Type {
424 case "text":
425 for _, l := range splitMirrorText(block.Text) {
426 if l != "" {
427 out = append(out, sanitizeSecrets(l))
428 }
429 }
430 case "tool_use":
431 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
432 out = append(out, msg)
433 }
434 // thinking blocks are intentionally skipped — too verbose for IRC
435 }
436 }
437 return out
438 }
439
440 func summarizeToolUse(name string, inputRaw json.RawMessage) string {
441 var input map[string]json.RawMessage
442 _ = json.Unmarshal(inputRaw, &input)
443
444 str := func(key string) string {
445 v, ok := input[key]
446 if !ok {
447 return ""
448 }
449 var s string
450 if err := json.Unmarshal(v, &s); err != nil {
451 return strings.Trim(string(v), `"`)
452 }
453 return s
454 }
455
456 switch name {
457 case "Bash":
458 cmd := sanitizeSecrets(compactCommand(str("command")))
459 if cmd != "" {
460 return "› " + cmd
461 }
462 return "› bash"
463 case "Edit":
464 if p := str("file_path"); p != "" {
465 return "edit " + p
466 }
467 return "edit"
468 case "Write":
469 if p := str("file_path"); p != "" {
470 return "write " + p
471 }
472 return "write"
473 case "Read":
474 if p := str("file_path"); p != "" {
475 return "read " + p
476 }
477 return "read"
478 case "Glob":
479 if p := str("pattern"); p != "" {
480 return "glob " + p
481 }
482 return "glob"
483 case "Grep":
484 if p := str("pattern"); p != "" {
485 return "grep " + p
486 }
487 return "grep"
488 case "Agent":
489 return "spawn agent"
490 case "WebFetch":
491 if u := str("url"); u != "" {
492 return "fetch " + sanitizeSecrets(u)
493 }
494 return "fetch"
495 case "WebSearch":
496 if q := str("query"); q != "" {
497 return "search " + q
498 }
499 return "search"
500 case "TodoWrite":
501 return "update todos"
502 case "NotebookEdit":
503 if p := str("notebook_path"); p != "" {
504 return "edit notebook " + p
505 }
506 return "edit notebook"
507 default:
508 if name == "" {
509 return ""
510 }
511 return name
512 }
513 }
514
515 func compactCommand(cmd string) string {
516 trimmed := strings.TrimSpace(cmd)
517 trimmed = strings.Join(strings.Fields(trimmed), " ")
518 if strings.HasPrefix(trimmed, "cd ") {
519 if idx := strings.Index(trimmed, " && "); idx > 0 {
520 trimmed = strings.TrimSpace(trimmed[idx+4:])
521 }
522 }
523 if len(trimmed) > 140 {
524 return trimmed[:140] + "..."
525 }
526 return trimmed
527 }
528
529 func sanitizeSecrets(text string) string {
530 if text == "" {
531 return ""
532 }
533 text = bearerPattern.ReplaceAllString(text, "${1}[redacted]")
534 text = assignTokenPattern.ReplaceAllString(text, "${1}[redacted]")
535 text = secretKeyPattern.ReplaceAllString(text, "[redacted]")
536 text = secretHexPattern.ReplaceAllString(text, "[redacted]")
537 return text
538 }
539
540 func splitMirrorText(text string) []string {
541 clean := strings.ReplaceAll(text, "\r\n", "\n")
542 clean = strings.ReplaceAll(clean, "\r", "\n")
543 raw := strings.Split(clean, "\n")
544 var out []string
545 for _, line := range raw {
546 line = strings.TrimSpace(line)
547 if line == "" {
548 continue
549 }
550 for len(line) > defaultMirrorLineMax {
551 cut := strings.LastIndex(line[:defaultMirrorLineMax], " ")
552 if cut <= 0 {
553 cut = defaultMirrorLineMax
554 }
555 out = append(out, line[:cut])
556 line = strings.TrimSpace(line[cut:])
557 }
558 if line != "" {
559 out = append(out, line)
560 }
561 }
562 return out
563 }
564
565 // --- Relay input (operator → Claude) ---
566
567 func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) {
568 lastSeen := time.Now()
569 ticker := time.NewTicker(cfg.PollInterval)
570 defer ticker.Stop()
571
@@ -361,10 +706,12 @@
706 sort.Slice(filtered, func(i, j int) bool {
707 return filtered[i].At.Before(filtered[j].At)
708 })
709 return filtered, newest
710 }
711
712 // --- Config loading ---
713
714 func loadConfig(args []string) (config, error) {
715 fileConfig := readEnvFile(configFilePath())
716
717 cfg := config{
@@ -416,11 +763,11 @@
763 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
764 return value
765 }
766 home, err := os.UserHomeDir()
767 if err != nil {
768 return filepath.Join(".config", "scuttlebot-relay.env")
769 }
770 return filepath.Join(home, ".config", "scuttlebot-relay.env")
771 }
772
773 func readEnvFile(path string) map[string]string {
774

Keyboard Shortcuts

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