ScuttleBot

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

Keyboard Shortcuts

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