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
Commit
fdc70addefa6fe2e8975a7e32b24b44fd5e45f3b7527a72887ae6c7f5160cb52
Parent
5ac549cc530d57e…
1 file changed
+358
-11
+358
-11
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -1,18 +1,20 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bufio" |
| 5 | 5 | "context" |
| 6 | + "encoding/json" | |
| 6 | 7 | "errors" |
| 7 | 8 | "fmt" |
| 8 | 9 | "hash/crc32" |
| 9 | 10 | "io" |
| 10 | 11 | "os" |
| 11 | 12 | "os/exec" |
| 12 | 13 | "os/signal" |
| 13 | 14 | "path/filepath" |
| 15 | + "regexp" | |
| 14 | 16 | "sort" |
| 15 | 17 | "strings" |
| 16 | 18 | "sync" |
| 17 | 19 | "syscall" |
| 18 | 20 | "time" |
| @@ -22,20 +24,23 @@ | ||
| 22 | 24 | "github.com/creack/pty" |
| 23 | 25 | "golang.org/x/term" |
| 24 | 26 | ) |
| 25 | 27 | |
| 26 | 28 | 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 | |
| 37 | 42 | ) |
| 38 | 43 | |
| 39 | 44 | var serviceBots = map[string]struct{}{ |
| 40 | 45 | "bridge": {}, |
| 41 | 46 | "oracle": {}, |
| @@ -47,10 +52,17 @@ | ||
| 47 | 52 | "herald": {}, |
| 48 | 53 | "scroll": {}, |
| 49 | 54 | "systembot": {}, |
| 50 | 55 | "auditbot": {}, |
| 51 | 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 | +) | |
| 52 | 64 | |
| 53 | 65 | type config struct { |
| 54 | 66 | ClaudeBin string |
| 55 | 67 | ConfigFile string |
| 56 | 68 | Transport sessionrelay.Transport |
| @@ -75,10 +87,27 @@ | ||
| 75 | 87 | |
| 76 | 88 | type relayState struct { |
| 77 | 89 | mu sync.RWMutex |
| 78 | 90 | lastBusy time.Time |
| 79 | 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 | +} | |
| 80 | 109 | |
| 81 | 110 | func main() { |
| 82 | 111 | cfg, err := loadConfig(os.Args[1:]) |
| 83 | 112 | if err != nil { |
| 84 | 113 | fmt.Fprintln(os.Stderr, "claude-relay:", err) |
| @@ -138,10 +167,11 @@ | ||
| 138 | 167 | defer closeCancel() |
| 139 | 168 | _ = relay.Close(closeCtx) |
| 140 | 169 | }() |
| 141 | 170 | } |
| 142 | 171 | |
| 172 | + startedAt := time.Now() | |
| 143 | 173 | cmd := exec.Command(cfg.ClaudeBin, cfg.Args...) |
| 144 | 174 | cmd.Env = append(os.Environ(), |
| 145 | 175 | "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile, |
| 146 | 176 | "SCUTTLEBOT_URL="+cfg.URL, |
| 147 | 177 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| @@ -150,10 +180,11 @@ | ||
| 150 | 180 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 151 | 181 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 152 | 182 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 153 | 183 | ) |
| 154 | 184 | if relayActive { |
| 185 | + go mirrorSessionLoop(ctx, relay, cfg, startedAt) | |
| 155 | 186 | go presenceLoop(ctx, relay, cfg.HeartbeatInterval) |
| 156 | 187 | } |
| 157 | 188 | |
| 158 | 189 | if !isInteractiveTTY() { |
| 159 | 190 | cmd.Stdin = os.Stdin |
| @@ -217,10 +248,324 @@ | ||
| 217 | 248 | _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode)) |
| 218 | 249 | } |
| 219 | 250 | return err |
| 220 | 251 | } |
| 221 | 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 | + | |
| 222 | 567 | func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) { |
| 223 | 568 | lastSeen := time.Now() |
| 224 | 569 | ticker := time.NewTicker(cfg.PollInterval) |
| 225 | 570 | defer ticker.Stop() |
| 226 | 571 | |
| @@ -361,10 +706,12 @@ | ||
| 361 | 706 | sort.Slice(filtered, func(i, j int) bool { |
| 362 | 707 | return filtered[i].At.Before(filtered[j].At) |
| 363 | 708 | }) |
| 364 | 709 | return filtered, newest |
| 365 | 710 | } |
| 711 | + | |
| 712 | +// --- Config loading --- | |
| 366 | 713 | |
| 367 | 714 | func loadConfig(args []string) (config, error) { |
| 368 | 715 | fileConfig := readEnvFile(configFilePath()) |
| 369 | 716 | |
| 370 | 717 | cfg := config{ |
| @@ -416,11 +763,11 @@ | ||
| 416 | 763 | if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" { |
| 417 | 764 | return value |
| 418 | 765 | } |
| 419 | 766 | home, err := os.UserHomeDir() |
| 420 | 767 | if err != nil { |
| 421 | - return filepath.Join(".config", "scuttlebot-relay.env") // Fallback | |
| 768 | + return filepath.Join(".config", "scuttlebot-relay.env") | |
| 422 | 769 | } |
| 423 | 770 | return filepath.Join(home, ".config", "scuttlebot-relay.env") |
| 424 | 771 | } |
| 425 | 772 | |
| 426 | 773 | func readEnvFile(path string) map[string]string { |
| 427 | 774 |
| --- 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 |