ScuttleBot
Add shared session relay transports
Commit
24a217e9a4c108faed34ad2236c4daaf965690d2b0fe60735c3152155e580c61
Parent
50baf1a5833ef0a…
18 files changed
+111
-97
+5
-5
+29
+62
+50
-9
+119
-3
+55
+51
+108
+3
+168
+3
-1
+13
-1
+9
-2
+14
-2
+44
-2
+24
+8
~
cmd/codex-relay/main.go
~
cmd/codex-relay/main_test.go
~
internal/api/chat.go
+
internal/api/chat_test.go
~
internal/api/server.go
~
internal/bots/bridge/bridge.go
+
internal/bots/bridge/bridge_test.go
+
pkg/sessionrelay/http.go
+
pkg/sessionrelay/irc.go
+
pkg/sessionrelay/sessionrelay.go
+
pkg/sessionrelay/sessionrelay_test.go
~
skills/irc-agent/README.md
~
skills/openai-relay/FLEET.md
~
skills/openai-relay/SKILL.md
~
skills/openai-relay/hooks/README.md
~
skills/openai-relay/install.md
~
skills/openai-relay/scripts/install-codex-relay.sh
~
skills/scuttlebot-relay/ADDING_AGENTS.md
+111
-97
| --- cmd/codex-relay/main.go | ||
| +++ cmd/codex-relay/main.go | ||
| @@ -1,17 +1,15 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bufio" |
| 5 | - "bytes" | |
| 6 | 5 | "context" |
| 7 | 6 | "encoding/json" |
| 8 | 7 | "errors" |
| 9 | 8 | "fmt" |
| 10 | 9 | "hash/crc32" |
| 11 | 10 | "io" |
| 12 | - "net/http" | |
| 13 | 11 | "os" |
| 14 | 12 | "os/exec" |
| 15 | 13 | "os/signal" |
| 16 | 14 | "path/filepath" |
| 17 | 15 | "regexp" |
| @@ -20,21 +18,25 @@ | ||
| 20 | 18 | "sync" |
| 21 | 19 | "syscall" |
| 22 | 20 | "time" |
| 23 | 21 | |
| 24 | 22 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 23 | + "github.com/conflicthq/scuttlebot/pkg/sessionrelay" | |
| 25 | 24 | "github.com/creack/pty" |
| 26 | 25 | "golang.org/x/term" |
| 27 | 26 | ) |
| 28 | 27 | |
| 29 | 28 | const ( |
| 30 | 29 | defaultRelayURL = "http://localhost:8080" |
| 30 | + defaultIRCAddr = "127.0.0.1:6667" | |
| 31 | 31 | defaultChannel = "general" |
| 32 | + defaultTransport = sessionrelay.TransportHTTP | |
| 32 | 33 | defaultPollInterval = 2 * time.Second |
| 34 | + defaultConnectWait = 10 * time.Second | |
| 33 | 35 | defaultInjectDelay = 150 * time.Millisecond |
| 34 | 36 | defaultBusyWindow = 1500 * time.Millisecond |
| 35 | - defaultRequestTimout = 3 * time.Second | |
| 37 | + defaultHeartbeat = 60 * time.Second | |
| 36 | 38 | defaultConfigFile = ".config/scuttlebot-relay.env" |
| 37 | 39 | defaultScanInterval = 250 * time.Millisecond |
| 38 | 40 | defaultDiscoverWait = 20 * time.Second |
| 39 | 41 | defaultMirrorLineMax = 360 |
| 40 | 42 | ) |
| @@ -61,34 +63,29 @@ | ||
| 61 | 63 | ) |
| 62 | 64 | |
| 63 | 65 | type config struct { |
| 64 | 66 | CodexBin string |
| 65 | 67 | ConfigFile string |
| 68 | + Transport sessionrelay.Transport | |
| 66 | 69 | URL string |
| 67 | 70 | Token string |
| 71 | + IRCAddr string | |
| 72 | + IRCPass string | |
| 73 | + IRCAgentType string | |
| 74 | + IRCDeleteOnClose bool | |
| 68 | 75 | Channel string |
| 69 | 76 | SessionID string |
| 70 | 77 | Nick string |
| 71 | 78 | HooksEnabled bool |
| 72 | 79 | InterruptOnMessage bool |
| 73 | 80 | PollInterval time.Duration |
| 81 | + HeartbeatInterval time.Duration | |
| 74 | 82 | TargetCWD string |
| 75 | 83 | Args []string |
| 76 | 84 | } |
| 77 | 85 | |
| 78 | -type relayClient struct { | |
| 79 | - http *http.Client | |
| 80 | - url string | |
| 81 | - token string | |
| 82 | -} | |
| 83 | - | |
| 84 | -type message struct { | |
| 85 | - At string `json:"at"` | |
| 86 | - Nick string `json:"nick"` | |
| 87 | - Text string `json:"text"` | |
| 88 | - Time time.Time | |
| 89 | -} | |
| 86 | +type message = sessionrelay.Message | |
| 90 | 87 | |
| 91 | 88 | type relayState struct { |
| 92 | 89 | mu sync.RWMutex |
| 93 | 90 | lastBusy time.Time |
| 94 | 91 | } |
| @@ -144,23 +141,55 @@ | ||
| 144 | 141 | } |
| 145 | 142 | } |
| 146 | 143 | |
| 147 | 144 | func run(cfg config) error { |
| 148 | 145 | fmt.Fprintf(os.Stderr, "codex-relay: nick %s\n", cfg.Nick) |
| 149 | - relayActive := cfg.HooksEnabled && shouldRelaySession(cfg.Args) | |
| 150 | - | |
| 151 | - client := relayClient{ | |
| 152 | - http: &http.Client{Timeout: defaultRequestTimout}, | |
| 153 | - url: strings.TrimRight(cfg.URL, "/"), | |
| 154 | - token: cfg.Token, | |
| 155 | - } | |
| 156 | - | |
| 157 | - if relayActive { | |
| 158 | - _ = client.postStatus(cfg.Channel, cfg.Nick, fmt.Sprintf( | |
| 159 | - "online in %s; mention %s to interrupt before the next action", | |
| 160 | - filepath.Base(cfg.TargetCWD), cfg.Nick, | |
| 161 | - )) | |
| 146 | + relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args) | |
| 147 | + | |
| 148 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 149 | + defer cancel() | |
| 150 | + | |
| 151 | + var relay sessionrelay.Connector | |
| 152 | + relayActive := false | |
| 153 | + if relayRequested { | |
| 154 | + conn, err := sessionrelay.New(sessionrelay.Config{ | |
| 155 | + Transport: cfg.Transport, | |
| 156 | + URL: cfg.URL, | |
| 157 | + Token: cfg.Token, | |
| 158 | + Channel: cfg.Channel, | |
| 159 | + Nick: cfg.Nick, | |
| 160 | + IRC: sessionrelay.IRCConfig{ | |
| 161 | + Addr: cfg.IRCAddr, | |
| 162 | + Pass: cfg.IRCPass, | |
| 163 | + AgentType: cfg.IRCAgentType, | |
| 164 | + DeleteOnClose: cfg.IRCDeleteOnClose, | |
| 165 | + }, | |
| 166 | + }) | |
| 167 | + if err != nil { | |
| 168 | + fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err) | |
| 169 | + } else { | |
| 170 | + connectCtx, connectCancel := context.WithTimeout(ctx, defaultConnectWait) | |
| 171 | + if err := conn.Connect(connectCtx); err != nil { | |
| 172 | + fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err) | |
| 173 | + _ = conn.Close(context.Background()) | |
| 174 | + } else { | |
| 175 | + relay = conn | |
| 176 | + relayActive = true | |
| 177 | + _ = relay.Post(context.Background(), fmt.Sprintf( | |
| 178 | + "online in %s; mention %s to interrupt before the next action", | |
| 179 | + filepath.Base(cfg.TargetCWD), cfg.Nick, | |
| 180 | + )) | |
| 181 | + } | |
| 182 | + connectCancel() | |
| 183 | + } | |
| 184 | + } | |
| 185 | + if relay != nil { | |
| 186 | + defer func() { | |
| 187 | + closeCtx, closeCancel := context.WithTimeout(context.Background(), defaultConnectWait) | |
| 188 | + defer closeCancel() | |
| 189 | + _ = relay.Close(closeCtx) | |
| 190 | + }() | |
| 162 | 191 | } |
| 163 | 192 | |
| 164 | 193 | cmd := exec.Command(cfg.CodexBin, cfg.Args...) |
| 165 | 194 | startedAt := time.Now() |
| 166 | 195 | cmd.Env = append(os.Environ(), |
| @@ -169,17 +198,15 @@ | ||
| 169 | 198 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 170 | 199 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| 171 | 200 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 172 | 201 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 173 | 202 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 174 | - "SCUTTLEBOT_ACTIVITY_VIA_BROKER=1", | |
| 203 | + "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), | |
| 175 | 204 | ) |
| 176 | - | |
| 177 | - ctx, cancel := context.WithCancel(context.Background()) | |
| 178 | - defer cancel() | |
| 179 | 205 | if relayActive { |
| 180 | - go mirrorSessionLoop(ctx, client, cfg, startedAt) | |
| 206 | + go mirrorSessionLoop(ctx, relay, cfg, startedAt) | |
| 207 | + go presenceLoop(ctx, relay, cfg.HeartbeatInterval) | |
| 181 | 208 | } |
| 182 | 209 | |
| 183 | 210 | if !isInteractiveTTY() { |
| 184 | 211 | cmd.Stdin = os.Stdin |
| 185 | 212 | cmd.Stdout = os.Stdout |
| @@ -186,16 +213,16 @@ | ||
| 186 | 213 | cmd.Stderr = os.Stderr |
| 187 | 214 | err := cmd.Run() |
| 188 | 215 | if err != nil { |
| 189 | 216 | exitCode := exitStatus(err) |
| 190 | 217 | if relayActive { |
| 191 | - _ = client.postStatus(cfg.Channel, cfg.Nick, fmt.Sprintf("offline (exit %d)", exitCode)) | |
| 218 | + _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode)) | |
| 192 | 219 | } |
| 193 | 220 | return err |
| 194 | 221 | } |
| 195 | 222 | if relayActive { |
| 196 | - _ = client.postStatus(cfg.Channel, cfg.Nick, "offline (exit 0)") | |
| 223 | + _ = relay.Post(context.Background(), "offline (exit 0)") | |
| 197 | 224 | } |
| 198 | 225 | return nil |
| 199 | 226 | } |
| 200 | 227 | |
| 201 | 228 | ptmx, err := pty.Start(cmd) |
| @@ -229,34 +256,34 @@ | ||
| 229 | 256 | }() |
| 230 | 257 | go func() { |
| 231 | 258 | copyPTYOutput(ptmx, os.Stdout, state) |
| 232 | 259 | }() |
| 233 | 260 | if relayActive { |
| 234 | - go relayInputLoop(ctx, client, cfg, state, ptmx) | |
| 261 | + go relayInputLoop(ctx, relay, cfg, state, ptmx) | |
| 235 | 262 | } |
| 236 | 263 | |
| 237 | 264 | err = cmd.Wait() |
| 238 | 265 | cancel() |
| 239 | 266 | |
| 240 | 267 | exitCode := exitStatus(err) |
| 241 | 268 | if relayActive { |
| 242 | - _ = client.postStatus(cfg.Channel, cfg.Nick, fmt.Sprintf("offline (exit %d)", exitCode)) | |
| 269 | + _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode)) | |
| 243 | 270 | } |
| 244 | 271 | return err |
| 245 | 272 | } |
| 246 | 273 | |
| 247 | -func relayInputLoop(ctx context.Context, client relayClient, cfg config, state *relayState, ptyFile *os.File) { | |
| 274 | +func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) { | |
| 248 | 275 | lastSeen := time.Now() |
| 249 | 276 | ticker := time.NewTicker(cfg.PollInterval) |
| 250 | 277 | defer ticker.Stop() |
| 251 | 278 | |
| 252 | 279 | for { |
| 253 | 280 | select { |
| 254 | 281 | case <-ctx.Done(): |
| 255 | 282 | return |
| 256 | 283 | case <-ticker.C: |
| 257 | - messages, err := client.fetchMessages(cfg.Channel) | |
| 284 | + messages, err := relay.MessagesSince(ctx, lastSeen) | |
| 258 | 285 | if err != nil { |
| 259 | 286 | continue |
| 260 | 287 | } |
| 261 | 288 | batch, newest := filterMessages(messages, lastSeen, cfg.Nick) |
| 262 | 289 | if len(batch) == 0 { |
| @@ -267,10 +294,27 @@ | ||
| 267 | 294 | return |
| 268 | 295 | } |
| 269 | 296 | } |
| 270 | 297 | } |
| 271 | 298 | } |
| 299 | + | |
| 300 | +func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { | |
| 301 | + if interval <= 0 { | |
| 302 | + return | |
| 303 | + } | |
| 304 | + ticker := time.NewTicker(interval) | |
| 305 | + defer ticker.Stop() | |
| 306 | + | |
| 307 | + for { | |
| 308 | + select { | |
| 309 | + case <-ctx.Done(): | |
| 310 | + return | |
| 311 | + case <-ticker.C: | |
| 312 | + _ = relay.Touch(ctx) | |
| 313 | + } | |
| 314 | + } | |
| 315 | +} | |
| 272 | 316 | |
| 273 | 317 | func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error { |
| 274 | 318 | lines := make([]string, 0, len(batch)) |
| 275 | 319 | for _, msg := range batch { |
| 276 | 320 | text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick) |
| @@ -343,15 +387,15 @@ | ||
| 343 | 387 | |
| 344 | 388 | func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) { |
| 345 | 389 | filtered := make([]message, 0, len(messages)) |
| 346 | 390 | newest := since |
| 347 | 391 | for _, msg := range messages { |
| 348 | - if msg.Time.IsZero() || !msg.Time.After(since) { | |
| 392 | + if msg.At.IsZero() || !msg.At.After(since) { | |
| 349 | 393 | continue |
| 350 | 394 | } |
| 351 | - if msg.Time.After(newest) { | |
| 352 | - newest = msg.Time | |
| 395 | + if msg.At.After(newest) { | |
| 396 | + newest = msg.At | |
| 353 | 397 | } |
| 354 | 398 | if msg.Nick == nick { |
| 355 | 399 | continue |
| 356 | 400 | } |
| 357 | 401 | if _, ok := serviceBots[msg.Nick]; ok { |
| @@ -364,11 +408,11 @@ | ||
| 364 | 408 | continue |
| 365 | 409 | } |
| 366 | 410 | filtered = append(filtered, msg) |
| 367 | 411 | } |
| 368 | 412 | sort.Slice(filtered, func(i, j int) bool { |
| 369 | - return filtered[i].Time.Before(filtered[j].Time) | |
| 413 | + return filtered[i].At.Before(filtered[j].At) | |
| 370 | 414 | }) |
| 371 | 415 | return filtered, newest |
| 372 | 416 | } |
| 373 | 417 | |
| 374 | 418 | func loadConfig(args []string) (config, error) { |
| @@ -375,16 +419,22 @@ | ||
| 375 | 419 | fileConfig := readEnvFile(configFilePath()) |
| 376 | 420 | |
| 377 | 421 | cfg := config{ |
| 378 | 422 | CodexBin: getenvOr(fileConfig, "CODEX_BIN", "codex"), |
| 379 | 423 | ConfigFile: getenvOr(fileConfig, "SCUTTLEBOT_CONFIG_FILE", configFilePath()), |
| 424 | + Transport: sessionrelay.Transport(strings.ToLower(getenvOr(fileConfig, "SCUTTLEBOT_TRANSPORT", string(defaultTransport)))), | |
| 380 | 425 | URL: getenvOr(fileConfig, "SCUTTLEBOT_URL", defaultRelayURL), |
| 381 | 426 | Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""), |
| 427 | + IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr), | |
| 428 | + IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""), | |
| 429 | + IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"), | |
| 430 | + IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true), | |
| 382 | 431 | Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"), |
| 383 | 432 | HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true), |
| 384 | 433 | InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true), |
| 385 | 434 | PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval), |
| 435 | + HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat), | |
| 386 | 436 | Args: append([]string(nil), args...), |
| 387 | 437 | } |
| 388 | 438 | |
| 389 | 439 | target, err := targetCWD(args) |
| 390 | 440 | if err != nil { |
| @@ -408,11 +458,11 @@ | ||
| 408 | 458 | cfg.Nick = sanitize(nick) |
| 409 | 459 | |
| 410 | 460 | if cfg.Channel == "" { |
| 411 | 461 | cfg.Channel = defaultChannel |
| 412 | 462 | } |
| 413 | - if cfg.Token == "" { | |
| 463 | + if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" { | |
| 414 | 464 | cfg.HooksEnabled = false |
| 415 | 465 | } |
| 416 | 466 | return cfg, nil |
| 417 | 467 | } |
| 418 | 468 | |
| @@ -486,10 +536,25 @@ | ||
| 486 | 536 | if err != nil || d <= 0 { |
| 487 | 537 | return fallback |
| 488 | 538 | } |
| 489 | 539 | return d |
| 490 | 540 | } |
| 541 | + | |
| 542 | +func getenvDurationAllowZeroOr(file map[string]string, key string, fallback time.Duration) time.Duration { | |
| 543 | + value := getenvOr(file, key, "") | |
| 544 | + if value == "" { | |
| 545 | + return fallback | |
| 546 | + } | |
| 547 | + if strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) == -1 { | |
| 548 | + value += "s" | |
| 549 | + } | |
| 550 | + d, err := time.ParseDuration(value) | |
| 551 | + if err != nil || d < 0 { | |
| 552 | + return fallback | |
| 553 | + } | |
| 554 | + return d | |
| 555 | +} | |
| 491 | 556 | |
| 492 | 557 | func targetCWD(args []string) (string, error) { |
| 493 | 558 | cwd, err := os.Getwd() |
| 494 | 559 | if err != nil { |
| 495 | 560 | return "", err |
| @@ -543,72 +608,21 @@ | ||
| 543 | 608 | func defaultSessionID(target string) string { |
| 544 | 609 | sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano()))) |
| 545 | 610 | return fmt.Sprintf("%08x", sum) |
| 546 | 611 | } |
| 547 | 612 | |
| 548 | -func (c relayClient) postStatus(channel, nick, text string) error { | |
| 549 | - if c.token == "" { | |
| 550 | - return nil | |
| 551 | - } | |
| 552 | - body, _ := json.Marshal(map[string]string{"nick": nick, "text": text}) | |
| 553 | - req, err := http.NewRequest(http.MethodPost, c.url+"/v1/channels/"+channel+"/messages", bytes.NewReader(body)) | |
| 554 | - if err != nil { | |
| 555 | - return err | |
| 556 | - } | |
| 557 | - req.Header.Set("Authorization", "Bearer "+c.token) | |
| 558 | - req.Header.Set("Content-Type", "application/json") | |
| 559 | - resp, err := c.http.Do(req) | |
| 560 | - if err != nil { | |
| 561 | - return err | |
| 562 | - } | |
| 563 | - defer resp.Body.Close() | |
| 564 | - if resp.StatusCode/100 != 2 { | |
| 565 | - return fmt.Errorf("status post: %s", resp.Status) | |
| 566 | - } | |
| 567 | - return nil | |
| 568 | -} | |
| 569 | - | |
| 570 | -func (c relayClient) fetchMessages(channel string) ([]message, error) { | |
| 571 | - req, err := http.NewRequest(http.MethodGet, c.url+"/v1/channels/"+channel+"/messages", nil) | |
| 572 | - if err != nil { | |
| 573 | - return nil, err | |
| 574 | - } | |
| 575 | - req.Header.Set("Authorization", "Bearer "+c.token) | |
| 576 | - resp, err := c.http.Do(req) | |
| 577 | - if err != nil { | |
| 578 | - return nil, err | |
| 579 | - } | |
| 580 | - defer resp.Body.Close() | |
| 581 | - if resp.StatusCode/100 != 2 { | |
| 582 | - return nil, fmt.Errorf("message fetch: %s", resp.Status) | |
| 583 | - } | |
| 584 | - var payload struct { | |
| 585 | - Messages []message `json:"messages"` | |
| 586 | - } | |
| 587 | - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { | |
| 588 | - return nil, err | |
| 589 | - } | |
| 590 | - for i := range payload.Messages { | |
| 591 | - ts, err := time.Parse(time.RFC3339Nano, payload.Messages[i].At) | |
| 592 | - if err == nil { | |
| 593 | - payload.Messages[i].Time = ts | |
| 594 | - } | |
| 595 | - } | |
| 596 | - return payload.Messages, nil | |
| 597 | -} | |
| 598 | - | |
| 599 | -func mirrorSessionLoop(ctx context.Context, client relayClient, cfg config, startedAt time.Time) { | |
| 613 | +func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) { | |
| 600 | 614 | sessionPath, err := discoverSessionPath(ctx, cfg, startedAt) |
| 601 | 615 | if err != nil { |
| 602 | 616 | return |
| 603 | 617 | } |
| 604 | 618 | _ = tailSessionFile(ctx, sessionPath, func(text string) { |
| 605 | 619 | for _, line := range splitMirrorText(text) { |
| 606 | 620 | if line == "" { |
| 607 | 621 | continue |
| 608 | 622 | } |
| 609 | - _ = client.postStatus(cfg.Channel, cfg.Nick, line) | |
| 623 | + _ = relay.Post(ctx, line) | |
| 610 | 624 | } |
| 611 | 625 | }) |
| 612 | 626 | } |
| 613 | 627 | |
| 614 | 628 | func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) { |
| 615 | 629 |
| --- cmd/codex-relay/main.go | |
| +++ cmd/codex-relay/main.go | |
| @@ -1,17 +1,15 @@ | |
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "hash/crc32" |
| 11 | "io" |
| 12 | "net/http" |
| 13 | "os" |
| 14 | "os/exec" |
| 15 | "os/signal" |
| 16 | "path/filepath" |
| 17 | "regexp" |
| @@ -20,21 +18,25 @@ | |
| 20 | "sync" |
| 21 | "syscall" |
| 22 | "time" |
| 23 | |
| 24 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 25 | "github.com/creack/pty" |
| 26 | "golang.org/x/term" |
| 27 | ) |
| 28 | |
| 29 | const ( |
| 30 | defaultRelayURL = "http://localhost:8080" |
| 31 | defaultChannel = "general" |
| 32 | defaultPollInterval = 2 * time.Second |
| 33 | defaultInjectDelay = 150 * time.Millisecond |
| 34 | defaultBusyWindow = 1500 * time.Millisecond |
| 35 | defaultRequestTimout = 3 * time.Second |
| 36 | defaultConfigFile = ".config/scuttlebot-relay.env" |
| 37 | defaultScanInterval = 250 * time.Millisecond |
| 38 | defaultDiscoverWait = 20 * time.Second |
| 39 | defaultMirrorLineMax = 360 |
| 40 | ) |
| @@ -61,34 +63,29 @@ | |
| 61 | ) |
| 62 | |
| 63 | type config struct { |
| 64 | CodexBin string |
| 65 | ConfigFile string |
| 66 | URL string |
| 67 | Token string |
| 68 | Channel string |
| 69 | SessionID string |
| 70 | Nick string |
| 71 | HooksEnabled bool |
| 72 | InterruptOnMessage bool |
| 73 | PollInterval time.Duration |
| 74 | TargetCWD string |
| 75 | Args []string |
| 76 | } |
| 77 | |
| 78 | type relayClient struct { |
| 79 | http *http.Client |
| 80 | url string |
| 81 | token string |
| 82 | } |
| 83 | |
| 84 | type message struct { |
| 85 | At string `json:"at"` |
| 86 | Nick string `json:"nick"` |
| 87 | Text string `json:"text"` |
| 88 | Time time.Time |
| 89 | } |
| 90 | |
| 91 | type relayState struct { |
| 92 | mu sync.RWMutex |
| 93 | lastBusy time.Time |
| 94 | } |
| @@ -144,23 +141,55 @@ | |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func run(cfg config) error { |
| 148 | fmt.Fprintf(os.Stderr, "codex-relay: nick %s\n", cfg.Nick) |
| 149 | relayActive := cfg.HooksEnabled && shouldRelaySession(cfg.Args) |
| 150 | |
| 151 | client := relayClient{ |
| 152 | http: &http.Client{Timeout: defaultRequestTimout}, |
| 153 | url: strings.TrimRight(cfg.URL, "/"), |
| 154 | token: cfg.Token, |
| 155 | } |
| 156 | |
| 157 | if relayActive { |
| 158 | _ = client.postStatus(cfg.Channel, cfg.Nick, fmt.Sprintf( |
| 159 | "online in %s; mention %s to interrupt before the next action", |
| 160 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 161 | )) |
| 162 | } |
| 163 | |
| 164 | cmd := exec.Command(cfg.CodexBin, cfg.Args...) |
| 165 | startedAt := time.Now() |
| 166 | cmd.Env = append(os.Environ(), |
| @@ -169,17 +198,15 @@ | |
| 169 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 170 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| 171 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 172 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 173 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 174 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER=1", |
| 175 | ) |
| 176 | |
| 177 | ctx, cancel := context.WithCancel(context.Background()) |
| 178 | defer cancel() |
| 179 | if relayActive { |
| 180 | go mirrorSessionLoop(ctx, client, cfg, startedAt) |
| 181 | } |
| 182 | |
| 183 | if !isInteractiveTTY() { |
| 184 | cmd.Stdin = os.Stdin |
| 185 | cmd.Stdout = os.Stdout |
| @@ -186,16 +213,16 @@ | |
| 186 | cmd.Stderr = os.Stderr |
| 187 | err := cmd.Run() |
| 188 | if err != nil { |
| 189 | exitCode := exitStatus(err) |
| 190 | if relayActive { |
| 191 | _ = client.postStatus(cfg.Channel, cfg.Nick, fmt.Sprintf("offline (exit %d)", exitCode)) |
| 192 | } |
| 193 | return err |
| 194 | } |
| 195 | if relayActive { |
| 196 | _ = client.postStatus(cfg.Channel, cfg.Nick, "offline (exit 0)") |
| 197 | } |
| 198 | return nil |
| 199 | } |
| 200 | |
| 201 | ptmx, err := pty.Start(cmd) |
| @@ -229,34 +256,34 @@ | |
| 229 | }() |
| 230 | go func() { |
| 231 | copyPTYOutput(ptmx, os.Stdout, state) |
| 232 | }() |
| 233 | if relayActive { |
| 234 | go relayInputLoop(ctx, client, cfg, state, ptmx) |
| 235 | } |
| 236 | |
| 237 | err = cmd.Wait() |
| 238 | cancel() |
| 239 | |
| 240 | exitCode := exitStatus(err) |
| 241 | if relayActive { |
| 242 | _ = client.postStatus(cfg.Channel, cfg.Nick, fmt.Sprintf("offline (exit %d)", exitCode)) |
| 243 | } |
| 244 | return err |
| 245 | } |
| 246 | |
| 247 | func relayInputLoop(ctx context.Context, client relayClient, cfg config, state *relayState, ptyFile *os.File) { |
| 248 | lastSeen := time.Now() |
| 249 | ticker := time.NewTicker(cfg.PollInterval) |
| 250 | defer ticker.Stop() |
| 251 | |
| 252 | for { |
| 253 | select { |
| 254 | case <-ctx.Done(): |
| 255 | return |
| 256 | case <-ticker.C: |
| 257 | messages, err := client.fetchMessages(cfg.Channel) |
| 258 | if err != nil { |
| 259 | continue |
| 260 | } |
| 261 | batch, newest := filterMessages(messages, lastSeen, cfg.Nick) |
| 262 | if len(batch) == 0 { |
| @@ -267,10 +294,27 @@ | |
| 267 | return |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error { |
| 274 | lines := make([]string, 0, len(batch)) |
| 275 | for _, msg := range batch { |
| 276 | text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick) |
| @@ -343,15 +387,15 @@ | |
| 343 | |
| 344 | func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) { |
| 345 | filtered := make([]message, 0, len(messages)) |
| 346 | newest := since |
| 347 | for _, msg := range messages { |
| 348 | if msg.Time.IsZero() || !msg.Time.After(since) { |
| 349 | continue |
| 350 | } |
| 351 | if msg.Time.After(newest) { |
| 352 | newest = msg.Time |
| 353 | } |
| 354 | if msg.Nick == nick { |
| 355 | continue |
| 356 | } |
| 357 | if _, ok := serviceBots[msg.Nick]; ok { |
| @@ -364,11 +408,11 @@ | |
| 364 | continue |
| 365 | } |
| 366 | filtered = append(filtered, msg) |
| 367 | } |
| 368 | sort.Slice(filtered, func(i, j int) bool { |
| 369 | return filtered[i].Time.Before(filtered[j].Time) |
| 370 | }) |
| 371 | return filtered, newest |
| 372 | } |
| 373 | |
| 374 | func loadConfig(args []string) (config, error) { |
| @@ -375,16 +419,22 @@ | |
| 375 | fileConfig := readEnvFile(configFilePath()) |
| 376 | |
| 377 | cfg := config{ |
| 378 | CodexBin: getenvOr(fileConfig, "CODEX_BIN", "codex"), |
| 379 | ConfigFile: getenvOr(fileConfig, "SCUTTLEBOT_CONFIG_FILE", configFilePath()), |
| 380 | URL: getenvOr(fileConfig, "SCUTTLEBOT_URL", defaultRelayURL), |
| 381 | Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""), |
| 382 | Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"), |
| 383 | HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true), |
| 384 | InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true), |
| 385 | PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval), |
| 386 | Args: append([]string(nil), args...), |
| 387 | } |
| 388 | |
| 389 | target, err := targetCWD(args) |
| 390 | if err != nil { |
| @@ -408,11 +458,11 @@ | |
| 408 | cfg.Nick = sanitize(nick) |
| 409 | |
| 410 | if cfg.Channel == "" { |
| 411 | cfg.Channel = defaultChannel |
| 412 | } |
| 413 | if cfg.Token == "" { |
| 414 | cfg.HooksEnabled = false |
| 415 | } |
| 416 | return cfg, nil |
| 417 | } |
| 418 | |
| @@ -486,10 +536,25 @@ | |
| 486 | if err != nil || d <= 0 { |
| 487 | return fallback |
| 488 | } |
| 489 | return d |
| 490 | } |
| 491 | |
| 492 | func targetCWD(args []string) (string, error) { |
| 493 | cwd, err := os.Getwd() |
| 494 | if err != nil { |
| 495 | return "", err |
| @@ -543,72 +608,21 @@ | |
| 543 | func defaultSessionID(target string) string { |
| 544 | sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano()))) |
| 545 | return fmt.Sprintf("%08x", sum) |
| 546 | } |
| 547 | |
| 548 | func (c relayClient) postStatus(channel, nick, text string) error { |
| 549 | if c.token == "" { |
| 550 | return nil |
| 551 | } |
| 552 | body, _ := json.Marshal(map[string]string{"nick": nick, "text": text}) |
| 553 | req, err := http.NewRequest(http.MethodPost, c.url+"/v1/channels/"+channel+"/messages", bytes.NewReader(body)) |
| 554 | if err != nil { |
| 555 | return err |
| 556 | } |
| 557 | req.Header.Set("Authorization", "Bearer "+c.token) |
| 558 | req.Header.Set("Content-Type", "application/json") |
| 559 | resp, err := c.http.Do(req) |
| 560 | if err != nil { |
| 561 | return err |
| 562 | } |
| 563 | defer resp.Body.Close() |
| 564 | if resp.StatusCode/100 != 2 { |
| 565 | return fmt.Errorf("status post: %s", resp.Status) |
| 566 | } |
| 567 | return nil |
| 568 | } |
| 569 | |
| 570 | func (c relayClient) fetchMessages(channel string) ([]message, error) { |
| 571 | req, err := http.NewRequest(http.MethodGet, c.url+"/v1/channels/"+channel+"/messages", nil) |
| 572 | if err != nil { |
| 573 | return nil, err |
| 574 | } |
| 575 | req.Header.Set("Authorization", "Bearer "+c.token) |
| 576 | resp, err := c.http.Do(req) |
| 577 | if err != nil { |
| 578 | return nil, err |
| 579 | } |
| 580 | defer resp.Body.Close() |
| 581 | if resp.StatusCode/100 != 2 { |
| 582 | return nil, fmt.Errorf("message fetch: %s", resp.Status) |
| 583 | } |
| 584 | var payload struct { |
| 585 | Messages []message `json:"messages"` |
| 586 | } |
| 587 | if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { |
| 588 | return nil, err |
| 589 | } |
| 590 | for i := range payload.Messages { |
| 591 | ts, err := time.Parse(time.RFC3339Nano, payload.Messages[i].At) |
| 592 | if err == nil { |
| 593 | payload.Messages[i].Time = ts |
| 594 | } |
| 595 | } |
| 596 | return payload.Messages, nil |
| 597 | } |
| 598 | |
| 599 | func mirrorSessionLoop(ctx context.Context, client relayClient, cfg config, startedAt time.Time) { |
| 600 | sessionPath, err := discoverSessionPath(ctx, cfg, startedAt) |
| 601 | if err != nil { |
| 602 | return |
| 603 | } |
| 604 | _ = tailSessionFile(ctx, sessionPath, func(text string) { |
| 605 | for _, line := range splitMirrorText(text) { |
| 606 | if line == "" { |
| 607 | continue |
| 608 | } |
| 609 | _ = client.postStatus(cfg.Channel, cfg.Nick, line) |
| 610 | } |
| 611 | }) |
| 612 | } |
| 613 | |
| 614 | func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) { |
| 615 |
| --- cmd/codex-relay/main.go | |
| +++ cmd/codex-relay/main.go | |
| @@ -1,17 +1,15 @@ | |
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "hash/crc32" |
| 10 | "io" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "os/signal" |
| 14 | "path/filepath" |
| 15 | "regexp" |
| @@ -20,21 +18,25 @@ | |
| 18 | "sync" |
| 19 | "syscall" |
| 20 | "time" |
| 21 | |
| 22 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 23 | "github.com/conflicthq/scuttlebot/pkg/sessionrelay" |
| 24 | "github.com/creack/pty" |
| 25 | "golang.org/x/term" |
| 26 | ) |
| 27 | |
| 28 | const ( |
| 29 | defaultRelayURL = "http://localhost:8080" |
| 30 | defaultIRCAddr = "127.0.0.1:6667" |
| 31 | defaultChannel = "general" |
| 32 | defaultTransport = sessionrelay.TransportHTTP |
| 33 | defaultPollInterval = 2 * time.Second |
| 34 | defaultConnectWait = 10 * time.Second |
| 35 | defaultInjectDelay = 150 * time.Millisecond |
| 36 | defaultBusyWindow = 1500 * time.Millisecond |
| 37 | defaultHeartbeat = 60 * time.Second |
| 38 | defaultConfigFile = ".config/scuttlebot-relay.env" |
| 39 | defaultScanInterval = 250 * time.Millisecond |
| 40 | defaultDiscoverWait = 20 * time.Second |
| 41 | defaultMirrorLineMax = 360 |
| 42 | ) |
| @@ -61,34 +63,29 @@ | |
| 63 | ) |
| 64 | |
| 65 | type config struct { |
| 66 | CodexBin string |
| 67 | ConfigFile string |
| 68 | Transport sessionrelay.Transport |
| 69 | URL string |
| 70 | Token string |
| 71 | IRCAddr string |
| 72 | IRCPass string |
| 73 | IRCAgentType string |
| 74 | IRCDeleteOnClose bool |
| 75 | Channel string |
| 76 | SessionID string |
| 77 | Nick string |
| 78 | HooksEnabled bool |
| 79 | InterruptOnMessage bool |
| 80 | PollInterval time.Duration |
| 81 | HeartbeatInterval time.Duration |
| 82 | TargetCWD string |
| 83 | Args []string |
| 84 | } |
| 85 | |
| 86 | type message = sessionrelay.Message |
| 87 | |
| 88 | type relayState struct { |
| 89 | mu sync.RWMutex |
| 90 | lastBusy time.Time |
| 91 | } |
| @@ -144,23 +141,55 @@ | |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func run(cfg config) error { |
| 145 | fmt.Fprintf(os.Stderr, "codex-relay: nick %s\n", cfg.Nick) |
| 146 | relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args) |
| 147 | |
| 148 | ctx, cancel := context.WithCancel(context.Background()) |
| 149 | defer cancel() |
| 150 | |
| 151 | var relay sessionrelay.Connector |
| 152 | relayActive := false |
| 153 | if relayRequested { |
| 154 | conn, err := sessionrelay.New(sessionrelay.Config{ |
| 155 | Transport: cfg.Transport, |
| 156 | URL: cfg.URL, |
| 157 | Token: cfg.Token, |
| 158 | Channel: cfg.Channel, |
| 159 | Nick: cfg.Nick, |
| 160 | IRC: sessionrelay.IRCConfig{ |
| 161 | Addr: cfg.IRCAddr, |
| 162 | Pass: cfg.IRCPass, |
| 163 | AgentType: cfg.IRCAgentType, |
| 164 | DeleteOnClose: cfg.IRCDeleteOnClose, |
| 165 | }, |
| 166 | }) |
| 167 | if err != nil { |
| 168 | fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err) |
| 169 | } else { |
| 170 | connectCtx, connectCancel := context.WithTimeout(ctx, defaultConnectWait) |
| 171 | if err := conn.Connect(connectCtx); err != nil { |
| 172 | fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err) |
| 173 | _ = conn.Close(context.Background()) |
| 174 | } else { |
| 175 | relay = conn |
| 176 | relayActive = true |
| 177 | _ = relay.Post(context.Background(), fmt.Sprintf( |
| 178 | "online in %s; mention %s to interrupt before the next action", |
| 179 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 180 | )) |
| 181 | } |
| 182 | connectCancel() |
| 183 | } |
| 184 | } |
| 185 | if relay != nil { |
| 186 | defer func() { |
| 187 | closeCtx, closeCancel := context.WithTimeout(context.Background(), defaultConnectWait) |
| 188 | defer closeCancel() |
| 189 | _ = relay.Close(closeCtx) |
| 190 | }() |
| 191 | } |
| 192 | |
| 193 | cmd := exec.Command(cfg.CodexBin, cfg.Args...) |
| 194 | startedAt := time.Now() |
| 195 | cmd.Env = append(os.Environ(), |
| @@ -169,17 +198,15 @@ | |
| 198 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 199 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| 200 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 201 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 202 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 203 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 204 | ) |
| 205 | if relayActive { |
| 206 | go mirrorSessionLoop(ctx, relay, cfg, startedAt) |
| 207 | go presenceLoop(ctx, relay, cfg.HeartbeatInterval) |
| 208 | } |
| 209 | |
| 210 | if !isInteractiveTTY() { |
| 211 | cmd.Stdin = os.Stdin |
| 212 | cmd.Stdout = os.Stdout |
| @@ -186,16 +213,16 @@ | |
| 213 | cmd.Stderr = os.Stderr |
| 214 | err := cmd.Run() |
| 215 | if err != nil { |
| 216 | exitCode := exitStatus(err) |
| 217 | if relayActive { |
| 218 | _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode)) |
| 219 | } |
| 220 | return err |
| 221 | } |
| 222 | if relayActive { |
| 223 | _ = relay.Post(context.Background(), "offline (exit 0)") |
| 224 | } |
| 225 | return nil |
| 226 | } |
| 227 | |
| 228 | ptmx, err := pty.Start(cmd) |
| @@ -229,34 +256,34 @@ | |
| 256 | }() |
| 257 | go func() { |
| 258 | copyPTYOutput(ptmx, os.Stdout, state) |
| 259 | }() |
| 260 | if relayActive { |
| 261 | go relayInputLoop(ctx, relay, cfg, state, ptmx) |
| 262 | } |
| 263 | |
| 264 | err = cmd.Wait() |
| 265 | cancel() |
| 266 | |
| 267 | exitCode := exitStatus(err) |
| 268 | if relayActive { |
| 269 | _ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode)) |
| 270 | } |
| 271 | return err |
| 272 | } |
| 273 | |
| 274 | func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File) { |
| 275 | lastSeen := time.Now() |
| 276 | ticker := time.NewTicker(cfg.PollInterval) |
| 277 | defer ticker.Stop() |
| 278 | |
| 279 | for { |
| 280 | select { |
| 281 | case <-ctx.Done(): |
| 282 | return |
| 283 | case <-ticker.C: |
| 284 | messages, err := relay.MessagesSince(ctx, lastSeen) |
| 285 | if err != nil { |
| 286 | continue |
| 287 | } |
| 288 | batch, newest := filterMessages(messages, lastSeen, cfg.Nick) |
| 289 | if len(batch) == 0 { |
| @@ -267,10 +294,27 @@ | |
| 294 | return |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 301 | if interval <= 0 { |
| 302 | return |
| 303 | } |
| 304 | ticker := time.NewTicker(interval) |
| 305 | defer ticker.Stop() |
| 306 | |
| 307 | for { |
| 308 | select { |
| 309 | case <-ctx.Done(): |
| 310 | return |
| 311 | case <-ticker.C: |
| 312 | _ = relay.Touch(ctx) |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error { |
| 318 | lines := make([]string, 0, len(batch)) |
| 319 | for _, msg := range batch { |
| 320 | text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick) |
| @@ -343,15 +387,15 @@ | |
| 387 | |
| 388 | func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) { |
| 389 | filtered := make([]message, 0, len(messages)) |
| 390 | newest := since |
| 391 | for _, msg := range messages { |
| 392 | if msg.At.IsZero() || !msg.At.After(since) { |
| 393 | continue |
| 394 | } |
| 395 | if msg.At.After(newest) { |
| 396 | newest = msg.At |
| 397 | } |
| 398 | if msg.Nick == nick { |
| 399 | continue |
| 400 | } |
| 401 | if _, ok := serviceBots[msg.Nick]; ok { |
| @@ -364,11 +408,11 @@ | |
| 408 | continue |
| 409 | } |
| 410 | filtered = append(filtered, msg) |
| 411 | } |
| 412 | sort.Slice(filtered, func(i, j int) bool { |
| 413 | return filtered[i].At.Before(filtered[j].At) |
| 414 | }) |
| 415 | return filtered, newest |
| 416 | } |
| 417 | |
| 418 | func loadConfig(args []string) (config, error) { |
| @@ -375,16 +419,22 @@ | |
| 419 | fileConfig := readEnvFile(configFilePath()) |
| 420 | |
| 421 | cfg := config{ |
| 422 | CodexBin: getenvOr(fileConfig, "CODEX_BIN", "codex"), |
| 423 | ConfigFile: getenvOr(fileConfig, "SCUTTLEBOT_CONFIG_FILE", configFilePath()), |
| 424 | Transport: sessionrelay.Transport(strings.ToLower(getenvOr(fileConfig, "SCUTTLEBOT_TRANSPORT", string(defaultTransport)))), |
| 425 | URL: getenvOr(fileConfig, "SCUTTLEBOT_URL", defaultRelayURL), |
| 426 | Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""), |
| 427 | IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr), |
| 428 | IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""), |
| 429 | IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"), |
| 430 | IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true), |
| 431 | Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"), |
| 432 | HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true), |
| 433 | InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true), |
| 434 | PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval), |
| 435 | HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat), |
| 436 | Args: append([]string(nil), args...), |
| 437 | } |
| 438 | |
| 439 | target, err := targetCWD(args) |
| 440 | if err != nil { |
| @@ -408,11 +458,11 @@ | |
| 458 | cfg.Nick = sanitize(nick) |
| 459 | |
| 460 | if cfg.Channel == "" { |
| 461 | cfg.Channel = defaultChannel |
| 462 | } |
| 463 | if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" { |
| 464 | cfg.HooksEnabled = false |
| 465 | } |
| 466 | return cfg, nil |
| 467 | } |
| 468 | |
| @@ -486,10 +536,25 @@ | |
| 536 | if err != nil || d <= 0 { |
| 537 | return fallback |
| 538 | } |
| 539 | return d |
| 540 | } |
| 541 | |
| 542 | func getenvDurationAllowZeroOr(file map[string]string, key string, fallback time.Duration) time.Duration { |
| 543 | value := getenvOr(file, key, "") |
| 544 | if value == "" { |
| 545 | return fallback |
| 546 | } |
| 547 | if strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) == -1 { |
| 548 | value += "s" |
| 549 | } |
| 550 | d, err := time.ParseDuration(value) |
| 551 | if err != nil || d < 0 { |
| 552 | return fallback |
| 553 | } |
| 554 | return d |
| 555 | } |
| 556 | |
| 557 | func targetCWD(args []string) (string, error) { |
| 558 | cwd, err := os.Getwd() |
| 559 | if err != nil { |
| 560 | return "", err |
| @@ -543,72 +608,21 @@ | |
| 608 | func defaultSessionID(target string) string { |
| 609 | sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano()))) |
| 610 | return fmt.Sprintf("%08x", sum) |
| 611 | } |
| 612 | |
| 613 | func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) { |
| 614 | sessionPath, err := discoverSessionPath(ctx, cfg, startedAt) |
| 615 | if err != nil { |
| 616 | return |
| 617 | } |
| 618 | _ = tailSessionFile(ctx, sessionPath, func(text string) { |
| 619 | for _, line := range splitMirrorText(text) { |
| 620 | if line == "" { |
| 621 | continue |
| 622 | } |
| 623 | _ = relay.Post(ctx, line) |
| 624 | } |
| 625 | }) |
| 626 | } |
| 627 | |
| 628 | func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) { |
| 629 |
+5
-5
| --- cmd/codex-relay/main_test.go | ||
| +++ cmd/codex-relay/main_test.go | ||
| @@ -14,15 +14,15 @@ | ||
| 14 | 14 | base := time.Date(2026, 3, 31, 21, 0, 0, 0, time.FixedZone("CST", -6*60*60)) |
| 15 | 15 | since := base.Add(-time.Second) |
| 16 | 16 | nick := "codex-scuttlebot-1234" |
| 17 | 17 | |
| 18 | 18 | messages := []message{ |
| 19 | - {Nick: "bridge", Text: "[glengoolie] hello", Time: base}, | |
| 20 | - {Nick: "glengoolie", Text: "ambient chat", Time: base.Add(time.Second)}, | |
| 21 | - {Nick: "codex-otherrepo-9999", Text: "status post", Time: base.Add(2 * time.Second)}, | |
| 22 | - {Nick: "glengoolie", Text: nick + ": check README.md", Time: base.Add(3 * time.Second)}, | |
| 23 | - {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", Time: base.Add(4 * time.Second)}, | |
| 19 | + {Nick: "bridge", Text: "[glengoolie] hello", At: base}, | |
| 20 | + {Nick: "glengoolie", Text: "ambient chat", At: base.Add(time.Second)}, | |
| 21 | + {Nick: "codex-otherrepo-9999", Text: "status post", At: base.Add(2 * time.Second)}, | |
| 22 | + {Nick: "glengoolie", Text: nick + ": check README.md", At: base.Add(3 * time.Second)}, | |
| 23 | + {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", At: base.Add(4 * time.Second)}, | |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | got, newest := filterMessages(messages, since, nick) |
| 27 | 27 | if len(got) != 2 { |
| 28 | 28 | t.Fatalf("len(filterMessages) = %d, want 2", len(got)) |
| 29 | 29 |
| --- cmd/codex-relay/main_test.go | |
| +++ cmd/codex-relay/main_test.go | |
| @@ -14,15 +14,15 @@ | |
| 14 | base := time.Date(2026, 3, 31, 21, 0, 0, 0, time.FixedZone("CST", -6*60*60)) |
| 15 | since := base.Add(-time.Second) |
| 16 | nick := "codex-scuttlebot-1234" |
| 17 | |
| 18 | messages := []message{ |
| 19 | {Nick: "bridge", Text: "[glengoolie] hello", Time: base}, |
| 20 | {Nick: "glengoolie", Text: "ambient chat", Time: base.Add(time.Second)}, |
| 21 | {Nick: "codex-otherrepo-9999", Text: "status post", Time: base.Add(2 * time.Second)}, |
| 22 | {Nick: "glengoolie", Text: nick + ": check README.md", Time: base.Add(3 * time.Second)}, |
| 23 | {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", Time: base.Add(4 * time.Second)}, |
| 24 | } |
| 25 | |
| 26 | got, newest := filterMessages(messages, since, nick) |
| 27 | if len(got) != 2 { |
| 28 | t.Fatalf("len(filterMessages) = %d, want 2", len(got)) |
| 29 |
| --- cmd/codex-relay/main_test.go | |
| +++ cmd/codex-relay/main_test.go | |
| @@ -14,15 +14,15 @@ | |
| 14 | base := time.Date(2026, 3, 31, 21, 0, 0, 0, time.FixedZone("CST", -6*60*60)) |
| 15 | since := base.Add(-time.Second) |
| 16 | nick := "codex-scuttlebot-1234" |
| 17 | |
| 18 | messages := []message{ |
| 19 | {Nick: "bridge", Text: "[glengoolie] hello", At: base}, |
| 20 | {Nick: "glengoolie", Text: "ambient chat", At: base.Add(time.Second)}, |
| 21 | {Nick: "codex-otherrepo-9999", Text: "status post", At: base.Add(2 * time.Second)}, |
| 22 | {Nick: "glengoolie", Text: nick + ": check README.md", At: base.Add(3 * time.Second)}, |
| 23 | {Nick: "glengoolie", Text: nick + ": and inspect bridge.go", At: base.Add(4 * time.Second)}, |
| 24 | } |
| 25 | |
| 26 | got, newest := filterMessages(messages, since, nick) |
| 27 | if len(got) != 2 { |
| 28 | t.Fatalf("len(filterMessages) = %d, want 2", len(got)) |
| 29 |
+29
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -15,10 +15,13 @@ | ||
| 15 | 15 | Channels() []string |
| 16 | 16 | JoinChannel(channel string) |
| 17 | 17 | Messages(channel string) []bridge.Message |
| 18 | 18 | Subscribe(channel string) (<-chan bridge.Message, func()) |
| 19 | 19 | Send(ctx context.Context, channel, text, senderNick string) error |
| 20 | + Stats() bridge.Stats | |
| 21 | + TouchUser(channel, nick string) | |
| 22 | + Users(channel string) []string | |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | 25 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 23 | 26 | channel := "#" + r.PathValue("channel") |
| 24 | 27 | s.bridge.JoinChannel(channel) |
| @@ -59,10 +62,36 @@ | ||
| 59 | 62 | writeError(w, http.StatusInternalServerError, "send failed") |
| 60 | 63 | return |
| 61 | 64 | } |
| 62 | 65 | w.WriteHeader(http.StatusNoContent) |
| 63 | 66 | } |
| 67 | + | |
| 68 | +func (s *Server) handleChannelPresence(w http.ResponseWriter, r *http.Request) { | |
| 69 | + channel := "#" + r.PathValue("channel") | |
| 70 | + var req struct { | |
| 71 | + Nick string `json:"nick"` | |
| 72 | + } | |
| 73 | + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 74 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 75 | + return | |
| 76 | + } | |
| 77 | + if req.Nick == "" { | |
| 78 | + writeError(w, http.StatusBadRequest, "nick is required") | |
| 79 | + return | |
| 80 | + } | |
| 81 | + s.bridge.TouchUser(channel, req.Nick) | |
| 82 | + w.WriteHeader(http.StatusNoContent) | |
| 83 | +} | |
| 84 | + | |
| 85 | +func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { | |
| 86 | + channel := "#" + r.PathValue("channel") | |
| 87 | + users := s.bridge.Users(channel) | |
| 88 | + if users == nil { | |
| 89 | + users = []string{} | |
| 90 | + } | |
| 91 | + writeJSON(w, http.StatusOK, map[string]any{"users": users}) | |
| 92 | +} | |
| 64 | 93 | |
| 65 | 94 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 66 | 95 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 67 | 96 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 68 | 97 | token := r.URL.Query().Get("token") |
| 69 | 98 | |
| 70 | 99 | ADDED internal/api/chat_test.go |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -15,10 +15,13 @@ | |
| 15 | Channels() []string |
| 16 | JoinChannel(channel string) |
| 17 | Messages(channel string) []bridge.Message |
| 18 | Subscribe(channel string) (<-chan bridge.Message, func()) |
| 19 | Send(ctx context.Context, channel, text, senderNick string) error |
| 20 | } |
| 21 | |
| 22 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 23 | channel := "#" + r.PathValue("channel") |
| 24 | s.bridge.JoinChannel(channel) |
| @@ -59,10 +62,36 @@ | |
| 59 | writeError(w, http.StatusInternalServerError, "send failed") |
| 60 | return |
| 61 | } |
| 62 | w.WriteHeader(http.StatusNoContent) |
| 63 | } |
| 64 | |
| 65 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 66 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 67 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 68 | token := r.URL.Query().Get("token") |
| 69 | |
| 70 | DDED internal/api/chat_test.go |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -15,10 +15,13 @@ | |
| 15 | Channels() []string |
| 16 | JoinChannel(channel string) |
| 17 | Messages(channel string) []bridge.Message |
| 18 | Subscribe(channel string) (<-chan bridge.Message, func()) |
| 19 | Send(ctx context.Context, channel, text, senderNick string) error |
| 20 | Stats() bridge.Stats |
| 21 | TouchUser(channel, nick string) |
| 22 | Users(channel string) []string |
| 23 | } |
| 24 | |
| 25 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 26 | channel := "#" + r.PathValue("channel") |
| 27 | s.bridge.JoinChannel(channel) |
| @@ -59,10 +62,36 @@ | |
| 62 | writeError(w, http.StatusInternalServerError, "send failed") |
| 63 | return |
| 64 | } |
| 65 | w.WriteHeader(http.StatusNoContent) |
| 66 | } |
| 67 | |
| 68 | func (s *Server) handleChannelPresence(w http.ResponseWriter, r *http.Request) { |
| 69 | channel := "#" + r.PathValue("channel") |
| 70 | var req struct { |
| 71 | Nick string `json:"nick"` |
| 72 | } |
| 73 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 74 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 75 | return |
| 76 | } |
| 77 | if req.Nick == "" { |
| 78 | writeError(w, http.StatusBadRequest, "nick is required") |
| 79 | return |
| 80 | } |
| 81 | s.bridge.TouchUser(channel, req.Nick) |
| 82 | w.WriteHeader(http.StatusNoContent) |
| 83 | } |
| 84 | |
| 85 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 86 | channel := "#" + r.PathValue("channel") |
| 87 | users := s.bridge.Users(channel) |
| 88 | if users == nil { |
| 89 | users = []string{} |
| 90 | } |
| 91 | writeJSON(w, http.StatusOK, map[string]any{"users": users}) |
| 92 | } |
| 93 | |
| 94 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 95 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 96 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 97 | token := r.URL.Query().Get("token") |
| 98 | |
| 99 | DDED internal/api/chat_test.go |
| --- a/internal/api/chat_test.go | ||
| +++ b/internal/api/chat_test.go | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "io" | |
| 8 | + "log/slog" | |
| 9 | + "net/http" | |
| 10 | + "net/http/httptest" | |
| 11 | + "testing" | |
| 12 | + | |
| 13 | + "github.com/confbots/bridge" | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/registry" | |
| 15 | +) | |
| 16 | + | |
| 17 | +type stubChatBridge struct { | |
| 18 | + touched []struct { | |
| 19 | + channel string | |
| 20 | + nick string | |
| 21 | + } | |
| 22 | +} | |
| 23 | + | |
| 24 | +func (b *stubChatBridge) Channels() []string { return nil } | |
| 25 | +func (b *stubChatBridge) JoinChannel(string) {} | |
| 26 | +func (b *stubChatBridge) api | |
| 27 | + | |
| 28 | +import ( | |
| 29 | + "bytes" | |
| 30 | + "context" | |
| 31 | + "encoding/json" | |
| 32 | + "io" | |
| 33 | + "log/slog" | |
| 34 | + "net/http" | |
| 35 | + "net/http/httptest" | |
| 36 | + "testing" | |
| 37 | + | |
| 38 | + "github.com/confbots/bridge" | |
| 39 | + "github.com/conflicthq/scuttlebot/internal/registry" | |
| 40 | +) | |
| 41 | + | |
| 42 | +type stubChatBridge struct { | |
| 43 | + touched []struct { | |
| 44 | + channel string | |
| 45 | + nick string | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +func (b *stubChatBridge) Channels() []string { return nil } | |
| 50 | +func (b *stubChatBridge) JoinChannel(string) {} | |
| 51 | +func (b *stubChatBridge) LeaveChannel(string) {} | |
| 52 | +func (b *stubChatBridge) Messages(string) []bridge.Message { return nil } | |
| 53 | +func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) { | |
| 54 | + return make(chan bridge.Message), func() {} | |
| 55 | +} | |
| 56 | +func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } | |
| 57 | +func (b *stubChatBridge) SendWithMeta(_ context.Contetats() bridg rs(string) []string { return nil } | |
| 58 | +func (b *stubChatBridge) TouchUser(chan bChatBridge) ing) { | |
| 59 | + b.touched package api | |
| 60 | + | |
| 61 | +import ( | |
| 62 | + "byt |
| --- a/internal/api/chat_test.go | |
| +++ b/internal/api/chat_test.go | |
| @@ -0,0 +1,62 @@ | |
| --- a/internal/api/chat_test.go | |
| +++ b/internal/api/chat_test.go | |
| @@ -0,0 +1,62 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "io" |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/confbots/bridge" |
| 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | type stubChatBridge struct { |
| 18 | touched []struct { |
| 19 | channel string |
| 20 | nick string |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | func (b *stubChatBridge) Channels() []string { return nil } |
| 25 | func (b *stubChatBridge) JoinChannel(string) {} |
| 26 | func (b *stubChatBridge) api |
| 27 | |
| 28 | import ( |
| 29 | "bytes" |
| 30 | "context" |
| 31 | "encoding/json" |
| 32 | "io" |
| 33 | "log/slog" |
| 34 | "net/http" |
| 35 | "net/http/httptest" |
| 36 | "testing" |
| 37 | |
| 38 | "github.com/confbots/bridge" |
| 39 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 40 | ) |
| 41 | |
| 42 | type stubChatBridge struct { |
| 43 | touched []struct { |
| 44 | channel string |
| 45 | nick string |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func (b *stubChatBridge) Channels() []string { return nil } |
| 50 | func (b *stubChatBridge) JoinChannel(string) {} |
| 51 | func (b *stubChatBridge) LeaveChannel(string) {} |
| 52 | func (b *stubChatBridge) Messages(string) []bridge.Message { return nil } |
| 53 | func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) { |
| 54 | return make(chan bridge.Message), func() {} |
| 55 | } |
| 56 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 57 | func (b *stubChatBridge) SendWithMeta(_ context.Contetats() bridg rs(string) []string { return nil } |
| 58 | func (b *stubChatBridge) TouchUser(chan bChatBridge) ing) { |
| 59 | b.touched package api |
| 60 | |
| 61 | import ( |
| 62 | "byt |
+50
-9
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -7,53 +7,94 @@ | ||
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | 9 | "log/slog" |
| 10 | 10 | "net/http" |
| 11 | 11 | |
| 12 | + "github.com/conflicthq/scuttlebot/internal/config" | |
| 12 | 13 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 13 | 14 | ) |
| 14 | 15 | |
| 15 | 16 | // Server is the scuttlebot HTTP API server. |
| 16 | 17 | type Server struct { |
| 17 | - registry *registry.Registry | |
| 18 | - tokens map[string]struct{} | |
| 19 | - log *slog.Logger | |
| 20 | - bridge chatBridge // nil if bridge is disabled | |
| 18 | + registry *registry.Registry | |
| 19 | + tokens map[string]struct{} | |
| 20 | + log *slog.Logger | |
| 21 | + bridge chatBridge // nil if bridge is disabled | |
| 22 | + policies *PolicyStore // nil if not configured | |
| 23 | + admins adminStore // nil if not configured | |
| 24 | + llmCfg *config.LLMConfig // nil if no LLM backends configured | |
| 25 | + loginRL *loginRateLimiter | |
| 26 | + tlsDomain string // empty if no TLS | |
| 21 | 27 | } |
| 22 | 28 | |
| 23 | 29 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 24 | -func New(reg *registry.Registry, tokens []string, b chatBridge, log *slog.Logger) *Server { | |
| 30 | +// Pass nil for admins to disable admin authentication endpoints. | |
| 31 | +// Pass nil for llmCfg to disable AI/LLM management endpoints. | |
| 32 | +func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, tlsDomain string, log *slog.Logger) *Server { | |
| 25 | 33 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 26 | 34 | for _, t := range tokens { |
| 27 | 35 | tokenSet[t] = struct{}{} |
| 28 | 36 | } |
| 29 | 37 | return &Server{ |
| 30 | - registry: reg, | |
| 31 | - tokens: tokenSet, | |
| 32 | - log: log, | |
| 33 | - bridge: b, | |
| 38 | + registry: reg, | |
| 39 | + tokens: tokenSet, | |
| 40 | + log: log, | |
| 41 | + bridge: b, | |
| 42 | + policies: ps, | |
| 43 | + admins: admins, | |
| 44 | + llmCfg: llmCfg, | |
| 45 | + loginRL: newLoginRateLimiter(), | |
| 46 | + tlsDomain: tlsDomain, | |
| 34 | 47 | } |
| 35 | 48 | } |
| 36 | 49 | |
| 37 | 50 | // Handler returns the HTTP handler with all routes registered. |
| 38 | 51 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 39 | 52 | func (s *Server) Handler() http.Handler { |
| 40 | 53 | apiMux := http.NewServeMux() |
| 41 | 54 | apiMux.HandleFunc("GET /v1/status", s.handleStatus) |
| 55 | + apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics) | |
| 56 | + if s.policies != nil { | |
| 57 | + apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings) | |
| 58 | + apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies) | |
| 59 | + apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies) | |
| 60 | + } | |
| 42 | 61 | apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) |
| 43 | 62 | apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) |
| 44 | 63 | apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) |
| 45 | 64 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) |
| 65 | + apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt) | |
| 46 | 66 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) |
| 67 | + apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete) | |
| 47 | 68 | if s.bridge != nil { |
| 48 | 69 | apiMux.HandleFunc("GET /v1/channels", s.handleListChannels) |
| 49 | 70 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel) |
| 50 | 71 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages) |
| 51 | 72 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage) |
| 73 | + apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence) | |
| 74 | + apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers) | |
| 75 | + } | |
| 76 | + | |
| 77 | + if s.admins != nil { | |
| 78 | + apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) | |
| 79 | + apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) | |
| 80 | + apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) | |
| 81 | + apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword) | |
| 52 | 82 | } |
| 53 | 83 | |
| 84 | + // LLM / AI gateway endpoints. | |
| 85 | + apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends) | |
| 86 | + apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate) | |
| 87 | + apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate) | |
| 88 | + apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete) | |
| 89 | + apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels) | |
| 90 | + apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover) | |
| 91 | + apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown) | |
| 92 | + apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete) | |
| 93 | + | |
| 54 | 94 | outer := http.NewServeMux() |
| 95 | + outer.HandleFunc("POST /login", s.handleLogin) | |
| 55 | 96 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 56 | 97 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 57 | 98 | }) |
| 58 | 99 | outer.Handle("/ui/", s.uiFileServer()) |
| 59 | 100 | // SSE stream uses ?token= auth (EventSource can't send headers), registered |
| 60 | 101 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -7,53 +7,94 @@ | |
| 7 | |
| 8 | import ( |
| 9 | "log/slog" |
| 10 | "net/http" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 13 | ) |
| 14 | |
| 15 | // Server is the scuttlebot HTTP API server. |
| 16 | type Server struct { |
| 17 | registry *registry.Registry |
| 18 | tokens map[string]struct{} |
| 19 | log *slog.Logger |
| 20 | bridge chatBridge // nil if bridge is disabled |
| 21 | } |
| 22 | |
| 23 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 24 | func New(reg *registry.Registry, tokens []string, b chatBridge, log *slog.Logger) *Server { |
| 25 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 26 | for _, t := range tokens { |
| 27 | tokenSet[t] = struct{}{} |
| 28 | } |
| 29 | return &Server{ |
| 30 | registry: reg, |
| 31 | tokens: tokenSet, |
| 32 | log: log, |
| 33 | bridge: b, |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // Handler returns the HTTP handler with all routes registered. |
| 38 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 39 | func (s *Server) Handler() http.Handler { |
| 40 | apiMux := http.NewServeMux() |
| 41 | apiMux.HandleFunc("GET /v1/status", s.handleStatus) |
| 42 | apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) |
| 43 | apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) |
| 44 | apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) |
| 45 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) |
| 46 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) |
| 47 | if s.bridge != nil { |
| 48 | apiMux.HandleFunc("GET /v1/channels", s.handleListChannels) |
| 49 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel) |
| 50 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages) |
| 51 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage) |
| 52 | } |
| 53 | |
| 54 | outer := http.NewServeMux() |
| 55 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 56 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 57 | }) |
| 58 | outer.Handle("/ui/", s.uiFileServer()) |
| 59 | // SSE stream uses ?token= auth (EventSource can't send headers), registered |
| 60 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -7,53 +7,94 @@ | |
| 7 | |
| 8 | import ( |
| 9 | "log/slog" |
| 10 | "net/http" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/config" |
| 13 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | ) |
| 15 | |
| 16 | // Server is the scuttlebot HTTP API server. |
| 17 | type Server struct { |
| 18 | registry *registry.Registry |
| 19 | tokens map[string]struct{} |
| 20 | log *slog.Logger |
| 21 | bridge chatBridge // nil if bridge is disabled |
| 22 | policies *PolicyStore // nil if not configured |
| 23 | admins adminStore // nil if not configured |
| 24 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| 25 | loginRL *loginRateLimiter |
| 26 | tlsDomain string // empty if no TLS |
| 27 | } |
| 28 | |
| 29 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 30 | // Pass nil for admins to disable admin authentication endpoints. |
| 31 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 32 | func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, tlsDomain string, log *slog.Logger) *Server { |
| 33 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 34 | for _, t := range tokens { |
| 35 | tokenSet[t] = struct{}{} |
| 36 | } |
| 37 | return &Server{ |
| 38 | registry: reg, |
| 39 | tokens: tokenSet, |
| 40 | log: log, |
| 41 | bridge: b, |
| 42 | policies: ps, |
| 43 | admins: admins, |
| 44 | llmCfg: llmCfg, |
| 45 | loginRL: newLoginRateLimiter(), |
| 46 | tlsDomain: tlsDomain, |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // Handler returns the HTTP handler with all routes registered. |
| 51 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 52 | func (s *Server) Handler() http.Handler { |
| 53 | apiMux := http.NewServeMux() |
| 54 | apiMux.HandleFunc("GET /v1/status", s.handleStatus) |
| 55 | apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics) |
| 56 | if s.policies != nil { |
| 57 | apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings) |
| 58 | apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies) |
| 59 | apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies) |
| 60 | } |
| 61 | apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) |
| 62 | apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) |
| 63 | apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) |
| 64 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) |
| 65 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt) |
| 66 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) |
| 67 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete) |
| 68 | if s.bridge != nil { |
| 69 | apiMux.HandleFunc("GET /v1/channels", s.handleListChannels) |
| 70 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel) |
| 71 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages) |
| 72 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage) |
| 73 | apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence) |
| 74 | apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers) |
| 75 | } |
| 76 | |
| 77 | if s.admins != nil { |
| 78 | apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) |
| 79 | apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) |
| 80 | apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) |
| 81 | apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword) |
| 82 | } |
| 83 | |
| 84 | // LLM / AI gateway endpoints. |
| 85 | apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends) |
| 86 | apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate) |
| 87 | apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate) |
| 88 | apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete) |
| 89 | apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels) |
| 90 | apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover) |
| 91 | apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown) |
| 92 | apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete) |
| 93 | |
| 94 | outer := http.NewServeMux() |
| 95 | outer.HandleFunc("POST /login", s.handleLogin) |
| 96 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 97 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 98 | }) |
| 99 | outer.Handle("/ui/", s.uiFileServer()) |
| 100 | // SSE stream uses ?token= auth (EventSource can't send headers), registered |
| 101 |
+119
-3
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -11,16 +11,18 @@ | ||
| 11 | 11 | "log/slog" |
| 12 | 12 | "net" |
| 13 | 13 | "strconv" |
| 14 | 14 | "strings" |
| 15 | 15 | "sync" |
| 16 | + "sync/atomic" | |
| 16 | 17 | "time" |
| 17 | 18 | |
| 18 | 19 | "github.com/lrstanley/girc" |
| 19 | 20 | ) |
| 20 | 21 | |
| 21 | 22 | const botNick = "bridge" |
| 23 | +const defaultWebUserTTL = 5 * time.Minute | |
| 22 | 24 | |
| 23 | 25 | // Message is a single IRC message captured by the bridge. |
| 24 | 26 | type Message struct { |
| 25 | 27 | At time.Time `json:"at"` |
| 26 | 28 | Channel string `json:"channel"` |
| @@ -60,10 +62,17 @@ | ||
| 60 | 62 | n := copy(out, r.msgs[r.head:]) |
| 61 | 63 | copy(out[n:], r.msgs[:r.head]) |
| 62 | 64 | } |
| 63 | 65 | return out |
| 64 | 66 | } |
| 67 | + | |
| 68 | +// Stats is a snapshot of bridge activity. | |
| 69 | +type Stats struct { | |
| 70 | + Channels int `json:"channels"` | |
| 71 | + MessagesTotal int64 `json:"messages_total"` | |
| 72 | + ActiveSubs int `json:"active_subscribers"` | |
| 73 | +} | |
| 65 | 74 | |
| 66 | 75 | // Bot is the IRC bridge bot. |
| 67 | 76 | type Bot struct { |
| 68 | 77 | ircAddr string |
| 69 | 78 | nick string |
| @@ -75,36 +84,59 @@ | ||
| 75 | 84 | mu sync.RWMutex |
| 76 | 85 | buffers map[string]*ringBuf |
| 77 | 86 | subs map[string]map[uint64]chan Message |
| 78 | 87 | subSeq uint64 |
| 79 | 88 | joined map[string]bool |
| 89 | + // webUsers tracks nicks that have posted via the HTTP bridge recently. | |
| 90 | + // channel → nick → last seen time | |
| 91 | + webUsers map[string]map[string]time.Time | |
| 92 | + // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users(). | |
| 93 | + webUserTTL time.Duration | |
| 94 | + | |
| 95 | + msgTotal atomic.Int64 | |
| 80 | 96 | |
| 81 | 97 | joinCh chan string |
| 82 | 98 | client *girc.Client |
| 83 | 99 | } |
| 84 | 100 | |
| 85 | 101 | // New creates a bridge Bot. |
| 86 | -func New(ircAddr, nick, password string, channels []string, bufSize int, log *slog.Logger) *Bot { | |
| 102 | +func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { | |
| 87 | 103 | if nick == "" { |
| 88 | 104 | nick = botNick |
| 89 | 105 | } |
| 90 | 106 | if bufSize <= 0 { |
| 91 | 107 | bufSize = 200 |
| 92 | 108 | } |
| 109 | + if webUserTTL <= 0 { | |
| 110 | + webUserTTL = defaultWebUserTTL | |
| 111 | + } | |
| 93 | 112 | return &Bot{ |
| 94 | 113 | ircAddr: ircAddr, |
| 95 | 114 | nick: nick, |
| 96 | 115 | password: password, |
| 97 | 116 | bufSize: bufSize, |
| 98 | 117 | initChannels: channels, |
| 118 | + webUsers: make(map[string]map[string]time.Time), | |
| 119 | + webUserTTL: webUserTTL, | |
| 99 | 120 | log: log, |
| 100 | 121 | buffers: make(map[string]*ringBuf), |
| 101 | 122 | subs: make(map[string]map[uint64]chan Message), |
| 102 | 123 | joined: make(map[string]bool), |
| 103 | 124 | joinCh: make(chan string, 32), |
| 104 | 125 | } |
| 105 | 126 | } |
| 127 | + | |
| 128 | +// SetWebUserTTL updates how long bridge-posted HTTP nicks remain visible in | |
| 129 | +// the channel user list after their last post. | |
| 130 | +func (b *Bot) SetWebUserTTL(ttl time.Duration) { | |
| 131 | + if ttl <= 0 { | |
| 132 | + ttl = defaultWebUserTTL | |
| 133 | + } | |
| 134 | + b.mu.Lock() | |
| 135 | + b.webUserTTL = ttl | |
| 136 | + b.mu.Unlock() | |
| 137 | +} | |
| 106 | 138 | |
| 107 | 139 | // Name returns the bot's IRC nick. |
| 108 | 140 | func (b *Bot) Name() string { return b.nick } |
| 109 | 141 | |
| 110 | 142 | // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled. |
| @@ -267,22 +299,106 @@ | ||
| 267 | 299 | ircText := text |
| 268 | 300 | if senderNick != "" { |
| 269 | 301 | ircText = "[" + senderNick + "] " + text |
| 270 | 302 | } |
| 271 | 303 | b.client.Cmd.Message(channel, ircText) |
| 304 | + | |
| 305 | + // Track web sender as active in this channel. | |
| 306 | + if senderNick != "" { | |
| 307 | + b.TouchUser(channel, senderNick) | |
| 308 | + } | |
| 309 | + | |
| 272 | 310 | // Buffer the outgoing message immediately (server won't echo it back). |
| 311 | + // Use senderNick so the web UI shows who actually sent it. | |
| 312 | + displayNick := b.nick | |
| 313 | + if senderNick != "" { | |
| 314 | + displayNick = senderNick | |
| 315 | + } | |
| 273 | 316 | b.dispatch(Message{ |
| 274 | 317 | At: time.Now(), |
| 275 | 318 | Channel: channel, |
| 276 | - Nick: b.nick, | |
| 277 | - Text: ircText, | |
| 319 | + Nick: displayNick, | |
| 320 | + Text: text, | |
| 278 | 321 | }) |
| 279 | 322 | return nil |
| 280 | 323 | } |
| 324 | + | |
| 325 | +// TouchUser marks a bridge/web nick as active in the given channel without | |
| 326 | +// sending a visible IRC message. This is used by broker-style local runtimes | |
| 327 | +// to maintain presence in the user list while idle. | |
| 328 | +func (b *Bot) TouchUser(channel, nick string) { | |
| 329 | + if nick == "" { | |
| 330 | + return | |
| 331 | + } | |
| 332 | + b.mu.Lock() | |
| 333 | + if b.webUsers[channel] == nil { | |
| 334 | + b.webUsers[channel] = make(map[string]time.Time) | |
| 335 | + } | |
| 336 | + b.webUsers[channel][nick] = time.Now() | |
| 337 | + b.mu.Unlock() | |
| 338 | +} | |
| 339 | + | |
| 340 | +// Users returns the current nick list for a channel — IRC connections plus | |
| 341 | +// web UI users who have posted recently within the configured TTL. | |
| 342 | +func (b *Bot) Users(channel string) []string { | |
| 343 | + seen := make(map[string]bool) | |
| 344 | + var nicks []string | |
| 345 | + | |
| 346 | + // IRC-connected nicks from NAMES — exclude the bridge bot itself. | |
| 347 | + if b.client != nil { | |
| 348 | + if ch := b.client.LookupChannel(channel); ch != nil { | |
| 349 | + for _, u := range ch.Users(b.client) { | |
| 350 | + if u.Nick == b.nick { | |
| 351 | + continue // skip the bridge bot | |
| 352 | + } | |
| 353 | + if !seen[u.Nick] { | |
| 354 | + seen[u.Nick] = true | |
| 355 | + nicks = append(nicks, u.Nick) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + // Web UI senders active within the configured TTL. Also prune expired nicks | |
| 362 | + // so the bridge doesn't retain dead web-user entries forever. | |
| 363 | + now := time.Now() | |
| 364 | + b.mu.Lock() | |
| 365 | + cutoff := now.Add(-b.webUserTTL) | |
| 366 | + for nick, last := range b.webUsers[channel] { | |
| 367 | + if !last.After(cutoff) { | |
| 368 | + delete(b.webUsers[channel], nick) | |
| 369 | + continue | |
| 370 | + } | |
| 371 | + if !seen[nick] { | |
| 372 | + seen[nick] = true | |
| 373 | + nicks = append(nicks, nick) | |
| 374 | + } | |
| 375 | + } | |
| 376 | + b.mu.Unlock() | |
| 377 | + | |
| 378 | + return nicks | |
| 379 | +} | |
| 380 | + | |
| 381 | +// Stats returns a snapshot of bridge activity. | |
| 382 | +func (b *Bot) Stats() Stats { | |
| 383 | + b.mu.RLock() | |
| 384 | + channels := len(b.joined) | |
| 385 | + subs := 0 | |
| 386 | + for _, m := range b.subs { | |
| 387 | + subs += len(m) | |
| 388 | + } | |
| 389 | + b.mu.RUnlock() | |
| 390 | + return Stats{ | |
| 391 | + Channels: channels, | |
| 392 | + MessagesTotal: b.msgTotal.Load(), | |
| 393 | + ActiveSubs: subs, | |
| 394 | + } | |
| 395 | +} | |
| 281 | 396 | |
| 282 | 397 | // dispatch pushes a message to the ring buffer and fans out to subscribers. |
| 283 | 398 | func (b *Bot) dispatch(msg Message) { |
| 399 | + b.msgTotal.Add(1) | |
| 284 | 400 | b.mu.Lock() |
| 285 | 401 | defer b.mu.Unlock() |
| 286 | 402 | rb := b.buffers[msg.Channel] |
| 287 | 403 | if rb == nil { |
| 288 | 404 | return |
| 289 | 405 | |
| 290 | 406 | ADDED internal/bots/bridge/bridge_test.go |
| 291 | 407 | ADDED pkg/sessionrelay/http.go |
| 292 | 408 | ADDED pkg/sessionrelay/irc.go |
| 293 | 409 | ADDED pkg/sessionrelay/sessionrelay.go |
| 294 | 410 | ADDED pkg/sessionrelay/sessionrelay_test.go |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -11,16 +11,18 @@ | |
| 11 | "log/slog" |
| 12 | "net" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/lrstanley/girc" |
| 19 | ) |
| 20 | |
| 21 | const botNick = "bridge" |
| 22 | |
| 23 | // Message is a single IRC message captured by the bridge. |
| 24 | type Message struct { |
| 25 | At time.Time `json:"at"` |
| 26 | Channel string `json:"channel"` |
| @@ -60,10 +62,17 @@ | |
| 60 | n := copy(out, r.msgs[r.head:]) |
| 61 | copy(out[n:], r.msgs[:r.head]) |
| 62 | } |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | // Bot is the IRC bridge bot. |
| 67 | type Bot struct { |
| 68 | ircAddr string |
| 69 | nick string |
| @@ -75,36 +84,59 @@ | |
| 75 | mu sync.RWMutex |
| 76 | buffers map[string]*ringBuf |
| 77 | subs map[string]map[uint64]chan Message |
| 78 | subSeq uint64 |
| 79 | joined map[string]bool |
| 80 | |
| 81 | joinCh chan string |
| 82 | client *girc.Client |
| 83 | } |
| 84 | |
| 85 | // New creates a bridge Bot. |
| 86 | func New(ircAddr, nick, password string, channels []string, bufSize int, log *slog.Logger) *Bot { |
| 87 | if nick == "" { |
| 88 | nick = botNick |
| 89 | } |
| 90 | if bufSize <= 0 { |
| 91 | bufSize = 200 |
| 92 | } |
| 93 | return &Bot{ |
| 94 | ircAddr: ircAddr, |
| 95 | nick: nick, |
| 96 | password: password, |
| 97 | bufSize: bufSize, |
| 98 | initChannels: channels, |
| 99 | log: log, |
| 100 | buffers: make(map[string]*ringBuf), |
| 101 | subs: make(map[string]map[uint64]chan Message), |
| 102 | joined: make(map[string]bool), |
| 103 | joinCh: make(chan string, 32), |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // Name returns the bot's IRC nick. |
| 108 | func (b *Bot) Name() string { return b.nick } |
| 109 | |
| 110 | // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled. |
| @@ -267,22 +299,106 @@ | |
| 267 | ircText := text |
| 268 | if senderNick != "" { |
| 269 | ircText = "[" + senderNick + "] " + text |
| 270 | } |
| 271 | b.client.Cmd.Message(channel, ircText) |
| 272 | // Buffer the outgoing message immediately (server won't echo it back). |
| 273 | b.dispatch(Message{ |
| 274 | At: time.Now(), |
| 275 | Channel: channel, |
| 276 | Nick: b.nick, |
| 277 | Text: ircText, |
| 278 | }) |
| 279 | return nil |
| 280 | } |
| 281 | |
| 282 | // dispatch pushes a message to the ring buffer and fans out to subscribers. |
| 283 | func (b *Bot) dispatch(msg Message) { |
| 284 | b.mu.Lock() |
| 285 | defer b.mu.Unlock() |
| 286 | rb := b.buffers[msg.Channel] |
| 287 | if rb == nil { |
| 288 | return |
| 289 | |
| 290 | DDED internal/bots/bridge/bridge_test.go |
| 291 | DDED pkg/sessionrelay/http.go |
| 292 | DDED pkg/sessionrelay/irc.go |
| 293 | DDED pkg/sessionrelay/sessionrelay.go |
| 294 | DDED pkg/sessionrelay/sessionrelay_test.go |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -11,16 +11,18 @@ | |
| 11 | "log/slog" |
| 12 | "net" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "sync/atomic" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/lrstanley/girc" |
| 20 | ) |
| 21 | |
| 22 | const botNick = "bridge" |
| 23 | const defaultWebUserTTL = 5 * time.Minute |
| 24 | |
| 25 | // Message is a single IRC message captured by the bridge. |
| 26 | type Message struct { |
| 27 | At time.Time `json:"at"` |
| 28 | Channel string `json:"channel"` |
| @@ -60,10 +62,17 @@ | |
| 62 | n := copy(out, r.msgs[r.head:]) |
| 63 | copy(out[n:], r.msgs[:r.head]) |
| 64 | } |
| 65 | return out |
| 66 | } |
| 67 | |
| 68 | // Stats is a snapshot of bridge activity. |
| 69 | type Stats struct { |
| 70 | Channels int `json:"channels"` |
| 71 | MessagesTotal int64 `json:"messages_total"` |
| 72 | ActiveSubs int `json:"active_subscribers"` |
| 73 | } |
| 74 | |
| 75 | // Bot is the IRC bridge bot. |
| 76 | type Bot struct { |
| 77 | ircAddr string |
| 78 | nick string |
| @@ -75,36 +84,59 @@ | |
| 84 | mu sync.RWMutex |
| 85 | buffers map[string]*ringBuf |
| 86 | subs map[string]map[uint64]chan Message |
| 87 | subSeq uint64 |
| 88 | joined map[string]bool |
| 89 | // webUsers tracks nicks that have posted via the HTTP bridge recently. |
| 90 | // channel → nick → last seen time |
| 91 | webUsers map[string]map[string]time.Time |
| 92 | // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users(). |
| 93 | webUserTTL time.Duration |
| 94 | |
| 95 | msgTotal atomic.Int64 |
| 96 | |
| 97 | joinCh chan string |
| 98 | client *girc.Client |
| 99 | } |
| 100 | |
| 101 | // New creates a bridge Bot. |
| 102 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 103 | if nick == "" { |
| 104 | nick = botNick |
| 105 | } |
| 106 | if bufSize <= 0 { |
| 107 | bufSize = 200 |
| 108 | } |
| 109 | if webUserTTL <= 0 { |
| 110 | webUserTTL = defaultWebUserTTL |
| 111 | } |
| 112 | return &Bot{ |
| 113 | ircAddr: ircAddr, |
| 114 | nick: nick, |
| 115 | password: password, |
| 116 | bufSize: bufSize, |
| 117 | initChannels: channels, |
| 118 | webUsers: make(map[string]map[string]time.Time), |
| 119 | webUserTTL: webUserTTL, |
| 120 | log: log, |
| 121 | buffers: make(map[string]*ringBuf), |
| 122 | subs: make(map[string]map[uint64]chan Message), |
| 123 | joined: make(map[string]bool), |
| 124 | joinCh: make(chan string, 32), |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // SetWebUserTTL updates how long bridge-posted HTTP nicks remain visible in |
| 129 | // the channel user list after their last post. |
| 130 | func (b *Bot) SetWebUserTTL(ttl time.Duration) { |
| 131 | if ttl <= 0 { |
| 132 | ttl = defaultWebUserTTL |
| 133 | } |
| 134 | b.mu.Lock() |
| 135 | b.webUserTTL = ttl |
| 136 | b.mu.Unlock() |
| 137 | } |
| 138 | |
| 139 | // Name returns the bot's IRC nick. |
| 140 | func (b *Bot) Name() string { return b.nick } |
| 141 | |
| 142 | // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled. |
| @@ -267,22 +299,106 @@ | |
| 299 | ircText := text |
| 300 | if senderNick != "" { |
| 301 | ircText = "[" + senderNick + "] " + text |
| 302 | } |
| 303 | b.client.Cmd.Message(channel, ircText) |
| 304 | |
| 305 | // Track web sender as active in this channel. |
| 306 | if senderNick != "" { |
| 307 | b.TouchUser(channel, senderNick) |
| 308 | } |
| 309 | |
| 310 | // Buffer the outgoing message immediately (server won't echo it back). |
| 311 | // Use senderNick so the web UI shows who actually sent it. |
| 312 | displayNick := b.nick |
| 313 | if senderNick != "" { |
| 314 | displayNick = senderNick |
| 315 | } |
| 316 | b.dispatch(Message{ |
| 317 | At: time.Now(), |
| 318 | Channel: channel, |
| 319 | Nick: displayNick, |
| 320 | Text: text, |
| 321 | }) |
| 322 | return nil |
| 323 | } |
| 324 | |
| 325 | // TouchUser marks a bridge/web nick as active in the given channel without |
| 326 | // sending a visible IRC message. This is used by broker-style local runtimes |
| 327 | // to maintain presence in the user list while idle. |
| 328 | func (b *Bot) TouchUser(channel, nick string) { |
| 329 | if nick == "" { |
| 330 | return |
| 331 | } |
| 332 | b.mu.Lock() |
| 333 | if b.webUsers[channel] == nil { |
| 334 | b.webUsers[channel] = make(map[string]time.Time) |
| 335 | } |
| 336 | b.webUsers[channel][nick] = time.Now() |
| 337 | b.mu.Unlock() |
| 338 | } |
| 339 | |
| 340 | // Users returns the current nick list for a channel — IRC connections plus |
| 341 | // web UI users who have posted recently within the configured TTL. |
| 342 | func (b *Bot) Users(channel string) []string { |
| 343 | seen := make(map[string]bool) |
| 344 | var nicks []string |
| 345 | |
| 346 | // IRC-connected nicks from NAMES — exclude the bridge bot itself. |
| 347 | if b.client != nil { |
| 348 | if ch := b.client.LookupChannel(channel); ch != nil { |
| 349 | for _, u := range ch.Users(b.client) { |
| 350 | if u.Nick == b.nick { |
| 351 | continue // skip the bridge bot |
| 352 | } |
| 353 | if !seen[u.Nick] { |
| 354 | seen[u.Nick] = true |
| 355 | nicks = append(nicks, u.Nick) |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // Web UI senders active within the configured TTL. Also prune expired nicks |
| 362 | // so the bridge doesn't retain dead web-user entries forever. |
| 363 | now := time.Now() |
| 364 | b.mu.Lock() |
| 365 | cutoff := now.Add(-b.webUserTTL) |
| 366 | for nick, last := range b.webUsers[channel] { |
| 367 | if !last.After(cutoff) { |
| 368 | delete(b.webUsers[channel], nick) |
| 369 | continue |
| 370 | } |
| 371 | if !seen[nick] { |
| 372 | seen[nick] = true |
| 373 | nicks = append(nicks, nick) |
| 374 | } |
| 375 | } |
| 376 | b.mu.Unlock() |
| 377 | |
| 378 | return nicks |
| 379 | } |
| 380 | |
| 381 | // Stats returns a snapshot of bridge activity. |
| 382 | func (b *Bot) Stats() Stats { |
| 383 | b.mu.RLock() |
| 384 | channels := len(b.joined) |
| 385 | subs := 0 |
| 386 | for _, m := range b.subs { |
| 387 | subs += len(m) |
| 388 | } |
| 389 | b.mu.RUnlock() |
| 390 | return Stats{ |
| 391 | Channels: channels, |
| 392 | MessagesTotal: b.msgTotal.Load(), |
| 393 | ActiveSubs: subs, |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | // dispatch pushes a message to the ring buffer and fans out to subscribers. |
| 398 | func (b *Bot) dispatch(msg Message) { |
| 399 | b.msgTotal.Add(1) |
| 400 | b.mu.Lock() |
| 401 | defer b.mu.Unlock() |
| 402 | rb := b.buffers[msg.Channel] |
| 403 | if rb == nil { |
| 404 | return |
| 405 | |
| 406 | DDED internal/bots/bridge/bridge_test.go |
| 407 | DDED pkg/sessionrelay/http.go |
| 408 | DDED pkg/sessionrelay/irc.go |
| 409 | DDED pkg/sessionrelay/sessionrelay.go |
| 410 | DDED pkg/sessionrelay/sessionrelay_test.go |
| --- a/internal/bots/bridge/bridge_test.go | ||
| +++ b/internal/bots/bridge/bridge_test.go | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +package bridge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "testing" | |
| 5 | + "time" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func TestUsersFiltersAndPrunesExpiredWebUsers(t *testing.T) { | |
| 9 | + b := &Bot{ | |
| 10 | + nick: "bridge", | |
| 11 | + webUserTTL: 5 * time.Minute, | |
| 12 | + webUsers: map[string]map[string]time.Time{ | |
| 13 | + "#general": { | |
| 14 | + "recent-user": time.Now().Add(-2 * time.Minute), | |
| 15 | + "stale-user": time.Now().Add(-10 * time.Minute), | |
| 16 | + }, | |
| 17 | + }, | |
| 18 | + } | |
| 19 | + | |
| 20 | + got := b.Users("#general") | |
| 21 | + if len(got) != 1 || got[0] != "recent-user" { | |
| 22 | + t.Fatalf("Users() = %v, want [recent-user]", got) | |
| 23 | + } | |
| 24 | + | |
| 25 | + if _, ok := b.webUsers["#general"]["stale-user"]; ok { | |
| 26 | + t.Fatalf("stale-user was not pruned from webUsers") | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +func TestSetWebUserTTLDefaultsNonPositiveValues(t *testing.T) { | |
| 31 | + b := &Bot{} | |
| 32 | + | |
| 33 | + b.SetWebUserTTL(0) | |
| 34 | + if b.webUserTTL != defaultWebUserTTL { | |
| 35 | + t.Fatalf("SetWebUserTTL(0) = %v, want %v", b.webUserTTL, defaultWebUserTTL) | |
| 36 | + } | |
| 37 | + | |
| 38 | + b.SetWebUserTTL(-1 * time.Minute) | |
| 39 | + if b.webUserTTL != defaultWebUserTTL { | |
| 40 | + t.Fatalf("SetWebUserTTL(-1m) = %v, want %v", b.webUserTTL, defaultWebUserTTL) | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +func TestTouchUserMarksNickActive(t *testing.T) { | |
| 45 | + b := &Bot{webUsers: make(map[string]map[string]time.Time)} | |
| 46 | + | |
| 47 | + b.TouchUser("#general", "codex-test") | |
| 48 | + | |
| 49 | + if b.webUsers["#general"] == nil { | |
| 50 | + t.Fatal("TouchUser did not initialize channel map") | |
| 51 | + } | |
| 52 | + if _, ok := b.webUsers["#general"]["codex-test"]; !ok { | |
| 53 | + t.Fatal("TouchUser did not record nick") | |
| 54 | + } | |
| 55 | +} |
| --- a/internal/bots/bridge/bridge_test.go | |
| +++ b/internal/bots/bridge/bridge_test.go | |
| @@ -0,0 +1,55 @@ | |
| --- a/internal/bots/bridge/bridge_test.go | |
| +++ b/internal/bots/bridge/bridge_test.go | |
| @@ -0,0 +1,55 @@ | |
| 1 | package bridge |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func TestUsersFiltersAndPrunesExpiredWebUsers(t *testing.T) { |
| 9 | b := &Bot{ |
| 10 | nick: "bridge", |
| 11 | webUserTTL: 5 * time.Minute, |
| 12 | webUsers: map[string]map[string]time.Time{ |
| 13 | "#general": { |
| 14 | "recent-user": time.Now().Add(-2 * time.Minute), |
| 15 | "stale-user": time.Now().Add(-10 * time.Minute), |
| 16 | }, |
| 17 | }, |
| 18 | } |
| 19 | |
| 20 | got := b.Users("#general") |
| 21 | if len(got) != 1 || got[0] != "recent-user" { |
| 22 | t.Fatalf("Users() = %v, want [recent-user]", got) |
| 23 | } |
| 24 | |
| 25 | if _, ok := b.webUsers["#general"]["stale-user"]; ok { |
| 26 | t.Fatalf("stale-user was not pruned from webUsers") |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestSetWebUserTTLDefaultsNonPositiveValues(t *testing.T) { |
| 31 | b := &Bot{} |
| 32 | |
| 33 | b.SetWebUserTTL(0) |
| 34 | if b.webUserTTL != defaultWebUserTTL { |
| 35 | t.Fatalf("SetWebUserTTL(0) = %v, want %v", b.webUserTTL, defaultWebUserTTL) |
| 36 | } |
| 37 | |
| 38 | b.SetWebUserTTL(-1 * time.Minute) |
| 39 | if b.webUserTTL != defaultWebUserTTL { |
| 40 | t.Fatalf("SetWebUserTTL(-1m) = %v, want %v", b.webUserTTL, defaultWebUserTTL) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestTouchUserMarksNickActive(t *testing.T) { |
| 45 | b := &Bot{webUsers: make(map[string]map[string]time.Time)} |
| 46 | |
| 47 | b.TouchUser("#general", "codex-test") |
| 48 | |
| 49 | if b.webUsers["#general"] == nil { |
| 50 | t.Fatal("TouchUser did not initialize channel map") |
| 51 | } |
| 52 | if _, ok := b.webUsers["#general"]["codex-test"]; !ok { |
| 53 | t.Fatal("TouchUser did not record nick") |
| 54 | } |
| 55 | } |
+51
| --- a/pkg/sessionrelay/http.go | ||
| +++ b/pkg/sessionrelay/http.go | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +package sessionrelay | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "net/http" | |
| 10 | + ""slices" | |
| 11 | + "sort" | |
| 12 | + "sync" | |
| 13 | + "time" | |
| 14 | +) | |
| 15 | + | |
| 16 | +type httpConnector struct { | |
| 17 | + http *http.Client | |
| 18 | + baseUchannelu sync.RWMutex | |
| 19 | + ch{ | |
| 20 | + At string `json:"at"` | |
| 21 | + Nick string `json:"nick"` | |
| 22 | + Text string `json:"text"` | |
| 23 | +} | |
| 24 | + | |
| 25 | +func newHTTPConnector(cfg Config) Connector { | |
| 26 | + return &cfg.HTTPClient, | |
| 27 | + baseURL: stringsTrimRightScfg.Tokecfg.HTTPClient, | |
| 28 | + baseURL: sessionrelay | |
| 29 | + | |
| 30 | +impackage sessionrelay | |
| 31 | + | |
| 32 | +import ( | |
| 33 | + "bytes" | |
| 34 | + "context" | |
| 35 | + "encoding/json" | |
| 36 | + "e channelSlug(cfg.Channel), | |
| 37 | + nick: cfg.Nick, | |
| 38 | + }S@pl,4h@BA,d@L0,1:.2o@Lc,s@13D,W@QP,8:.channelK@RC,I@Z~,H@TF,K@15G,1: | |
| 39 | +U@ST,I@Z~,H@TF,13@17L,4:nil,R@tW,Y@V1,3:} | |
| 40 | + | |
| 41 | +O@Vd,f@W1,B:} | |
| 42 | + if err :i@Wp,1:;N@16u,C:nil, err | |
| 43 | + } | |
| 44 | +S@OO,N:len(payload.Messages)) | |
| 45 | +f@YX,n@ZC,J@Z~,Y:continue | |
| 46 | + } | |
| 47 | + if !at.After(since)L@gh,Y@aF,Y@b3,1:}1H@cs,d@em,8:.channeln@fh,I@gV,E:return nil | |
| 48 | + } | |
| 49 | +T@h0,2A@hU,P:return nil | |
| 50 | + } | |
| 51 | + return errS@pl,G8@10f,_qhsv; |
| --- a/pkg/sessionrelay/http.go | |
| +++ b/pkg/sessionrelay/http.go | |
| @@ -0,0 +1,51 @@ | |
| --- a/pkg/sessionrelay/http.go | |
| +++ b/pkg/sessionrelay/http.go | |
| @@ -0,0 +1,51 @@ | |
| 1 | package sessionrelay |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | ""slices" |
| 11 | "sort" |
| 12 | "sync" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | type httpConnector struct { |
| 17 | http *http.Client |
| 18 | baseUchannelu sync.RWMutex |
| 19 | ch{ |
| 20 | At string `json:"at"` |
| 21 | Nick string `json:"nick"` |
| 22 | Text string `json:"text"` |
| 23 | } |
| 24 | |
| 25 | func newHTTPConnector(cfg Config) Connector { |
| 26 | return &cfg.HTTPClient, |
| 27 | baseURL: stringsTrimRightScfg.Tokecfg.HTTPClient, |
| 28 | baseURL: sessionrelay |
| 29 | |
| 30 | impackage sessionrelay |
| 31 | |
| 32 | import ( |
| 33 | "bytes" |
| 34 | "context" |
| 35 | "encoding/json" |
| 36 | "e channelSlug(cfg.Channel), |
| 37 | nick: cfg.Nick, |
| 38 | }S@pl,4h@BA,d@L0,1:.2o@Lc,s@13D,W@QP,8:.channelK@RC,I@Z~,H@TF,K@15G,1: |
| 39 | U@ST,I@Z~,H@TF,13@17L,4:nil,R@tW,Y@V1,3:} |
| 40 | |
| 41 | O@Vd,f@W1,B:} |
| 42 | if err :i@Wp,1:;N@16u,C:nil, err |
| 43 | } |
| 44 | S@OO,N:len(payload.Messages)) |
| 45 | f@YX,n@ZC,J@Z~,Y:continue |
| 46 | } |
| 47 | if !at.After(since)L@gh,Y@aF,Y@b3,1:}1H@cs,d@em,8:.channeln@fh,I@gV,E:return nil |
| 48 | } |
| 49 | T@h0,2A@hU,P:return nil |
| 50 | } |
| 51 | return errS@pl,G8@10f,_qhsv; |
+108
| --- a/pkg/sessionrelay/irc.go | ||
| +++ b/pkg/sessionrelay/irc.go | ||
| @@ -0,0 +1,108 @@ | ||
| 1 | +package sessionrelay | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "fmt" | |
| 8 | + "net" | |
| 9 | + "net/http" | |
| 10 | + " " | |
| 11 | + "net/http" | |
| 12 | + " "net" | |
| 13 | + "net/http" | |
| 14 | + "os" | |
| 15 | + "slices" | |
| 16 | + "strconv" | |
| 17 | + "strings" | |
| 18 | + "sync" | |
| 19 | + "time" | |
| 20 | + | |
| 21 | + "github.com/lrstanley/girc" | |
| 22 | +) | |
| 23 | + | |
| 24 | +type ircConnector schannelken string | |
| 25 | + primary string | |
| 26 | + nick string | |
| 27 | + addr string | |
| 28 | + agentType string | |
| 29 | + pass string | |
| 30 | + deleteOnClose booode bool | |
| 31 | + | |
| 32 | + mu sync.RWMutex | |
| 33 | + channels []string | |
| 34 | + messages []Message | |
| 35 | + client *girc.Client | |
| 36 | + errCh chan erro | |
| 37 | + registeredByRelay bool | |
| 38 | + connectedAt time.Time | |
| 39 | +} | |
| 40 | + | |
| 41 | +func newIRCConnector(cfg Config) (Connector, error) { | |
| 42 | + if cfg.IRC.Addr == "" { | |
| 43 | + return nil, fmt.Errorf("sessionrelay: irc transport requires irc addr") | |
| 44 | + } | |
| 45 | + return &ircConnector{ | |
| 46 | + htchannel: package sessionrela( | |
| 47 | + "bytes" | |
| 48 | + "context" | |
| 49 | + "encoding/json" | |
| 50 | + "fmt" | |
| 51 | + "net" | |
| 52 | + "net/http" | |
| 53 | + " "net" | |
| 54 | + "net/http" | |
| 55 | + "os" | |
| 56 | + "slices" | |
| 57 | + "strconv" | |
| 58 | + "strings" | |
| 59 | + "sync" | |
| 60 | + "time" | |
| 61 | + | |
| 62 | + "github.comcl.Cmd.Join(c.channel)0 * time.Second | |
| 63 | +) | |
| 64 | + | |
| 65 | +func (c *ircConnector) Co channels: append([]string(nil), cfg.Channels...), | |
| 66 | + messages: m{ | |
| 67 | + replacintials(ctx); err != nil { | |
| 68 | + return err | |
| 69 | + } | |
| 70 | + | |
| 71 | + host, port, err := splitHostchanneladdr) | |
| 72 | + if errport ( | |
| 73 | + "bytes" | |
| 74 | + "context"package sesdial creates a fresh girc client, wires up handlers, and starts the | |
| 75 | +// connection goroutine. onJoclient = client// joined — used k: && ctx.Err() =me.Now() target != c.channe used k: && ct { | |
| 76 | +artChannel(_r != nil { stringsTrimRightSlash(cfg.URL), | |
| 77 | + token: cfg.Token, | |
| 78 | + primary: normalizeChannel(cfg.Channel), | |
| 79 | + nick: cfg.Nick, | |
| 80 | + addr: cfg.IRC.Addr, | |
| 81 | + agentType: cfg.IRC.AgentType, | |
| 82 | + pass: cfg.IRC.Pass, | |
| 83 | + deleteOnClose: cfg.IRC.EnvelopeMode, | |
| 84 | + channels: append([]string(nil), cfg.Channels... = 2 * time.Second | |
| 85 | + ircReconnectMax = 30 * time.Second | |
| 86 | +) | |
| 87 | + | |
| 88 | +func (c *ircConnector) Co channels: append([]string(nil), cfg.Channels...), | |
| 89 | + messages: m{ | |
| 90 | + replacintials(ctx); err != nil { | |
| 91 | + return err | |
| 92 | + } | |
| 93 | +me != c.nick {host, | |
| 94 | + Port:package sesstime.Second | |
| 95 | +) | |
| 96 | + | |
| 97 | +func (c.s: append([]string(nil), cfg.Channels...), | |
| 98 | + messages: m{ | |
| 99 | + replacintials(ctx); err != nil { | |
| 100 | + return err | |
| 101 | + } | |
| 102 | + | |
| 103 | + host, port, err := splitHostPort(c.addr) | |
| 104 | + if errport ( | |
| 105 | + "bytes" | |
| 106 | + "context"package sesdial creates a fresh girc client, wires up handlers, and starts the | |
| 107 | +// connection goroutine. onJoclient = client// joined — used k: && ctx.Err() =me.Now() c.nick, | |
| 108 | + ck + " (sess_joinr != nil {Close[]string{c.channel} |
| --- a/pkg/sessionrelay/irc.go | |
| +++ b/pkg/sessionrelay/irc.go | |
| @@ -0,0 +1,108 @@ | |
| --- a/pkg/sessionrelay/irc.go | |
| +++ b/pkg/sessionrelay/irc.go | |
| @@ -0,0 +1,108 @@ | |
| 1 | package sessionrelay |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "net" |
| 9 | "net/http" |
| 10 | " " |
| 11 | "net/http" |
| 12 | " "net" |
| 13 | "net/http" |
| 14 | "os" |
| 15 | "slices" |
| 16 | "strconv" |
| 17 | "strings" |
| 18 | "sync" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/lrstanley/girc" |
| 22 | ) |
| 23 | |
| 24 | type ircConnector schannelken string |
| 25 | primary string |
| 26 | nick string |
| 27 | addr string |
| 28 | agentType string |
| 29 | pass string |
| 30 | deleteOnClose booode bool |
| 31 | |
| 32 | mu sync.RWMutex |
| 33 | channels []string |
| 34 | messages []Message |
| 35 | client *girc.Client |
| 36 | errCh chan erro |
| 37 | registeredByRelay bool |
| 38 | connectedAt time.Time |
| 39 | } |
| 40 | |
| 41 | func newIRCConnector(cfg Config) (Connector, error) { |
| 42 | if cfg.IRC.Addr == "" { |
| 43 | return nil, fmt.Errorf("sessionrelay: irc transport requires irc addr") |
| 44 | } |
| 45 | return &ircConnector{ |
| 46 | htchannel: package sessionrela( |
| 47 | "bytes" |
| 48 | "context" |
| 49 | "encoding/json" |
| 50 | "fmt" |
| 51 | "net" |
| 52 | "net/http" |
| 53 | " "net" |
| 54 | "net/http" |
| 55 | "os" |
| 56 | "slices" |
| 57 | "strconv" |
| 58 | "strings" |
| 59 | "sync" |
| 60 | "time" |
| 61 | |
| 62 | "github.comcl.Cmd.Join(c.channel)0 * time.Second |
| 63 | ) |
| 64 | |
| 65 | func (c *ircConnector) Co channels: append([]string(nil), cfg.Channels...), |
| 66 | messages: m{ |
| 67 | replacintials(ctx); err != nil { |
| 68 | return err |
| 69 | } |
| 70 | |
| 71 | host, port, err := splitHostchanneladdr) |
| 72 | if errport ( |
| 73 | "bytes" |
| 74 | "context"package sesdial creates a fresh girc client, wires up handlers, and starts the |
| 75 | // connection goroutine. onJoclient = client// joined — used k: && ctx.Err() =me.Now() target != c.channe used k: && ct { |
| 76 | artChannel(_r != nil { stringsTrimRightSlash(cfg.URL), |
| 77 | token: cfg.Token, |
| 78 | primary: normalizeChannel(cfg.Channel), |
| 79 | nick: cfg.Nick, |
| 80 | addr: cfg.IRC.Addr, |
| 81 | agentType: cfg.IRC.AgentType, |
| 82 | pass: cfg.IRC.Pass, |
| 83 | deleteOnClose: cfg.IRC.EnvelopeMode, |
| 84 | channels: append([]string(nil), cfg.Channels... = 2 * time.Second |
| 85 | ircReconnectMax = 30 * time.Second |
| 86 | ) |
| 87 | |
| 88 | func (c *ircConnector) Co channels: append([]string(nil), cfg.Channels...), |
| 89 | messages: m{ |
| 90 | replacintials(ctx); err != nil { |
| 91 | return err |
| 92 | } |
| 93 | me != c.nick {host, |
| 94 | Port:package sesstime.Second |
| 95 | ) |
| 96 | |
| 97 | func (c.s: append([]string(nil), cfg.Channels...), |
| 98 | messages: m{ |
| 99 | replacintials(ctx); err != nil { |
| 100 | return err |
| 101 | } |
| 102 | |
| 103 | host, port, err := splitHostPort(c.addr) |
| 104 | if errport ( |
| 105 | "bytes" |
| 106 | "context"package sesdial creates a fresh girc client, wires up handlers, and starts the |
| 107 | // connection goroutine. onJoclient = client// joined — used k: && ctx.Err() =me.Now() c.nick, |
| 108 | ck + " (sess_joinr != nil {Close[]string{c.channel} |
| --- a/pkg/sessionrelay/sessionrelay.go | ||
| +++ b/pkg/sessionrelay/sessionrelay.go | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +package sessionretime.Time | |
| 2 | + Nick string | |
| 3 | + TextClose |
| --- a/pkg/sessionrelay/sessionrelay.go | |
| +++ b/pkg/sessionrelay/sessionrelay.go | |
| @@ -0,0 +1,3 @@ | |
| --- a/pkg/sessionrelay/sessionrelay.go | |
| +++ b/pkg/sessionrelay/sessionrelay.go | |
| @@ -0,0 +1,3 @@ | |
| 1 | package sessionretime.Time |
| 2 | Nick string |
| 3 | TextClose |
| --- a/pkg/sessionrelay/sessionrelay_test.go | ||
| +++ b/pkg/sessionrelay/sessionrelay_test.go | ||
| @@ -0,0 +1,168 @@ | ||
| 1 | +package sessionrelay | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "ttptest" | |
| 9 | + "os" | |
| 10 | + "slices" | |
| 11 | + "testing" | |
| 12 | + "time" | |
| 13 | +) | |
| 14 | + | |
| 15 | +func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) { | |
| 16 | + t.Helper() | |
| 17 | + | |
| 18 | + base := time.Date(2026, 3, 31, 22, 0, 0,string | |
| 19 | + var posted map[string]strted []string | |
| 20 | + var touched []string | |
| 21 | + | |
| 22 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 23 | + gotAuth = append(gotAuth, r.Header.Get("Authorization")) | |
| 24 | + switch { | |
| 25 | + case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/messages": | |
| 26 | + var body map[string]string | |
| 27 | + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 28 | + t.Fatalf("decode general post body: %v", err) | |
| 29 | + } | |
| 30 | + posted = append(posted, "general:"+body["nick"]+":"+body["tedefault: | |
| 31 | + http.NotFound(w, r) | |
| 32 | + } | |
| 33 | + })) | |
| 34 | + defer srv.Close() | |
| 35 | + | |
| 36 | + connNewDecoder(r.Body).Decode(&body); err != nil { | |
| 37 | + t.Fatalf("decode release post body: %v", err) | |
| 38 | + } | |
| 39 | + posted = append(posted, "release:"+body["nick"]+":"+body["text"]) | |
| 40 | + w.WriteHeader(http.StatusNoContent) | |
| 41 | + case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/presence": | |
| 42 | + var body map[string]string | |
| 43 | + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 44 | + t.Fatalf("decode general touch body: %v", err) | |
| 45 | + } | |
| 46 | + touched = append(touched, "general:"+body["nick"]) | |
| 47 | + w.WriteHeader(http.StatusNoContent) | |
| 48 | + case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/presence": | |
| 49 | + var body map[string]string | |
| 50 | + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 51 | + t.Fatalf("decode release touch body: %v", err) | |
| 52 | + } | |
| 53 | + touched = append(touched, "release:"+body["nick"]) | |
| 54 | + w.WriteHeader(http.StatusNoContent) | |
| 55 | + case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages": | |
| 56 | + _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{ | |
| 57 | + {"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"}, | |
| 58 | + {"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"}, | |
| 59 | + }}) | |
| 60 | + case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/release/messages": | |
| 61 | + _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{ | |
| 62 | + {"at": base.Add(2 * time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: /join #task-42"}, | |
| 63 | + }}) | |
| 64 | + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register": | |
| 65 | + w.WriteHeader(http.StatusCreated) | |
| 66 | + default: | |
| 67 | + http.NotFound(w, r) | |
| 68 | + } | |
| 69 | + })) | |
| 70 | + defer srv.Close() | |
| 71 | + | |
| 72 | + conn, err := New(Config{ | |
| 73 | + Transport: TransportHTTP, | |
| 74 | + URL: srv.URL, | |
| 75 | + Token: "test-token", | |
| 76 | + Channel: "general", | |
| 77 | + Channels: []string{"general", "release"}, | |
| 78 | + Nick: "codex-test", | |
| 79 | + HTTPClient: srv.Client(), | |
| 80 | + }) | |
| 81 | + if err != nil { | |
| 82 | + t.Fatal(err) | |
| 83 | + } | |
| 84 | + if err := conn.Connect(context.Background()); err != nil { | |
| 85 | + t.Fatal(err) | |
| 86 | + } | |
| 87 | + if err := conn.Post(context.Background(), "online"); err != nil { | |
| 88 | + t.Fatal(err) | |
| 89 | + } | |
| 90 | + if want := []string{"general:codex-test:online", "release:codex-test:online"}; !slices.Equal(posted, want) { | |
| 91 | + t.Fatalf("posted = %#v, want %#v", posted, want) | |
| 92 | + } | |
| 93 | + for _, auth := range gotAuth { | |
| 94 | + if auth != "Bearer test-token" { | |
| 95 | + t.Fatalf("authorization = %q", auth) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + msgs, err := conn.MessagesSince(context.Background(), base) | |
| 100 | + if err != nil { | |
| 101 | + t.Fatal(err) | |
| 102 | + } | |
| 103 | + if len(msgs) != 2 { | |
| 104 | + t.Fatalf("MessagesSince len = %d, want 2", len(msgs)) | |
| 105 | + } | |
| 106 | + if msgs[0].Channel != "#general" || msgs[1].Channel != "#release" { | |
| 107 | + t.Fatalf("MessagesSince channels = %#v", msgs) | |
| 108 | + } | |
| 109 | + | |
| 110 | + if err := conn.Touch(context.Background()); err != nil { | |
| 111 | + t.Fatal(err) | |
| 112 | + } | |
| 113 | + if want := []string{"general:codex-test", "release:codex-test"}; !slices.Equal(touched, want) { | |
| 114 | + t.Fatalf("touches = %#v, want %#v", touched, want) | |
| 115 | + } | |
| 116 | +} | |
| 117 | + | |
| 118 | +func TestHTTPConnectorJoinPartAndControlChannel(t *testing.T) { | |
| 119 | + t.Helper() | |
| 120 | + | |
| 121 | + conn, err := New(Config{ | |
| 122 | + Transport: TransportHTTP, | |
| 123 | + URL: "http://example.com", | |
| 124 | + Token: "test-token", | |
| 125 | + Channel: "general", | |
| 126 | + Channels: []string{"general", "release"}, | |
| 127 | + Nick: "codex-test", | |
| 128 | + }) | |
| 129 | + if err != nil { | |
| 130 | + t.Fatal(err) | |
| 131 | + } | |
| 132 | + | |
| 133 | + if got := conn.ControlChannel(); got != "#general" { | |
| 134 | + t.Fatalf("ControlChannel = %q, want #general", got) | |
| 135 | + } | |
| 136 | + if err := conn.JoinChannel(context.Background(), "#task-42"); err != nil { | |
| 137 | + t.Fatal(err) | |
| 138 | + } | |
| 139 | + if want := []string{"#general", "#release", "#task-42"}; !slices.Equal(conn.Channels(), want) { | |
| 140 | + t.Fatalf("Channels after join = %#v, want %#v", conn.Channels(), want) | |
| 141 | + } | |
| 142 | + if err := conn.PartChannel(context.Background(), "#general"); err == nil { | |
| 143 | + t.Fatal("PartChannel(control) = nil, want error") | |
| 144 | + } | |
| 145 | + if err := conn.PartChannel(context.Background(), "#release"); err != nil { | |
| 146 | + t.Fatal(err) | |
| 147 | + } | |
| 148 | + if want := []string{"#general", "#task-42"}; !slices.Equal(conn.Channels(), want) { | |
| 149 | + t.Fatalf("Channels after part = %#v, want %#v", conn.Channels(), want) | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) { | |
| 154 | + t.Helper() | |
| 155 | + | |
| 156 | + var deletedPath string | |
| 157 | + var registerChannels []string | |
| 158 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 159 | + switch { | |
| 160 | + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register": | |
| 161 | + var body struct { | |
| 162 | + Channels []string `json:"channels"` | |
| 163 | + } | |
| 164 | + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 165 | + t.Fatalf("decode register body: %v", err) | |
| 166 | + } | |
| 167 | + registerChannels = body.Channels | |
| 168 | + w.Write |
| --- a/pkg/sessionrelay/sessionrelay_test.go | |
| +++ b/pkg/sessionrelay/sessionrelay_test.go | |
| @@ -0,0 +1,168 @@ | |
| --- a/pkg/sessionrelay/sessionrelay_test.go | |
| +++ b/pkg/sessionrelay/sessionrelay_test.go | |
| @@ -0,0 +1,168 @@ | |
| 1 | package sessionrelay |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "ttptest" |
| 9 | "os" |
| 10 | "slices" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) { |
| 16 | t.Helper() |
| 17 | |
| 18 | base := time.Date(2026, 3, 31, 22, 0, 0,string |
| 19 | var posted map[string]strted []string |
| 20 | var touched []string |
| 21 | |
| 22 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 23 | gotAuth = append(gotAuth, r.Header.Get("Authorization")) |
| 24 | switch { |
| 25 | case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/messages": |
| 26 | var body map[string]string |
| 27 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| 28 | t.Fatalf("decode general post body: %v", err) |
| 29 | } |
| 30 | posted = append(posted, "general:"+body["nick"]+":"+body["tedefault: |
| 31 | http.NotFound(w, r) |
| 32 | } |
| 33 | })) |
| 34 | defer srv.Close() |
| 35 | |
| 36 | connNewDecoder(r.Body).Decode(&body); err != nil { |
| 37 | t.Fatalf("decode release post body: %v", err) |
| 38 | } |
| 39 | posted = append(posted, "release:"+body["nick"]+":"+body["text"]) |
| 40 | w.WriteHeader(http.StatusNoContent) |
| 41 | case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/presence": |
| 42 | var body map[string]string |
| 43 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| 44 | t.Fatalf("decode general touch body: %v", err) |
| 45 | } |
| 46 | touched = append(touched, "general:"+body["nick"]) |
| 47 | w.WriteHeader(http.StatusNoContent) |
| 48 | case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/presence": |
| 49 | var body map[string]string |
| 50 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| 51 | t.Fatalf("decode release touch body: %v", err) |
| 52 | } |
| 53 | touched = append(touched, "release:"+body["nick"]) |
| 54 | w.WriteHeader(http.StatusNoContent) |
| 55 | case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages": |
| 56 | _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{ |
| 57 | {"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"}, |
| 58 | {"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"}, |
| 59 | }}) |
| 60 | case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/release/messages": |
| 61 | _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{ |
| 62 | {"at": base.Add(2 * time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: /join #task-42"}, |
| 63 | }}) |
| 64 | case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register": |
| 65 | w.WriteHeader(http.StatusCreated) |
| 66 | default: |
| 67 | http.NotFound(w, r) |
| 68 | } |
| 69 | })) |
| 70 | defer srv.Close() |
| 71 | |
| 72 | conn, err := New(Config{ |
| 73 | Transport: TransportHTTP, |
| 74 | URL: srv.URL, |
| 75 | Token: "test-token", |
| 76 | Channel: "general", |
| 77 | Channels: []string{"general", "release"}, |
| 78 | Nick: "codex-test", |
| 79 | HTTPClient: srv.Client(), |
| 80 | }) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if err := conn.Connect(context.Background()); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if err := conn.Post(context.Background(), "online"); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if want := []string{"general:codex-test:online", "release:codex-test:online"}; !slices.Equal(posted, want) { |
| 91 | t.Fatalf("posted = %#v, want %#v", posted, want) |
| 92 | } |
| 93 | for _, auth := range gotAuth { |
| 94 | if auth != "Bearer test-token" { |
| 95 | t.Fatalf("authorization = %q", auth) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | msgs, err := conn.MessagesSince(context.Background(), base) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | if len(msgs) != 2 { |
| 104 | t.Fatalf("MessagesSince len = %d, want 2", len(msgs)) |
| 105 | } |
| 106 | if msgs[0].Channel != "#general" || msgs[1].Channel != "#release" { |
| 107 | t.Fatalf("MessagesSince channels = %#v", msgs) |
| 108 | } |
| 109 | |
| 110 | if err := conn.Touch(context.Background()); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if want := []string{"general:codex-test", "release:codex-test"}; !slices.Equal(touched, want) { |
| 114 | t.Fatalf("touches = %#v, want %#v", touched, want) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestHTTPConnectorJoinPartAndControlChannel(t *testing.T) { |
| 119 | t.Helper() |
| 120 | |
| 121 | conn, err := New(Config{ |
| 122 | Transport: TransportHTTP, |
| 123 | URL: "http://example.com", |
| 124 | Token: "test-token", |
| 125 | Channel: "general", |
| 126 | Channels: []string{"general", "release"}, |
| 127 | Nick: "codex-test", |
| 128 | }) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | |
| 133 | if got := conn.ControlChannel(); got != "#general" { |
| 134 | t.Fatalf("ControlChannel = %q, want #general", got) |
| 135 | } |
| 136 | if err := conn.JoinChannel(context.Background(), "#task-42"); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if want := []string{"#general", "#release", "#task-42"}; !slices.Equal(conn.Channels(), want) { |
| 140 | t.Fatalf("Channels after join = %#v, want %#v", conn.Channels(), want) |
| 141 | } |
| 142 | if err := conn.PartChannel(context.Background(), "#general"); err == nil { |
| 143 | t.Fatal("PartChannel(control) = nil, want error") |
| 144 | } |
| 145 | if err := conn.PartChannel(context.Background(), "#release"); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if want := []string{"#general", "#task-42"}; !slices.Equal(conn.Channels(), want) { |
| 149 | t.Fatalf("Channels after part = %#v, want %#v", conn.Channels(), want) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) { |
| 154 | t.Helper() |
| 155 | |
| 156 | var deletedPath string |
| 157 | var registerChannels []string |
| 158 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 159 | switch { |
| 160 | case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register": |
| 161 | var body struct { |
| 162 | Channels []string `json:"channels"` |
| 163 | } |
| 164 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| 165 | t.Fatalf("decode register body: %v", err) |
| 166 | } |
| 167 | registerChannels = body.Channels |
| 168 | w.Write |
+3
-1
| --- skills/irc-agent/README.md | ||
| +++ skills/irc-agent/README.md | ||
| @@ -6,11 +6,13 @@ | ||
| 6 | 6 | `cmd/codex-agent`, and `cmd/gemini-agent` are thin wrappers with different defaults. |
| 7 | 7 | |
| 8 | 8 | This document is for IRC-resident agents. Live terminal runtimes such as |
| 9 | 9 | `codex-relay` use a different pattern: a broker owns session presence, |
| 10 | 10 | continuous operator input injection, and outbound activity mirroring while the |
| 11 | -runtime stays local. | |
| 11 | +runtime stays local. That broker path now uses the shared `pkg/sessionrelay` | |
| 12 | +connector package so future terminal clients can reuse the same HTTP or IRC | |
| 13 | +transport layer. | |
| 12 | 14 | |
| 13 | 15 | --- |
| 14 | 16 | |
| 15 | 17 | ## What scuttlebot gives you |
| 16 | 18 | |
| 17 | 19 |
| --- skills/irc-agent/README.md | |
| +++ skills/irc-agent/README.md | |
| @@ -6,11 +6,13 @@ | |
| 6 | `cmd/codex-agent`, and `cmd/gemini-agent` are thin wrappers with different defaults. |
| 7 | |
| 8 | This document is for IRC-resident agents. Live terminal runtimes such as |
| 9 | `codex-relay` use a different pattern: a broker owns session presence, |
| 10 | continuous operator input injection, and outbound activity mirroring while the |
| 11 | runtime stays local. |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## What scuttlebot gives you |
| 16 | |
| 17 |
| --- skills/irc-agent/README.md | |
| +++ skills/irc-agent/README.md | |
| @@ -6,11 +6,13 @@ | |
| 6 | `cmd/codex-agent`, and `cmd/gemini-agent` are thin wrappers with different defaults. |
| 7 | |
| 8 | This document is for IRC-resident agents. Live terminal runtimes such as |
| 9 | `codex-relay` use a different pattern: a broker owns session presence, |
| 10 | continuous operator input injection, and outbound activity mirroring while the |
| 11 | runtime stays local. That broker path now uses the shared `pkg/sessionrelay` |
| 12 | connector package so future terminal clients can reuse the same HTTP or IRC |
| 13 | transport layer. |
| 14 | |
| 15 | --- |
| 16 | |
| 17 | ## What scuttlebot gives you |
| 18 | |
| 19 |
+13
-1
| --- skills/openai-relay/FLEET.md | ||
| +++ skills/openai-relay/FLEET.md | ||
| @@ -4,10 +4,11 @@ | ||
| 4 | 4 | operator-addressable through scuttlebot. |
| 5 | 5 | |
| 6 | 6 | Source of truth: |
| 7 | 7 | - installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh) |
| 8 | 8 | - broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go) |
| 9 | +- shared connector: [`../../pkg/sessionrelay/`](../../pkg/sessionrelay/) | |
| 9 | 10 | - dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh) |
| 10 | 11 | - hooks: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh) |
| 11 | 12 | - runtime docs: [`install.md`](install.md), [`hooks/README.md`](hooks/README.md) |
| 12 | 13 | - shared runtime contract: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md) |
| 13 | 14 | |
| @@ -30,10 +31,14 @@ | ||
| 30 | 31 | - mirrored assistant messages from the active session log |
| 31 | 32 | - continuous addressed IRC input injection into the live terminal session |
| 32 | 33 | - explicit pre-tool fallback interrupts before the next action |
| 33 | 34 | - `offline` post on exit |
| 34 | 35 | |
| 36 | +Transport choice: | |
| 37 | +- `SCUTTLEBOT_TRANSPORT=http` keeps the bridge/API path and now uses presence heartbeats | |
| 38 | +- `SCUTTLEBOT_TRANSPORT=irc` logs the session nick directly into Ergo for real presence | |
| 39 | + | |
| 35 | 40 | This is the production control path for a human-operated Codex terminal. If you |
| 36 | 41 | want an always-on IRC-resident bot instead, use `cmd/codex-agent`. |
| 37 | 42 | |
| 38 | 43 | ## One-machine install |
| 39 | 44 | |
| @@ -64,11 +69,13 @@ | ||
| 64 | 69 | |
| 65 | 70 | ```bash |
| 66 | 71 | bash skills/openai-relay/scripts/install-codex-relay.sh \ |
| 67 | 72 | --url http://scuttlebot.internal:8080 \ |
| 68 | 73 | --token "$SCUTTLEBOT_TOKEN" \ |
| 69 | - --channel fleet | |
| 74 | + --channel fleet \ | |
| 75 | + --transport irc \ | |
| 76 | + --irc-addr scuttlebot.internal:6667 | |
| 70 | 77 | ``` |
| 71 | 78 | |
| 72 | 79 | If you need hooks present but inactive until the server is live: |
| 73 | 80 | |
| 74 | 81 | ```bash |
| @@ -95,12 +102,17 @@ | ||
| 95 | 102 | - replace the real `codex` binary in `PATH` |
| 96 | 103 | - force a fixed nick across sessions |
| 97 | 104 | - require IRC to be up at install time |
| 98 | 105 | |
| 99 | 106 | Useful shared env knobs: |
| 107 | +- `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend | |
| 108 | +- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc` | |
| 109 | +- `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration | |
| 110 | +- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit | |
| 100 | 111 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted |
| 101 | 112 | - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages |
| 113 | +- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` controls HTTP presence touches; set `0` to disable | |
| 102 | 114 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts |
| 103 | 115 | |
| 104 | 116 | ## Operator workflow |
| 105 | 117 | |
| 106 | 118 | 1. Watch the configured channel in scuttlebot. |
| 107 | 119 |
| --- skills/openai-relay/FLEET.md | |
| +++ skills/openai-relay/FLEET.md | |
| @@ -4,10 +4,11 @@ | |
| 4 | operator-addressable through scuttlebot. |
| 5 | |
| 6 | Source of truth: |
| 7 | - installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh) |
| 8 | - broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go) |
| 9 | - dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh) |
| 10 | - hooks: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh) |
| 11 | - runtime docs: [`install.md`](install.md), [`hooks/README.md`](hooks/README.md) |
| 12 | - shared runtime contract: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md) |
| 13 | |
| @@ -30,10 +31,14 @@ | |
| 30 | - mirrored assistant messages from the active session log |
| 31 | - continuous addressed IRC input injection into the live terminal session |
| 32 | - explicit pre-tool fallback interrupts before the next action |
| 33 | - `offline` post on exit |
| 34 | |
| 35 | This is the production control path for a human-operated Codex terminal. If you |
| 36 | want an always-on IRC-resident bot instead, use `cmd/codex-agent`. |
| 37 | |
| 38 | ## One-machine install |
| 39 | |
| @@ -64,11 +69,13 @@ | |
| 64 | |
| 65 | ```bash |
| 66 | bash skills/openai-relay/scripts/install-codex-relay.sh \ |
| 67 | --url http://scuttlebot.internal:8080 \ |
| 68 | --token "$SCUTTLEBOT_TOKEN" \ |
| 69 | --channel fleet |
| 70 | ``` |
| 71 | |
| 72 | If you need hooks present but inactive until the server is live: |
| 73 | |
| 74 | ```bash |
| @@ -95,12 +102,17 @@ | |
| 95 | - replace the real `codex` binary in `PATH` |
| 96 | - force a fixed nick across sessions |
| 97 | - require IRC to be up at install time |
| 98 | |
| 99 | Useful shared env knobs: |
| 100 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted |
| 101 | - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages |
| 102 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts |
| 103 | |
| 104 | ## Operator workflow |
| 105 | |
| 106 | 1. Watch the configured channel in scuttlebot. |
| 107 |
| --- skills/openai-relay/FLEET.md | |
| +++ skills/openai-relay/FLEET.md | |
| @@ -4,10 +4,11 @@ | |
| 4 | operator-addressable through scuttlebot. |
| 5 | |
| 6 | Source of truth: |
| 7 | - installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh) |
| 8 | - broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go) |
| 9 | - shared connector: [`../../pkg/sessionrelay/`](../../pkg/sessionrelay/) |
| 10 | - dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh) |
| 11 | - hooks: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh) |
| 12 | - runtime docs: [`install.md`](install.md), [`hooks/README.md`](hooks/README.md) |
| 13 | - shared runtime contract: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md) |
| 14 | |
| @@ -30,10 +31,14 @@ | |
| 31 | - mirrored assistant messages from the active session log |
| 32 | - continuous addressed IRC input injection into the live terminal session |
| 33 | - explicit pre-tool fallback interrupts before the next action |
| 34 | - `offline` post on exit |
| 35 | |
| 36 | Transport choice: |
| 37 | - `SCUTTLEBOT_TRANSPORT=http` keeps the bridge/API path and now uses presence heartbeats |
| 38 | - `SCUTTLEBOT_TRANSPORT=irc` logs the session nick directly into Ergo for real presence |
| 39 | |
| 40 | This is the production control path for a human-operated Codex terminal. If you |
| 41 | want an always-on IRC-resident bot instead, use `cmd/codex-agent`. |
| 42 | |
| 43 | ## One-machine install |
| 44 | |
| @@ -64,11 +69,13 @@ | |
| 69 | |
| 70 | ```bash |
| 71 | bash skills/openai-relay/scripts/install-codex-relay.sh \ |
| 72 | --url http://scuttlebot.internal:8080 \ |
| 73 | --token "$SCUTTLEBOT_TOKEN" \ |
| 74 | --channel fleet \ |
| 75 | --transport irc \ |
| 76 | --irc-addr scuttlebot.internal:6667 |
| 77 | ``` |
| 78 | |
| 79 | If you need hooks present but inactive until the server is live: |
| 80 | |
| 81 | ```bash |
| @@ -95,12 +102,17 @@ | |
| 102 | - replace the real `codex` binary in `PATH` |
| 103 | - force a fixed nick across sessions |
| 104 | - require IRC to be up at install time |
| 105 | |
| 106 | Useful shared env knobs: |
| 107 | - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend |
| 108 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc` |
| 109 | - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration |
| 110 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit |
| 111 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted |
| 112 | - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages |
| 113 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` controls HTTP presence touches; set `0` to disable |
| 114 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts |
| 115 | |
| 116 | ## Operator workflow |
| 117 | |
| 118 | 1. Watch the configured channel in scuttlebot. |
| 119 |
+9
-2
| --- skills/openai-relay/SKILL.md | ||
| +++ skills/openai-relay/SKILL.md | ||
| @@ -14,18 +14,18 @@ | ||
| 14 | 14 | hooks, and accept addressed instructions continuously while the session is running. |
| 15 | 15 | |
| 16 | 16 | Source-of-truth files in the repo: |
| 17 | 17 | - installer: `skills/openai-relay/scripts/install-codex-relay.sh` |
| 18 | 18 | - broker: `cmd/codex-relay/main.go` |
| 19 | +- shared connector: `pkg/sessionrelay/` | |
| 19 | 20 | - dev wrapper: `skills/openai-relay/scripts/codex-relay.sh` |
| 20 | 21 | - hooks: `skills/openai-relay/hooks/` |
| 21 | 22 | - fleet rollout doc: `skills/openai-relay/FLEET.md` |
| 22 | 23 | |
| 23 | 24 | Installed files under `~/.codex`, `~/.local/bin`, and `~/.config` are copies. |
| 24 | 25 | |
| 25 | 26 | ## Setup |
| 26 | -- Register a unique nick for each live Codex session, then store its passphrase. | |
| 27 | 27 | - Export gateway env vars: |
| 28 | 28 | - `SCUTTLEBOT_URL` e.g. `http://localhost:8080` |
| 29 | 29 | - `SCUTTLEBOT_TOKEN` bearer token |
| 30 | 30 | - Ensure the daemon has an `openai` backend configured. |
| 31 | 31 | - Ensure the relay endpoint is reachable: `curl -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" "$SCUTTLEBOT_URL/v1/status"`. |
| @@ -65,11 +65,18 @@ | ||
| 65 | 65 | - export a stable `SCUTTLEBOT_SESSION_ID` |
| 66 | 66 | - derive a stable `codex-{basename}-{session}` nick |
| 67 | 67 | - post `online ...` immediately when Codex starts |
| 68 | 68 | - post `offline ...` when Codex exits |
| 69 | 69 | - continuously inject addressed IRC messages into the live Codex terminal |
| 70 | -- let the existing hooks handle post-tool activity and pre-tool operator interrupts | |
| 70 | +- mirror assistant output and tool activity from the active session log | |
| 71 | +- use `pkg/sessionrelay` for both `http` and `irc` transport modes | |
| 72 | +- let the existing hooks remain the pre-tool fallback path | |
| 73 | + | |
| 74 | +Transport modes: | |
| 75 | +- `SCUTTLEBOT_TRANSPORT=http` uses the working HTTP bridge path and presence heartbeats | |
| 76 | +- `SCUTTLEBOT_TRANSPORT=irc` connects the live session nick directly to Ergo over SASL | |
| 77 | +- in `irc` mode, `SCUTTLEBOT_IRC_PASS` uses a fixed NickServ password; otherwise the broker auto-registers the ephemeral session nick through `/v1/agents/register` and deletes it on clean exit by default | |
| 71 | 78 | |
| 72 | 79 | To disable the relay without uninstalling: |
| 73 | 80 | |
| 74 | 81 | ```bash |
| 75 | 82 | SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay |
| 76 | 83 |
| --- skills/openai-relay/SKILL.md | |
| +++ skills/openai-relay/SKILL.md | |
| @@ -14,18 +14,18 @@ | |
| 14 | hooks, and accept addressed instructions continuously while the session is running. |
| 15 | |
| 16 | Source-of-truth files in the repo: |
| 17 | - installer: `skills/openai-relay/scripts/install-codex-relay.sh` |
| 18 | - broker: `cmd/codex-relay/main.go` |
| 19 | - dev wrapper: `skills/openai-relay/scripts/codex-relay.sh` |
| 20 | - hooks: `skills/openai-relay/hooks/` |
| 21 | - fleet rollout doc: `skills/openai-relay/FLEET.md` |
| 22 | |
| 23 | Installed files under `~/.codex`, `~/.local/bin`, and `~/.config` are copies. |
| 24 | |
| 25 | ## Setup |
| 26 | - Register a unique nick for each live Codex session, then store its passphrase. |
| 27 | - Export gateway env vars: |
| 28 | - `SCUTTLEBOT_URL` e.g. `http://localhost:8080` |
| 29 | - `SCUTTLEBOT_TOKEN` bearer token |
| 30 | - Ensure the daemon has an `openai` backend configured. |
| 31 | - Ensure the relay endpoint is reachable: `curl -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" "$SCUTTLEBOT_URL/v1/status"`. |
| @@ -65,11 +65,18 @@ | |
| 65 | - export a stable `SCUTTLEBOT_SESSION_ID` |
| 66 | - derive a stable `codex-{basename}-{session}` nick |
| 67 | - post `online ...` immediately when Codex starts |
| 68 | - post `offline ...` when Codex exits |
| 69 | - continuously inject addressed IRC messages into the live Codex terminal |
| 70 | - let the existing hooks handle post-tool activity and pre-tool operator interrupts |
| 71 | |
| 72 | To disable the relay without uninstalling: |
| 73 | |
| 74 | ```bash |
| 75 | SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay |
| 76 |
| --- skills/openai-relay/SKILL.md | |
| +++ skills/openai-relay/SKILL.md | |
| @@ -14,18 +14,18 @@ | |
| 14 | hooks, and accept addressed instructions continuously while the session is running. |
| 15 | |
| 16 | Source-of-truth files in the repo: |
| 17 | - installer: `skills/openai-relay/scripts/install-codex-relay.sh` |
| 18 | - broker: `cmd/codex-relay/main.go` |
| 19 | - shared connector: `pkg/sessionrelay/` |
| 20 | - dev wrapper: `skills/openai-relay/scripts/codex-relay.sh` |
| 21 | - hooks: `skills/openai-relay/hooks/` |
| 22 | - fleet rollout doc: `skills/openai-relay/FLEET.md` |
| 23 | |
| 24 | Installed files under `~/.codex`, `~/.local/bin`, and `~/.config` are copies. |
| 25 | |
| 26 | ## Setup |
| 27 | - Export gateway env vars: |
| 28 | - `SCUTTLEBOT_URL` e.g. `http://localhost:8080` |
| 29 | - `SCUTTLEBOT_TOKEN` bearer token |
| 30 | - Ensure the daemon has an `openai` backend configured. |
| 31 | - Ensure the relay endpoint is reachable: `curl -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" "$SCUTTLEBOT_URL/v1/status"`. |
| @@ -65,11 +65,18 @@ | |
| 65 | - export a stable `SCUTTLEBOT_SESSION_ID` |
| 66 | - derive a stable `codex-{basename}-{session}` nick |
| 67 | - post `online ...` immediately when Codex starts |
| 68 | - post `offline ...` when Codex exits |
| 69 | - continuously inject addressed IRC messages into the live Codex terminal |
| 70 | - mirror assistant output and tool activity from the active session log |
| 71 | - use `pkg/sessionrelay` for both `http` and `irc` transport modes |
| 72 | - let the existing hooks remain the pre-tool fallback path |
| 73 | |
| 74 | Transport modes: |
| 75 | - `SCUTTLEBOT_TRANSPORT=http` uses the working HTTP bridge path and presence heartbeats |
| 76 | - `SCUTTLEBOT_TRANSPORT=irc` connects the live session nick directly to Ergo over SASL |
| 77 | - in `irc` mode, `SCUTTLEBOT_IRC_PASS` uses a fixed NickServ password; otherwise the broker auto-registers the ephemeral session nick through `/v1/agents/register` and deletes it on clean exit by default |
| 78 | |
| 79 | To disable the relay without uninstalling: |
| 80 | |
| 81 | ```bash |
| 82 | SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay |
| 83 |
+14
-2
| --- skills/openai-relay/hooks/README.md | ||
| +++ skills/openai-relay/hooks/README.md | ||
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | # Codex Hook Primer |
| 2 | 2 | |
| 3 | 3 | These hooks are the pre-tool fallback path for a live Codex tool loop. |
| 4 | 4 | Continuous IRC-to-terminal input plus outbound message and tool mirroring are |
| 5 | -handled by the compiled `cmd/codex-relay` broker. | |
| 5 | +handled by the compiled `cmd/codex-relay` broker, which now sits on the shared | |
| 6 | +`pkg/sessionrelay` connector package. | |
| 6 | 7 | |
| 7 | 8 | If you need to add another runtime later, use |
| 8 | 9 | [`../../scuttlebot-relay/ADDING_AGENTS.md`](../../scuttlebot-relay/ADDING_AGENTS.md) |
| 9 | 10 | as the shared authoring contract. |
| 10 | 11 | |
| @@ -74,13 +75,18 @@ | ||
| 74 | 75 | - `curl` and `jq` available on `PATH` |
| 75 | 76 | |
| 76 | 77 | Optional: |
| 77 | 78 | - `SCUTTLEBOT_NICK` |
| 78 | 79 | - `SCUTTLEBOT_SESSION_ID` |
| 80 | +- `SCUTTLEBOT_TRANSPORT` | |
| 81 | +- `SCUTTLEBOT_IRC_ADDR` | |
| 82 | +- `SCUTTLEBOT_IRC_PASS` | |
| 83 | +- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` | |
| 79 | 84 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| 80 | 85 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE` |
| 81 | 86 | - `SCUTTLEBOT_POLL_INTERVAL` |
| 87 | +- `SCUTTLEBOT_PRESENCE_HEARTBEAT` | |
| 82 | 88 | - `SCUTTLEBOT_CONFIG_FILE` |
| 83 | 89 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER` |
| 84 | 90 | |
| 85 | 91 | Example: |
| 86 | 92 | |
| @@ -95,13 +101,16 @@ | ||
| 95 | 101 | ```bash |
| 96 | 102 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 97 | 103 | SCUTTLEBOT_URL=http://localhost:8080 |
| 98 | 104 | SCUTTLEBOT_TOKEN=... |
| 99 | 105 | SCUTTLEBOT_CHANNEL=general |
| 106 | +SCUTTLEBOT_TRANSPORT=http | |
| 107 | +SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 | |
| 100 | 108 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 101 | 109 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 102 | 110 | SCUTTLEBOT_POLL_INTERVAL=2s |
| 111 | +SCUTTLEBOT_PRESENCE_HEARTBEAT=60s | |
| 103 | 112 | EOF |
| 104 | 113 | ``` |
| 105 | 114 | |
| 106 | 115 | Disable the hooks entirely: |
| 107 | 116 | |
| @@ -277,15 +286,18 @@ | ||
| 277 | 286 | ``` |
| 278 | 287 | |
| 279 | 288 | ## Operational notes |
| 280 | 289 | |
| 281 | 290 | - `cmd/codex-relay` continuously polls for addressed IRC messages and injects them into the live Codex PTY. |
| 291 | +- `cmd/codex-relay` can do that over either the HTTP bridge API or a real IRC socket. | |
| 282 | 292 | - `cmd/codex-relay` also tails the active session JSONL and mirrors assistant output plus tool activity into IRC. |
| 283 | 293 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions. |
| 284 | 294 | - With the default `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1`, the broker only sends Ctrl-C when Codex appears busy. Idle sessions are injected directly and auto-submitted so the broker does not accidentally quit Codex at the prompt. |
| 285 | 295 | - `SCUTTLEBOT_POLL_INTERVAL=1s` changes the broker poll interval. |
| 286 | -- The hooks use the scuttlebot HTTP API, not direct IRC. | |
| 296 | +- `SCUTTLEBOT_TRANSPORT=irc` gives the live session a true IRC presence; `SCUTTLEBOT_IRC_PASS` skips auto-registration if you already manage the NickServ account yourself. | |
| 297 | +- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` keeps quiet HTTP-mode sessions in the active user list without visible chatter. | |
| 298 | +- The hooks themselves still use the scuttlebot HTTP API, not direct IRC. | |
| 287 | 299 | - If scuttlebot is down or unreachable, the hooks soft-fail and return quickly. |
| 288 | 300 | - `SCUTTLEBOT_HOOKS_ENABLED=0` disables both hooks explicitly. |
| 289 | 301 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` suppresses `scuttlebot-post.sh` so broker-launched sessions do not duplicate activity posts. |
| 290 | 302 | - `../scripts/install-codex-relay.sh --disabled` writes that disabled state into the shared env file. |
| 291 | 303 | - For fleet launch instructions, see [`../FLEET.md`](../FLEET.md). |
| 292 | 304 |
| --- skills/openai-relay/hooks/README.md | |
| +++ skills/openai-relay/hooks/README.md | |
| @@ -1,10 +1,11 @@ | |
| 1 | # Codex Hook Primer |
| 2 | |
| 3 | These hooks are the pre-tool fallback path for a live Codex tool loop. |
| 4 | Continuous IRC-to-terminal input plus outbound message and tool mirroring are |
| 5 | handled by the compiled `cmd/codex-relay` broker. |
| 6 | |
| 7 | If you need to add another runtime later, use |
| 8 | [`../../scuttlebot-relay/ADDING_AGENTS.md`](../../scuttlebot-relay/ADDING_AGENTS.md) |
| 9 | as the shared authoring contract. |
| 10 | |
| @@ -74,13 +75,18 @@ | |
| 74 | - `curl` and `jq` available on `PATH` |
| 75 | |
| 76 | Optional: |
| 77 | - `SCUTTLEBOT_NICK` |
| 78 | - `SCUTTLEBOT_SESSION_ID` |
| 79 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| 80 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE` |
| 81 | - `SCUTTLEBOT_POLL_INTERVAL` |
| 82 | - `SCUTTLEBOT_CONFIG_FILE` |
| 83 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER` |
| 84 | |
| 85 | Example: |
| 86 | |
| @@ -95,13 +101,16 @@ | |
| 95 | ```bash |
| 96 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 97 | SCUTTLEBOT_URL=http://localhost:8080 |
| 98 | SCUTTLEBOT_TOKEN=... |
| 99 | SCUTTLEBOT_CHANNEL=general |
| 100 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 101 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 102 | SCUTTLEBOT_POLL_INTERVAL=2s |
| 103 | EOF |
| 104 | ``` |
| 105 | |
| 106 | Disable the hooks entirely: |
| 107 | |
| @@ -277,15 +286,18 @@ | |
| 277 | ``` |
| 278 | |
| 279 | ## Operational notes |
| 280 | |
| 281 | - `cmd/codex-relay` continuously polls for addressed IRC messages and injects them into the live Codex PTY. |
| 282 | - `cmd/codex-relay` also tails the active session JSONL and mirrors assistant output plus tool activity into IRC. |
| 283 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions. |
| 284 | - With the default `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1`, the broker only sends Ctrl-C when Codex appears busy. Idle sessions are injected directly and auto-submitted so the broker does not accidentally quit Codex at the prompt. |
| 285 | - `SCUTTLEBOT_POLL_INTERVAL=1s` changes the broker poll interval. |
| 286 | - The hooks use the scuttlebot HTTP API, not direct IRC. |
| 287 | - If scuttlebot is down or unreachable, the hooks soft-fail and return quickly. |
| 288 | - `SCUTTLEBOT_HOOKS_ENABLED=0` disables both hooks explicitly. |
| 289 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` suppresses `scuttlebot-post.sh` so broker-launched sessions do not duplicate activity posts. |
| 290 | - `../scripts/install-codex-relay.sh --disabled` writes that disabled state into the shared env file. |
| 291 | - For fleet launch instructions, see [`../FLEET.md`](../FLEET.md). |
| 292 |
| --- skills/openai-relay/hooks/README.md | |
| +++ skills/openai-relay/hooks/README.md | |
| @@ -1,10 +1,11 @@ | |
| 1 | # Codex Hook Primer |
| 2 | |
| 3 | These hooks are the pre-tool fallback path for a live Codex tool loop. |
| 4 | Continuous IRC-to-terminal input plus outbound message and tool mirroring are |
| 5 | handled by the compiled `cmd/codex-relay` broker, which now sits on the shared |
| 6 | `pkg/sessionrelay` connector package. |
| 7 | |
| 8 | If you need to add another runtime later, use |
| 9 | [`../../scuttlebot-relay/ADDING_AGENTS.md`](../../scuttlebot-relay/ADDING_AGENTS.md) |
| 10 | as the shared authoring contract. |
| 11 | |
| @@ -74,13 +75,18 @@ | |
| 75 | - `curl` and `jq` available on `PATH` |
| 76 | |
| 77 | Optional: |
| 78 | - `SCUTTLEBOT_NICK` |
| 79 | - `SCUTTLEBOT_SESSION_ID` |
| 80 | - `SCUTTLEBOT_TRANSPORT` |
| 81 | - `SCUTTLEBOT_IRC_ADDR` |
| 82 | - `SCUTTLEBOT_IRC_PASS` |
| 83 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` |
| 84 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| 85 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE` |
| 86 | - `SCUTTLEBOT_POLL_INTERVAL` |
| 87 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT` |
| 88 | - `SCUTTLEBOT_CONFIG_FILE` |
| 89 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER` |
| 90 | |
| 91 | Example: |
| 92 | |
| @@ -95,13 +101,16 @@ | |
| 101 | ```bash |
| 102 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 103 | SCUTTLEBOT_URL=http://localhost:8080 |
| 104 | SCUTTLEBOT_TOKEN=... |
| 105 | SCUTTLEBOT_CHANNEL=general |
| 106 | SCUTTLEBOT_TRANSPORT=http |
| 107 | SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 |
| 108 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 109 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 110 | SCUTTLEBOT_POLL_INTERVAL=2s |
| 111 | SCUTTLEBOT_PRESENCE_HEARTBEAT=60s |
| 112 | EOF |
| 113 | ``` |
| 114 | |
| 115 | Disable the hooks entirely: |
| 116 | |
| @@ -277,15 +286,18 @@ | |
| 286 | ``` |
| 287 | |
| 288 | ## Operational notes |
| 289 | |
| 290 | - `cmd/codex-relay` continuously polls for addressed IRC messages and injects them into the live Codex PTY. |
| 291 | - `cmd/codex-relay` can do that over either the HTTP bridge API or a real IRC socket. |
| 292 | - `cmd/codex-relay` also tails the active session JSONL and mirrors assistant output plus tool activity into IRC. |
| 293 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions. |
| 294 | - With the default `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1`, the broker only sends Ctrl-C when Codex appears busy. Idle sessions are injected directly and auto-submitted so the broker does not accidentally quit Codex at the prompt. |
| 295 | - `SCUTTLEBOT_POLL_INTERVAL=1s` changes the broker poll interval. |
| 296 | - `SCUTTLEBOT_TRANSPORT=irc` gives the live session a true IRC presence; `SCUTTLEBOT_IRC_PASS` skips auto-registration if you already manage the NickServ account yourself. |
| 297 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` keeps quiet HTTP-mode sessions in the active user list without visible chatter. |
| 298 | - The hooks themselves still use the scuttlebot HTTP API, not direct IRC. |
| 299 | - If scuttlebot is down or unreachable, the hooks soft-fail and return quickly. |
| 300 | - `SCUTTLEBOT_HOOKS_ENABLED=0` disables both hooks explicitly. |
| 301 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` suppresses `scuttlebot-post.sh` so broker-launched sessions do not duplicate activity posts. |
| 302 | - `../scripts/install-codex-relay.sh --disabled` writes that disabled state into the shared env file. |
| 303 | - For fleet launch instructions, see [`../FLEET.md`](../FLEET.md). |
| 304 |
+44
-2
| --- skills/openai-relay/install.md | ||
| +++ skills/openai-relay/install.md | ||
| @@ -9,21 +9,21 @@ | ||
| 9 | 9 | continuously while the session is running. |
| 10 | 10 | |
| 11 | 11 | All source-of-truth code lives in this repo: |
| 12 | 12 | - installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh) |
| 13 | 13 | - broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go) |
| 14 | +- shared connector: [`../../pkg/sessionrelay/`](../../pkg/sessionrelay/) | |
| 14 | 15 | - dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh) |
| 15 | 16 | - hook scripts: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh) |
| 16 | 17 | - fleet rollout guide: [`FLEET.md`](FLEET.md) |
| 17 | 18 | |
| 18 | 19 | Files under `~/.codex/`, `~/.local/bin/`, and `~/.config/` are installed copies. |
| 19 | 20 | The repo remains the source of truth. |
| 20 | 21 | |
| 21 | 22 | ## Prerequisites |
| 22 | 23 | - `codex`, `go`, `curl`, and `jq` on `PATH` |
| 23 | -- A registered scuttlebot agent nick plus its SASL passphrase | |
| 24 | -- Scuttlebot API token for gateway mode | |
| 24 | +- Scuttlebot API token for gateway mode and broker registration | |
| 25 | 25 | - The `openai` backend configured on the daemon |
| 26 | 26 | - Direct mode only: `OPENAI_API_KEY` |
| 27 | 27 | |
| 28 | 28 | Quick connectivity check: |
| 29 | 29 | ```bash |
| @@ -56,12 +56,46 @@ | ||
| 56 | 56 | Runtime behavior: |
| 57 | 57 | - `cmd/codex-relay` keeps Codex on a real PTY |
| 58 | 58 | - it posts `online` immediately on launch |
| 59 | 59 | - it mirrors assistant messages and tool activity from the active session log |
| 60 | 60 | - it polls scuttlebot continuously for addressed operator messages |
| 61 | +- it uses the shared `pkg/sessionrelay` connector with selectable transport | |
| 61 | 62 | - by default it interrupts only when Codex appears busy; idle sessions are injected directly so the broker does not accidentally quit Codex |
| 62 | 63 | - the shell hooks still keep the pre-tool block path, and `scuttlebot-post.sh` remains available as a non-broker activity fallback |
| 64 | + | |
| 65 | +### Transport modes | |
| 66 | + | |
| 67 | +`codex-relay` supports two transport modes behind the same broker: | |
| 68 | + | |
| 69 | +- `SCUTTLEBOT_TRANSPORT=http` | |
| 70 | + - default | |
| 71 | + - uses the existing HTTP bridge API | |
| 72 | + - keeps web/bridge semantics | |
| 73 | + - now uses `/v1/channels/{channel}/presence` heartbeats so quiet sessions stay visible in the active user list | |
| 74 | + | |
| 75 | +- `SCUTTLEBOT_TRANSPORT=irc` | |
| 76 | + - connects the live session nick directly to Ergo over SASL | |
| 77 | + - gives true IRC presence, join/part semantics, and `NAMES` visibility | |
| 78 | + - uses `SCUTTLEBOT_IRC_PASS` if you provide one | |
| 79 | + - otherwise auto-registers the ephemeral session nick through `/v1/agents/register` using the bearer token, then deletes it on clean exit by default | |
| 80 | + | |
| 81 | +Common knobs: | |
| 82 | +- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` | |
| 83 | +- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` | |
| 84 | +- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1` | |
| 85 | + | |
| 86 | +Examples: | |
| 87 | + | |
| 88 | +```bash | |
| 89 | +# HTTP bridge path | |
| 90 | +SCUTTLEBOT_TRANSPORT=http ~/.local/bin/codex-relay | |
| 91 | + | |
| 92 | +# Real IRC-connected terminal broker | |
| 93 | +SCUTTLEBOT_TRANSPORT=irc \ | |
| 94 | +SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 \ | |
| 95 | +~/.local/bin/codex-relay | |
| 96 | +``` | |
| 63 | 97 | |
| 64 | 98 | Disable the relay without uninstalling: |
| 65 | 99 | |
| 66 | 100 | ```bash |
| 67 | 101 | SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay |
| @@ -124,13 +158,16 @@ | ||
| 124 | 158 | ```bash |
| 125 | 159 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 126 | 160 | SCUTTLEBOT_URL=http://localhost:8080 |
| 127 | 161 | SCUTTLEBOT_TOKEN=<your-bearer-token> |
| 128 | 162 | SCUTTLEBOT_CHANNEL=general |
| 163 | +SCUTTLEBOT_TRANSPORT=http | |
| 164 | +SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 | |
| 129 | 165 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 130 | 166 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 131 | 167 | SCUTTLEBOT_POLL_INTERVAL=2s |
| 168 | +SCUTTLEBOT_PRESENCE_HEARTBEAT=60s | |
| 132 | 169 | EOF |
| 133 | 170 | ``` |
| 134 | 171 | |
| 135 | 172 | Launch Codex through the broker: |
| 136 | 173 | |
| @@ -150,10 +187,15 @@ | ||
| 150 | 187 | - soft-fails if scuttlebot is disabled or unreachable |
| 151 | 188 | |
| 152 | 189 | Optional broker env: |
| 153 | 190 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions |
| 154 | 191 | - `SCUTTLEBOT_POLL_INTERVAL=1s` tunes how often the broker polls for new addressed IRC messages |
| 192 | +- `SCUTTLEBOT_TRANSPORT=irc` switches from the HTTP bridge path to a real IRC socket | |
| 193 | +- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` points the real IRC transport at Ergo | |
| 194 | +- `SCUTTLEBOT_IRC_PASS=<passphrase>` skips auto-registration and uses a fixed NickServ password | |
| 195 | +- `SCUTTLEBOT_PRESENCE_HEARTBEAT=0` disables HTTP presence heartbeats | |
| 196 | +- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks in the registry after clean exit | |
| 155 | 197 | |
| 156 | 198 | If you want `codex` itself to always use the wrapper, prefer a shell alias: |
| 157 | 199 | |
| 158 | 200 | ```bash |
| 159 | 201 | alias codex="$HOME/.local/bin/codex-relay" |
| 160 | 202 |
| --- skills/openai-relay/install.md | |
| +++ skills/openai-relay/install.md | |
| @@ -9,21 +9,21 @@ | |
| 9 | continuously while the session is running. |
| 10 | |
| 11 | All source-of-truth code lives in this repo: |
| 12 | - installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh) |
| 13 | - broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go) |
| 14 | - dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh) |
| 15 | - hook scripts: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh) |
| 16 | - fleet rollout guide: [`FLEET.md`](FLEET.md) |
| 17 | |
| 18 | Files under `~/.codex/`, `~/.local/bin/`, and `~/.config/` are installed copies. |
| 19 | The repo remains the source of truth. |
| 20 | |
| 21 | ## Prerequisites |
| 22 | - `codex`, `go`, `curl`, and `jq` on `PATH` |
| 23 | - A registered scuttlebot agent nick plus its SASL passphrase |
| 24 | - Scuttlebot API token for gateway mode |
| 25 | - The `openai` backend configured on the daemon |
| 26 | - Direct mode only: `OPENAI_API_KEY` |
| 27 | |
| 28 | Quick connectivity check: |
| 29 | ```bash |
| @@ -56,12 +56,46 @@ | |
| 56 | Runtime behavior: |
| 57 | - `cmd/codex-relay` keeps Codex on a real PTY |
| 58 | - it posts `online` immediately on launch |
| 59 | - it mirrors assistant messages and tool activity from the active session log |
| 60 | - it polls scuttlebot continuously for addressed operator messages |
| 61 | - by default it interrupts only when Codex appears busy; idle sessions are injected directly so the broker does not accidentally quit Codex |
| 62 | - the shell hooks still keep the pre-tool block path, and `scuttlebot-post.sh` remains available as a non-broker activity fallback |
| 63 | |
| 64 | Disable the relay without uninstalling: |
| 65 | |
| 66 | ```bash |
| 67 | SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay |
| @@ -124,13 +158,16 @@ | |
| 124 | ```bash |
| 125 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 126 | SCUTTLEBOT_URL=http://localhost:8080 |
| 127 | SCUTTLEBOT_TOKEN=<your-bearer-token> |
| 128 | SCUTTLEBOT_CHANNEL=general |
| 129 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 130 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 131 | SCUTTLEBOT_POLL_INTERVAL=2s |
| 132 | EOF |
| 133 | ``` |
| 134 | |
| 135 | Launch Codex through the broker: |
| 136 | |
| @@ -150,10 +187,15 @@ | |
| 150 | - soft-fails if scuttlebot is disabled or unreachable |
| 151 | |
| 152 | Optional broker env: |
| 153 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions |
| 154 | - `SCUTTLEBOT_POLL_INTERVAL=1s` tunes how often the broker polls for new addressed IRC messages |
| 155 | |
| 156 | If you want `codex` itself to always use the wrapper, prefer a shell alias: |
| 157 | |
| 158 | ```bash |
| 159 | alias codex="$HOME/.local/bin/codex-relay" |
| 160 |
| --- skills/openai-relay/install.md | |
| +++ skills/openai-relay/install.md | |
| @@ -9,21 +9,21 @@ | |
| 9 | continuously while the session is running. |
| 10 | |
| 11 | All source-of-truth code lives in this repo: |
| 12 | - installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh) |
| 13 | - broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go) |
| 14 | - shared connector: [`../../pkg/sessionrelay/`](../../pkg/sessionrelay/) |
| 15 | - dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh) |
| 16 | - hook scripts: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh) |
| 17 | - fleet rollout guide: [`FLEET.md`](FLEET.md) |
| 18 | |
| 19 | Files under `~/.codex/`, `~/.local/bin/`, and `~/.config/` are installed copies. |
| 20 | The repo remains the source of truth. |
| 21 | |
| 22 | ## Prerequisites |
| 23 | - `codex`, `go`, `curl`, and `jq` on `PATH` |
| 24 | - Scuttlebot API token for gateway mode and broker registration |
| 25 | - The `openai` backend configured on the daemon |
| 26 | - Direct mode only: `OPENAI_API_KEY` |
| 27 | |
| 28 | Quick connectivity check: |
| 29 | ```bash |
| @@ -56,12 +56,46 @@ | |
| 56 | Runtime behavior: |
| 57 | - `cmd/codex-relay` keeps Codex on a real PTY |
| 58 | - it posts `online` immediately on launch |
| 59 | - it mirrors assistant messages and tool activity from the active session log |
| 60 | - it polls scuttlebot continuously for addressed operator messages |
| 61 | - it uses the shared `pkg/sessionrelay` connector with selectable transport |
| 62 | - by default it interrupts only when Codex appears busy; idle sessions are injected directly so the broker does not accidentally quit Codex |
| 63 | - the shell hooks still keep the pre-tool block path, and `scuttlebot-post.sh` remains available as a non-broker activity fallback |
| 64 | |
| 65 | ### Transport modes |
| 66 | |
| 67 | `codex-relay` supports two transport modes behind the same broker: |
| 68 | |
| 69 | - `SCUTTLEBOT_TRANSPORT=http` |
| 70 | - default |
| 71 | - uses the existing HTTP bridge API |
| 72 | - keeps web/bridge semantics |
| 73 | - now uses `/v1/channels/{channel}/presence` heartbeats so quiet sessions stay visible in the active user list |
| 74 | |
| 75 | - `SCUTTLEBOT_TRANSPORT=irc` |
| 76 | - connects the live session nick directly to Ergo over SASL |
| 77 | - gives true IRC presence, join/part semantics, and `NAMES` visibility |
| 78 | - uses `SCUTTLEBOT_IRC_PASS` if you provide one |
| 79 | - otherwise auto-registers the ephemeral session nick through `/v1/agents/register` using the bearer token, then deletes it on clean exit by default |
| 80 | |
| 81 | Common knobs: |
| 82 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` |
| 83 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` |
| 84 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1` |
| 85 | |
| 86 | Examples: |
| 87 | |
| 88 | ```bash |
| 89 | # HTTP bridge path |
| 90 | SCUTTLEBOT_TRANSPORT=http ~/.local/bin/codex-relay |
| 91 | |
| 92 | # Real IRC-connected terminal broker |
| 93 | SCUTTLEBOT_TRANSPORT=irc \ |
| 94 | SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 \ |
| 95 | ~/.local/bin/codex-relay |
| 96 | ``` |
| 97 | |
| 98 | Disable the relay without uninstalling: |
| 99 | |
| 100 | ```bash |
| 101 | SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay |
| @@ -124,13 +158,16 @@ | |
| 158 | ```bash |
| 159 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 160 | SCUTTLEBOT_URL=http://localhost:8080 |
| 161 | SCUTTLEBOT_TOKEN=<your-bearer-token> |
| 162 | SCUTTLEBOT_CHANNEL=general |
| 163 | SCUTTLEBOT_TRANSPORT=http |
| 164 | SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 |
| 165 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 166 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 167 | SCUTTLEBOT_POLL_INTERVAL=2s |
| 168 | SCUTTLEBOT_PRESENCE_HEARTBEAT=60s |
| 169 | EOF |
| 170 | ``` |
| 171 | |
| 172 | Launch Codex through the broker: |
| 173 | |
| @@ -150,10 +187,15 @@ | |
| 187 | - soft-fails if scuttlebot is disabled or unreachable |
| 188 | |
| 189 | Optional broker env: |
| 190 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions |
| 191 | - `SCUTTLEBOT_POLL_INTERVAL=1s` tunes how often the broker polls for new addressed IRC messages |
| 192 | - `SCUTTLEBOT_TRANSPORT=irc` switches from the HTTP bridge path to a real IRC socket |
| 193 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` points the real IRC transport at Ergo |
| 194 | - `SCUTTLEBOT_IRC_PASS=<passphrase>` skips auto-registration and uses a fixed NickServ password |
| 195 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=0` disables HTTP presence heartbeats |
| 196 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks in the registry after clean exit |
| 197 | |
| 198 | If you want `codex` itself to always use the wrapper, prefer a shell alias: |
| 199 | |
| 200 | ```bash |
| 201 | alias codex="$HOME/.local/bin/codex-relay" |
| 202 |
| --- skills/openai-relay/scripts/install-codex-relay.sh | ||
| +++ skills/openai-relay/scripts/install-codex-relay.sh | ||
| @@ -10,10 +10,12 @@ | ||
| 10 | 10 | |
| 11 | 11 | Options: |
| 12 | 12 | --url URL Set SCUTTLEBOT_URL in the shared env file. |
| 13 | 13 | --token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
| 14 | 14 | --channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
| 15 | + --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http. | |
| 16 | + --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667. | |
| 15 | 17 | --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
| 16 | 18 | --disabled Write SCUTTLEBOT_HOOKS_ENABLED=0. |
| 17 | 19 | --config-file PATH Shared env file path. Default: ~/.config/scuttlebot-relay.env |
| 18 | 20 | --hooks-dir PATH Codex hooks install dir. Default: ~/.codex/hooks |
| 19 | 21 | --hooks-json PATH Codex hooks config JSON. Default: ~/.codex/hooks.json |
| @@ -23,13 +25,17 @@ | ||
| 23 | 25 | |
| 24 | 26 | Environment defaults: |
| 25 | 27 | SCUTTLEBOT_URL |
| 26 | 28 | SCUTTLEBOT_TOKEN |
| 27 | 29 | SCUTTLEBOT_CHANNEL |
| 30 | + SCUTTLEBOT_TRANSPORT | |
| 31 | + SCUTTLEBOT_IRC_ADDR | |
| 32 | + SCUTTLEBOT_IRC_PASS | |
| 28 | 33 | SCUTTLEBOT_HOOKS_ENABLED |
| 29 | 34 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
| 30 | 35 | SCUTTLEBOT_POLL_INTERVAL |
| 36 | + SCUTTLEBOT_PRESENCE_HEARTBEAT | |
| 31 | 37 | SCUTTLEBOT_CONFIG_FILE |
| 32 | 38 | CODEX_HOOKS_DIR |
| 33 | 39 | CODEX_HOOKS_JSON |
| 34 | 40 | CODEX_CONFIG_TOML |
| 35 | 41 | CODEX_BIN_DIR |
| @@ -46,13 +52,17 @@ | ||
| 46 | 52 | REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
| 47 | 53 | |
| 48 | 54 | SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
| 49 | 55 | SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
| 50 | 56 | SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
| 57 | +SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}" | |
| 58 | +SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}" | |
| 59 | +SCUTTLEBOT_IRC_PASS_VALUE="${SCUTTLEBOT_IRC_PASS:-}" | |
| 51 | 60 | SCUTTLEBOT_HOOKS_ENABLED_VALUE="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 52 | 61 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE="${SCUTTLEBOT_INTERRUPT_ON_MESSAGE:-1}" |
| 53 | 62 | SCUTTLEBOT_POLL_INTERVAL_VALUE="${SCUTTLEBOT_POLL_INTERVAL:-2s}" |
| 63 | +SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE="${SCUTTLEBOT_PRESENCE_HEARTBEAT:-60s}" | |
| 54 | 64 | |
| 55 | 65 | CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}" |
| 56 | 66 | HOOKS_DIR="${CODEX_HOOKS_DIR:-$HOME/.codex/hooks}" |
| 57 | 67 | HOOKS_JSON="${CODEX_HOOKS_JSON:-$HOME/.codex/hooks.json}" |
| 58 | 68 | CODEX_CONFIG="${CODEX_CONFIG_TOML:-$HOME/.codex/config.toml}" |
| @@ -69,10 +79,18 @@ | ||
| 69 | 79 | shift 2 |
| 70 | 80 | ;; |
| 71 | 81 | --channel) |
| 72 | 82 | SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
| 73 | 83 | shift 2 |
| 84 | + ;; | |
| 85 | + --transport) | |
| 86 | + SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}" | |
| 87 | + shift 2 | |
| 88 | + ;; | |
| 89 | + --irc-addr) | |
| 90 | + SCUTTLEBOT_IRC_ADDR_VALUE="${2:?missing value for --irc-addr}" | |
| 91 | + shift 2 | |
| 74 | 92 | ;; |
| 75 | 93 | --enabled) |
| 76 | 94 | SCUTTLEBOT_HOOKS_ENABLED_VALUE=1 |
| 77 | 95 | shift |
| 78 | 96 | ;; |
| @@ -301,14 +319,20 @@ | ||
| 301 | 319 | if [ -n "$SCUTTLEBOT_TOKEN_VALUE" ]; then |
| 302 | 320 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
| 303 | 321 | fi |
| 304 | 322 | if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
| 305 | 323 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
| 324 | +fi | |
| 325 | +upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE" | |
| 326 | +upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE" | |
| 327 | +if [ -n "$SCUTTLEBOT_IRC_PASS_VALUE" ]; then | |
| 328 | + upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE" | |
| 306 | 329 | fi |
| 307 | 330 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_HOOKS_ENABLED "$SCUTTLEBOT_HOOKS_ENABLED_VALUE" |
| 308 | 331 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_INTERRUPT_ON_MESSAGE "$SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE" |
| 309 | 332 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_POLL_INTERVAL "$SCUTTLEBOT_POLL_INTERVAL_VALUE" |
| 333 | +upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_PRESENCE_HEARTBEAT "$SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE" | |
| 310 | 334 | |
| 311 | 335 | printf 'Installed Codex relay files:\n' |
| 312 | 336 | printf ' hooks: %s\n' "$HOOKS_DIR" |
| 313 | 337 | printf ' hooks.json: %s\n' "$HOOKS_JSON" |
| 314 | 338 | printf ' config: %s\n' "$CODEX_CONFIG" |
| 315 | 339 |
| --- skills/openai-relay/scripts/install-codex-relay.sh | |
| +++ skills/openai-relay/scripts/install-codex-relay.sh | |
| @@ -10,10 +10,12 @@ | |
| 10 | |
| 11 | Options: |
| 12 | --url URL Set SCUTTLEBOT_URL in the shared env file. |
| 13 | --token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
| 14 | --channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
| 15 | --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
| 16 | --disabled Write SCUTTLEBOT_HOOKS_ENABLED=0. |
| 17 | --config-file PATH Shared env file path. Default: ~/.config/scuttlebot-relay.env |
| 18 | --hooks-dir PATH Codex hooks install dir. Default: ~/.codex/hooks |
| 19 | --hooks-json PATH Codex hooks config JSON. Default: ~/.codex/hooks.json |
| @@ -23,13 +25,17 @@ | |
| 23 | |
| 24 | Environment defaults: |
| 25 | SCUTTLEBOT_URL |
| 26 | SCUTTLEBOT_TOKEN |
| 27 | SCUTTLEBOT_CHANNEL |
| 28 | SCUTTLEBOT_HOOKS_ENABLED |
| 29 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
| 30 | SCUTTLEBOT_POLL_INTERVAL |
| 31 | SCUTTLEBOT_CONFIG_FILE |
| 32 | CODEX_HOOKS_DIR |
| 33 | CODEX_HOOKS_JSON |
| 34 | CODEX_CONFIG_TOML |
| 35 | CODEX_BIN_DIR |
| @@ -46,13 +52,17 @@ | |
| 46 | REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
| 47 | |
| 48 | SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
| 49 | SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
| 50 | SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
| 51 | SCUTTLEBOT_HOOKS_ENABLED_VALUE="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 52 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE="${SCUTTLEBOT_INTERRUPT_ON_MESSAGE:-1}" |
| 53 | SCUTTLEBOT_POLL_INTERVAL_VALUE="${SCUTTLEBOT_POLL_INTERVAL:-2s}" |
| 54 | |
| 55 | CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}" |
| 56 | HOOKS_DIR="${CODEX_HOOKS_DIR:-$HOME/.codex/hooks}" |
| 57 | HOOKS_JSON="${CODEX_HOOKS_JSON:-$HOME/.codex/hooks.json}" |
| 58 | CODEX_CONFIG="${CODEX_CONFIG_TOML:-$HOME/.codex/config.toml}" |
| @@ -69,10 +79,18 @@ | |
| 69 | shift 2 |
| 70 | ;; |
| 71 | --channel) |
| 72 | SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
| 73 | shift 2 |
| 74 | ;; |
| 75 | --enabled) |
| 76 | SCUTTLEBOT_HOOKS_ENABLED_VALUE=1 |
| 77 | shift |
| 78 | ;; |
| @@ -301,14 +319,20 @@ | |
| 301 | if [ -n "$SCUTTLEBOT_TOKEN_VALUE" ]; then |
| 302 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
| 303 | fi |
| 304 | if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
| 305 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
| 306 | fi |
| 307 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_HOOKS_ENABLED "$SCUTTLEBOT_HOOKS_ENABLED_VALUE" |
| 308 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_INTERRUPT_ON_MESSAGE "$SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE" |
| 309 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_POLL_INTERVAL "$SCUTTLEBOT_POLL_INTERVAL_VALUE" |
| 310 | |
| 311 | printf 'Installed Codex relay files:\n' |
| 312 | printf ' hooks: %s\n' "$HOOKS_DIR" |
| 313 | printf ' hooks.json: %s\n' "$HOOKS_JSON" |
| 314 | printf ' config: %s\n' "$CODEX_CONFIG" |
| 315 |
| --- skills/openai-relay/scripts/install-codex-relay.sh | |
| +++ skills/openai-relay/scripts/install-codex-relay.sh | |
| @@ -10,10 +10,12 @@ | |
| 10 | |
| 11 | Options: |
| 12 | --url URL Set SCUTTLEBOT_URL in the shared env file. |
| 13 | --token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
| 14 | --channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
| 15 | --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http. |
| 16 | --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667. |
| 17 | --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
| 18 | --disabled Write SCUTTLEBOT_HOOKS_ENABLED=0. |
| 19 | --config-file PATH Shared env file path. Default: ~/.config/scuttlebot-relay.env |
| 20 | --hooks-dir PATH Codex hooks install dir. Default: ~/.codex/hooks |
| 21 | --hooks-json PATH Codex hooks config JSON. Default: ~/.codex/hooks.json |
| @@ -23,13 +25,17 @@ | |
| 25 | |
| 26 | Environment defaults: |
| 27 | SCUTTLEBOT_URL |
| 28 | SCUTTLEBOT_TOKEN |
| 29 | SCUTTLEBOT_CHANNEL |
| 30 | SCUTTLEBOT_TRANSPORT |
| 31 | SCUTTLEBOT_IRC_ADDR |
| 32 | SCUTTLEBOT_IRC_PASS |
| 33 | SCUTTLEBOT_HOOKS_ENABLED |
| 34 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
| 35 | SCUTTLEBOT_POLL_INTERVAL |
| 36 | SCUTTLEBOT_PRESENCE_HEARTBEAT |
| 37 | SCUTTLEBOT_CONFIG_FILE |
| 38 | CODEX_HOOKS_DIR |
| 39 | CODEX_HOOKS_JSON |
| 40 | CODEX_CONFIG_TOML |
| 41 | CODEX_BIN_DIR |
| @@ -46,13 +52,17 @@ | |
| 52 | REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
| 53 | |
| 54 | SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
| 55 | SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
| 56 | SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
| 57 | SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}" |
| 58 | SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}" |
| 59 | SCUTTLEBOT_IRC_PASS_VALUE="${SCUTTLEBOT_IRC_PASS:-}" |
| 60 | SCUTTLEBOT_HOOKS_ENABLED_VALUE="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 61 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE="${SCUTTLEBOT_INTERRUPT_ON_MESSAGE:-1}" |
| 62 | SCUTTLEBOT_POLL_INTERVAL_VALUE="${SCUTTLEBOT_POLL_INTERVAL:-2s}" |
| 63 | SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE="${SCUTTLEBOT_PRESENCE_HEARTBEAT:-60s}" |
| 64 | |
| 65 | CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}" |
| 66 | HOOKS_DIR="${CODEX_HOOKS_DIR:-$HOME/.codex/hooks}" |
| 67 | HOOKS_JSON="${CODEX_HOOKS_JSON:-$HOME/.codex/hooks.json}" |
| 68 | CODEX_CONFIG="${CODEX_CONFIG_TOML:-$HOME/.codex/config.toml}" |
| @@ -69,10 +79,18 @@ | |
| 79 | shift 2 |
| 80 | ;; |
| 81 | --channel) |
| 82 | SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
| 83 | shift 2 |
| 84 | ;; |
| 85 | --transport) |
| 86 | SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}" |
| 87 | shift 2 |
| 88 | ;; |
| 89 | --irc-addr) |
| 90 | SCUTTLEBOT_IRC_ADDR_VALUE="${2:?missing value for --irc-addr}" |
| 91 | shift 2 |
| 92 | ;; |
| 93 | --enabled) |
| 94 | SCUTTLEBOT_HOOKS_ENABLED_VALUE=1 |
| 95 | shift |
| 96 | ;; |
| @@ -301,14 +319,20 @@ | |
| 319 | if [ -n "$SCUTTLEBOT_TOKEN_VALUE" ]; then |
| 320 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
| 321 | fi |
| 322 | if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
| 323 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
| 324 | fi |
| 325 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE" |
| 326 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE" |
| 327 | if [ -n "$SCUTTLEBOT_IRC_PASS_VALUE" ]; then |
| 328 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE" |
| 329 | fi |
| 330 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_HOOKS_ENABLED "$SCUTTLEBOT_HOOKS_ENABLED_VALUE" |
| 331 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_INTERRUPT_ON_MESSAGE "$SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE" |
| 332 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_POLL_INTERVAL "$SCUTTLEBOT_POLL_INTERVAL_VALUE" |
| 333 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_PRESENCE_HEARTBEAT "$SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE" |
| 334 | |
| 335 | printf 'Installed Codex relay files:\n' |
| 336 | printf ' hooks: %s\n' "$HOOKS_DIR" |
| 337 | printf ' hooks.json: %s\n' "$HOOKS_JSON" |
| 338 | printf ' config: %s\n' "$CODEX_CONFIG" |
| 339 |
| --- skills/scuttlebot-relay/ADDING_AGENTS.md | ||
| +++ skills/scuttlebot-relay/ADDING_AGENTS.md | ||
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | # Adding Another Agent Runtime |
| 2 | 2 | |
| 3 | 3 | This repo now has two concrete operator-control implementations: |
| 4 | 4 | - Claude hooks in `skills/scuttlebot-relay/hooks/` |
| 5 | 5 | - Codex broker + hooks in `cmd/codex-relay/` and `skills/openai-relay/hooks/` |
| 6 | + | |
| 7 | +Shared transport/runtime code now lives in `pkg/sessionrelay/`. Reuse that | |
| 8 | +before writing another relay client by hand. | |
| 6 | 9 | |
| 7 | 10 | If you add another agent runtime, do not invent a new relay model. Follow the |
| 8 | 11 | same control contract so operators get one consistent experience. |
| 9 | 12 | |
| 10 | 13 | ## The contract |
| @@ -35,10 +38,15 @@ | ||
| 35 | 38 | |
| 36 | 39 | Hooks remain useful for pre-action fallback and for runtimes that do not have a |
| 37 | 40 | broker yet, but hook-only telemetry is not the production pattern for |
| 38 | 41 | interactive sessions. |
| 39 | 42 | |
| 43 | +If the runtime needs the same channel send/receive/presence semantics as | |
| 44 | +`codex-relay`, start from `pkg/sessionrelay`: | |
| 45 | +- `TransportHTTP` for the bridge/API path | |
| 46 | +- `TransportIRC` for true SASL IRC presence with optional auto-registration via `/v1/agents/register` | |
| 47 | + | |
| 40 | 48 | ## Required environment contract |
| 41 | 49 | |
| 42 | 50 | All adapters should use the same environment variables: |
| 43 | 51 | - `SCUTTLEBOT_URL` |
| 44 | 52 | - `SCUTTLEBOT_TOKEN` |
| 45 | 53 |
| --- skills/scuttlebot-relay/ADDING_AGENTS.md | |
| +++ skills/scuttlebot-relay/ADDING_AGENTS.md | |
| @@ -1,10 +1,13 @@ | |
| 1 | # Adding Another Agent Runtime |
| 2 | |
| 3 | This repo now has two concrete operator-control implementations: |
| 4 | - Claude hooks in `skills/scuttlebot-relay/hooks/` |
| 5 | - Codex broker + hooks in `cmd/codex-relay/` and `skills/openai-relay/hooks/` |
| 6 | |
| 7 | If you add another agent runtime, do not invent a new relay model. Follow the |
| 8 | same control contract so operators get one consistent experience. |
| 9 | |
| 10 | ## The contract |
| @@ -35,10 +38,15 @@ | |
| 35 | |
| 36 | Hooks remain useful for pre-action fallback and for runtimes that do not have a |
| 37 | broker yet, but hook-only telemetry is not the production pattern for |
| 38 | interactive sessions. |
| 39 | |
| 40 | ## Required environment contract |
| 41 | |
| 42 | All adapters should use the same environment variables: |
| 43 | - `SCUTTLEBOT_URL` |
| 44 | - `SCUTTLEBOT_TOKEN` |
| 45 |
| --- skills/scuttlebot-relay/ADDING_AGENTS.md | |
| +++ skills/scuttlebot-relay/ADDING_AGENTS.md | |
| @@ -1,10 +1,13 @@ | |
| 1 | # Adding Another Agent Runtime |
| 2 | |
| 3 | This repo now has two concrete operator-control implementations: |
| 4 | - Claude hooks in `skills/scuttlebot-relay/hooks/` |
| 5 | - Codex broker + hooks in `cmd/codex-relay/` and `skills/openai-relay/hooks/` |
| 6 | |
| 7 | Shared transport/runtime code now lives in `pkg/sessionrelay/`. Reuse that |
| 8 | before writing another relay client by hand. |
| 9 | |
| 10 | If you add another agent runtime, do not invent a new relay model. Follow the |
| 11 | same control contract so operators get one consistent experience. |
| 12 | |
| 13 | ## The contract |
| @@ -35,10 +38,15 @@ | |
| 38 | |
| 39 | Hooks remain useful for pre-action fallback and for runtimes that do not have a |
| 40 | broker yet, but hook-only telemetry is not the production pattern for |
| 41 | interactive sessions. |
| 42 | |
| 43 | If the runtime needs the same channel send/receive/presence semantics as |
| 44 | `codex-relay`, start from `pkg/sessionrelay`: |
| 45 | - `TransportHTTP` for the bridge/API path |
| 46 | - `TransportIRC` for true SASL IRC presence with optional auto-registration via `/v1/agents/register` |
| 47 | |
| 48 | ## Required environment contract |
| 49 | |
| 50 | All adapters should use the same environment variables: |
| 51 | - `SCUTTLEBOT_URL` |
| 52 | - `SCUTTLEBOT_TOKEN` |
| 53 |