ScuttleBot

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

Keyboard Shortcuts

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