ScuttleBot

Add shared session relay transports

lmata 2026-04-01 04:46 trunk
Commit 24a217e9a4c108faed34ad2236c4daaf965690d2b0fe60735c3152155e580c61
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -1,17 +1,15 @@
11
package main
22
33
import (
44
"bufio"
5
- "bytes"
65
"context"
76
"encoding/json"
87
"errors"
98
"fmt"
109
"hash/crc32"
1110
"io"
12
- "net/http"
1311
"os"
1412
"os/exec"
1513
"os/signal"
1614
"path/filepath"
1715
"regexp"
@@ -20,21 +18,25 @@
2018
"sync"
2119
"syscall"
2220
"time"
2321
2422
"github.com/conflicthq/scuttlebot/pkg/ircagent"
23
+ "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
2524
"github.com/creack/pty"
2625
"golang.org/x/term"
2726
)
2827
2928
const (
3029
defaultRelayURL = "http://localhost:8080"
30
+ defaultIRCAddr = "127.0.0.1:6667"
3131
defaultChannel = "general"
32
+ defaultTransport = sessionrelay.TransportHTTP
3233
defaultPollInterval = 2 * time.Second
34
+ defaultConnectWait = 10 * time.Second
3335
defaultInjectDelay = 150 * time.Millisecond
3436
defaultBusyWindow = 1500 * time.Millisecond
35
- defaultRequestTimout = 3 * time.Second
37
+ defaultHeartbeat = 60 * time.Second
3638
defaultConfigFile = ".config/scuttlebot-relay.env"
3739
defaultScanInterval = 250 * time.Millisecond
3840
defaultDiscoverWait = 20 * time.Second
3941
defaultMirrorLineMax = 360
4042
)
@@ -61,34 +63,29 @@
6163
)
6264
6365
type config struct {
6466
CodexBin string
6567
ConfigFile string
68
+ Transport sessionrelay.Transport
6669
URL string
6770
Token string
71
+ IRCAddr string
72
+ IRCPass string
73
+ IRCAgentType string
74
+ IRCDeleteOnClose bool
6875
Channel string
6976
SessionID string
7077
Nick string
7178
HooksEnabled bool
7279
InterruptOnMessage bool
7380
PollInterval time.Duration
81
+ HeartbeatInterval time.Duration
7482
TargetCWD string
7583
Args []string
7684
}
7785
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
9087
9188
type relayState struct {
9289
mu sync.RWMutex
9390
lastBusy time.Time
9491
}
@@ -144,23 +141,55 @@
144141
}
145142
}
146143
147144
func run(cfg config) error {
148145
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
+ }()
162191
}
163192
164193
cmd := exec.Command(cfg.CodexBin, cfg.Args...)
165194
startedAt := time.Now()
166195
cmd.Env = append(os.Environ(),
@@ -169,17 +198,15 @@
169198
"SCUTTLEBOT_TOKEN="+cfg.Token,
170199
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
171200
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
172201
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
173202
"SCUTTLEBOT_NICK="+cfg.Nick,
174
- "SCUTTLEBOT_ACTIVITY_VIA_BROKER=1",
203
+ "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
175204
)
176
-
177
- ctx, cancel := context.WithCancel(context.Background())
178
- defer cancel()
179205
if relayActive {
180
- go mirrorSessionLoop(ctx, client, cfg, startedAt)
206
+ go mirrorSessionLoop(ctx, relay, cfg, startedAt)
207
+ go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
181208
}
182209
183210
if !isInteractiveTTY() {
184211
cmd.Stdin = os.Stdin
185212
cmd.Stdout = os.Stdout
@@ -186,16 +213,16 @@
186213
cmd.Stderr = os.Stderr
187214
err := cmd.Run()
188215
if err != nil {
189216
exitCode := exitStatus(err)
190217
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))
192219
}
193220
return err
194221
}
195222
if relayActive {
196
- _ = client.postStatus(cfg.Channel, cfg.Nick, "offline (exit 0)")
223
+ _ = relay.Post(context.Background(), "offline (exit 0)")
197224
}
198225
return nil
199226
}
200227
201228
ptmx, err := pty.Start(cmd)
@@ -229,34 +256,34 @@
229256
}()
230257
go func() {
231258
copyPTYOutput(ptmx, os.Stdout, state)
232259
}()
233260
if relayActive {
234
- go relayInputLoop(ctx, client, cfg, state, ptmx)
261
+ go relayInputLoop(ctx, relay, cfg, state, ptmx)
235262
}
236263
237264
err = cmd.Wait()
238265
cancel()
239266
240267
exitCode := exitStatus(err)
241268
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))
243270
}
244271
return err
245272
}
246273
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) {
248275
lastSeen := time.Now()
249276
ticker := time.NewTicker(cfg.PollInterval)
250277
defer ticker.Stop()
251278
252279
for {
253280
select {
254281
case <-ctx.Done():
255282
return
256283
case <-ticker.C:
257
- messages, err := client.fetchMessages(cfg.Channel)
284
+ messages, err := relay.MessagesSince(ctx, lastSeen)
258285
if err != nil {
259286
continue
260287
}
261288
batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
262289
if len(batch) == 0 {
@@ -267,10 +294,27 @@
267294
return
268295
}
269296
}
270297
}
271298
}
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
+}
272316
273317
func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error {
274318
lines := make([]string, 0, len(batch))
275319
for _, msg := range batch {
276320
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
@@ -343,15 +387,15 @@
343387
344388
func filterMessages(messages []message, since time.Time, nick string) ([]message, time.Time) {
345389
filtered := make([]message, 0, len(messages))
346390
newest := since
347391
for _, msg := range messages {
348
- if msg.Time.IsZero() || !msg.Time.After(since) {
392
+ if msg.At.IsZero() || !msg.At.After(since) {
349393
continue
350394
}
351
- if msg.Time.After(newest) {
352
- newest = msg.Time
395
+ if msg.At.After(newest) {
396
+ newest = msg.At
353397
}
354398
if msg.Nick == nick {
355399
continue
356400
}
357401
if _, ok := serviceBots[msg.Nick]; ok {
@@ -364,11 +408,11 @@
364408
continue
365409
}
366410
filtered = append(filtered, msg)
367411
}
368412
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)
370414
})
371415
return filtered, newest
372416
}
373417
374418
func loadConfig(args []string) (config, error) {
@@ -375,16 +419,22 @@
375419
fileConfig := readEnvFile(configFilePath())
376420
377421
cfg := config{
378422
CodexBin: getenvOr(fileConfig, "CODEX_BIN", "codex"),
379423
ConfigFile: getenvOr(fileConfig, "SCUTTLEBOT_CONFIG_FILE", configFilePath()),
424
+ Transport: sessionrelay.Transport(strings.ToLower(getenvOr(fileConfig, "SCUTTLEBOT_TRANSPORT", string(defaultTransport)))),
380425
URL: getenvOr(fileConfig, "SCUTTLEBOT_URL", defaultRelayURL),
381426
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),
382431
Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"),
383432
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
384433
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
385434
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
435
+ HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
386436
Args: append([]string(nil), args...),
387437
}
388438
389439
target, err := targetCWD(args)
390440
if err != nil {
@@ -408,11 +458,11 @@
408458
cfg.Nick = sanitize(nick)
409459
410460
if cfg.Channel == "" {
411461
cfg.Channel = defaultChannel
412462
}
413
- if cfg.Token == "" {
463
+ if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
414464
cfg.HooksEnabled = false
415465
}
416466
return cfg, nil
417467
}
418468
@@ -486,10 +536,25 @@
486536
if err != nil || d <= 0 {
487537
return fallback
488538
}
489539
return d
490540
}
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
+}
491556
492557
func targetCWD(args []string) (string, error) {
493558
cwd, err := os.Getwd()
494559
if err != nil {
495560
return "", err
@@ -543,72 +608,21 @@
543608
func defaultSessionID(target string) string {
544609
sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano())))
545610
return fmt.Sprintf("%08x", sum)
546611
}
547612
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) {
600614
sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
601615
if err != nil {
602616
return
603617
}
604618
_ = tailSessionFile(ctx, sessionPath, func(text string) {
605619
for _, line := range splitMirrorText(text) {
606620
if line == "" {
607621
continue
608622
}
609
- _ = client.postStatus(cfg.Channel, cfg.Nick, line)
623
+ _ = relay.Post(ctx, line)
610624
}
611625
})
612626
}
613627
614628
func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
615629
--- 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
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -14,15 +14,15 @@
1414
base := time.Date(2026, 3, 31, 21, 0, 0, 0, time.FixedZone("CST", -6*60*60))
1515
since := base.Add(-time.Second)
1616
nick := "codex-scuttlebot-1234"
1717
1818
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)},
2424
}
2525
2626
got, newest := filterMessages(messages, since, nick)
2727
if len(got) != 2 {
2828
t.Fatalf("len(filterMessages) = %d, want 2", len(got))
2929
--- 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
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -15,10 +15,13 @@
1515
Channels() []string
1616
JoinChannel(channel string)
1717
Messages(channel string) []bridge.Message
1818
Subscribe(channel string) (<-chan bridge.Message, func())
1919
Send(ctx context.Context, channel, text, senderNick string) error
20
+ Stats() bridge.Stats
21
+ TouchUser(channel, nick string)
22
+ Users(channel string) []string
2023
}
2124
2225
func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
2326
channel := "#" + r.PathValue("channel")
2427
s.bridge.JoinChannel(channel)
@@ -59,10 +62,36 @@
5962
writeError(w, http.StatusInternalServerError, "send failed")
6063
return
6164
}
6265
w.WriteHeader(http.StatusNoContent)
6366
}
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
+}
6493
6594
// handleChannelStream serves an SSE stream of IRC messages for a channel.
6695
// Auth is via ?token= query param because EventSource doesn't support custom headers.
6796
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
6897
token := r.URL.Query().Get("token")
6998
7099
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
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,53 +7,94 @@
77
88
import (
99
"log/slog"
1010
"net/http"
1111
12
+ "github.com/conflicthq/scuttlebot/internal/config"
1213
"github.com/conflicthq/scuttlebot/internal/registry"
1314
)
1415
1516
// Server is the scuttlebot HTTP API server.
1617
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
2127
}
2228
2329
// 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 {
2533
tokenSet := make(map[string]struct{}, len(tokens))
2634
for _, t := range tokens {
2735
tokenSet[t] = struct{}{}
2836
}
2937
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,
3447
}
3548
}
3649
3750
// Handler returns the HTTP handler with all routes registered.
3851
// /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
3952
func (s *Server) Handler() http.Handler {
4053
apiMux := http.NewServeMux()
4154
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
+ }
4261
apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
4362
apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
4463
apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
4564
apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
65
+ apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
4666
apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
67
+ apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
4768
if s.bridge != nil {
4869
apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
4970
apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
5071
apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
5172
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)
5282
}
5383
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
+
5494
outer := http.NewServeMux()
95
+ outer.HandleFunc("POST /login", s.handleLogin)
5596
outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
5697
http.Redirect(w, r, "/ui/", http.StatusFound)
5798
})
5899
outer.Handle("/ui/", s.uiFileServer())
59100
// SSE stream uses ?token= auth (EventSource can't send headers), registered
60101
--- 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
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -11,16 +11,18 @@
1111
"log/slog"
1212
"net"
1313
"strconv"
1414
"strings"
1515
"sync"
16
+ "sync/atomic"
1617
"time"
1718
1819
"github.com/lrstanley/girc"
1920
)
2021
2122
const botNick = "bridge"
23
+const defaultWebUserTTL = 5 * time.Minute
2224
2325
// Message is a single IRC message captured by the bridge.
2426
type Message struct {
2527
At time.Time `json:"at"`
2628
Channel string `json:"channel"`
@@ -60,10 +62,17 @@
6062
n := copy(out, r.msgs[r.head:])
6163
copy(out[n:], r.msgs[:r.head])
6264
}
6365
return out
6466
}
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
+}
6574
6675
// Bot is the IRC bridge bot.
6776
type Bot struct {
6877
ircAddr string
6978
nick string
@@ -75,36 +84,59 @@
7584
mu sync.RWMutex
7685
buffers map[string]*ringBuf
7786
subs map[string]map[uint64]chan Message
7887
subSeq uint64
7988
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
8096
8197
joinCh chan string
8298
client *girc.Client
8399
}
84100
85101
// 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 {
87103
if nick == "" {
88104
nick = botNick
89105
}
90106
if bufSize <= 0 {
91107
bufSize = 200
92108
}
109
+ if webUserTTL <= 0 {
110
+ webUserTTL = defaultWebUserTTL
111
+ }
93112
return &Bot{
94113
ircAddr: ircAddr,
95114
nick: nick,
96115
password: password,
97116
bufSize: bufSize,
98117
initChannels: channels,
118
+ webUsers: make(map[string]map[string]time.Time),
119
+ webUserTTL: webUserTTL,
99120
log: log,
100121
buffers: make(map[string]*ringBuf),
101122
subs: make(map[string]map[uint64]chan Message),
102123
joined: make(map[string]bool),
103124
joinCh: make(chan string, 32),
104125
}
105126
}
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
+}
106138
107139
// Name returns the bot's IRC nick.
108140
func (b *Bot) Name() string { return b.nick }
109141
110142
// Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -267,22 +299,106 @@
267299
ircText := text
268300
if senderNick != "" {
269301
ircText = "[" + senderNick + "] " + text
270302
}
271303
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
+
272310
// 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
+ }
273316
b.dispatch(Message{
274317
At: time.Now(),
275318
Channel: channel,
276
- Nick: b.nick,
277
- Text: ircText,
319
+ Nick: displayNick,
320
+ Text: text,
278321
})
279322
return nil
280323
}
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
+}
281396
282397
// dispatch pushes a message to the ring buffer and fans out to subscribers.
283398
func (b *Bot) dispatch(msg Message) {
399
+ b.msgTotal.Add(1)
284400
b.mu.Lock()
285401
defer b.mu.Unlock()
286402
rb := b.buffers[msg.Channel]
287403
if rb == nil {
288404
return
289405
290406
ADDED internal/bots/bridge/bridge_test.go
291407
ADDED pkg/sessionrelay/http.go
292408
ADDED pkg/sessionrelay/irc.go
293409
ADDED pkg/sessionrelay/sessionrelay.go
294410
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 }
--- 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;
--- 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
--- skills/irc-agent/README.md
+++ skills/irc-agent/README.md
@@ -6,11 +6,13 @@
66
`cmd/codex-agent`, and `cmd/gemini-agent` are thin wrappers with different defaults.
77
88
This document is for IRC-resident agents. Live terminal runtimes such as
99
`codex-relay` use a different pattern: a broker owns session presence,
1010
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.
1214
1315
---
1416
1517
## What scuttlebot gives you
1618
1719
--- 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
--- skills/openai-relay/FLEET.md
+++ skills/openai-relay/FLEET.md
@@ -4,10 +4,11 @@
44
operator-addressable through scuttlebot.
55
66
Source of truth:
77
- installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh)
88
- broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go)
9
+- shared connector: [`../../pkg/sessionrelay/`](../../pkg/sessionrelay/)
910
- dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh)
1011
- hooks: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh)
1112
- runtime docs: [`install.md`](install.md), [`hooks/README.md`](hooks/README.md)
1213
- shared runtime contract: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
1314
@@ -30,10 +31,14 @@
3031
- mirrored assistant messages from the active session log
3132
- continuous addressed IRC input injection into the live terminal session
3233
- explicit pre-tool fallback interrupts before the next action
3334
- `offline` post on exit
3435
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
+
3540
This is the production control path for a human-operated Codex terminal. If you
3641
want an always-on IRC-resident bot instead, use `cmd/codex-agent`.
3742
3843
## One-machine install
3944
@@ -64,11 +69,13 @@
6469
6570
```bash
6671
bash skills/openai-relay/scripts/install-codex-relay.sh \
6772
--url http://scuttlebot.internal:8080 \
6873
--token "$SCUTTLEBOT_TOKEN" \
69
- --channel fleet
74
+ --channel fleet \
75
+ --transport irc \
76
+ --irc-addr scuttlebot.internal:6667
7077
```
7178
7279
If you need hooks present but inactive until the server is live:
7380
7481
```bash
@@ -95,12 +102,17 @@
95102
- replace the real `codex` binary in `PATH`
96103
- force a fixed nick across sessions
97104
- require IRC to be up at install time
98105
99106
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
100111
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted
101112
- `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
102114
- `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
103115
104116
## Operator workflow
105117
106118
1. Watch the configured channel in scuttlebot.
107119
--- 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
--- skills/openai-relay/SKILL.md
+++ skills/openai-relay/SKILL.md
@@ -14,18 +14,18 @@
1414
hooks, and accept addressed instructions continuously while the session is running.
1515
1616
Source-of-truth files in the repo:
1717
- installer: `skills/openai-relay/scripts/install-codex-relay.sh`
1818
- broker: `cmd/codex-relay/main.go`
19
+- shared connector: `pkg/sessionrelay/`
1920
- dev wrapper: `skills/openai-relay/scripts/codex-relay.sh`
2021
- hooks: `skills/openai-relay/hooks/`
2122
- fleet rollout doc: `skills/openai-relay/FLEET.md`
2223
2324
Installed files under `~/.codex`, `~/.local/bin`, and `~/.config` are copies.
2425
2526
## Setup
26
-- Register a unique nick for each live Codex session, then store its passphrase.
2727
- Export gateway env vars:
2828
- `SCUTTLEBOT_URL` e.g. `http://localhost:8080`
2929
- `SCUTTLEBOT_TOKEN` bearer token
3030
- Ensure the daemon has an `openai` backend configured.
3131
- Ensure the relay endpoint is reachable: `curl -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" "$SCUTTLEBOT_URL/v1/status"`.
@@ -65,11 +65,18 @@
6565
- export a stable `SCUTTLEBOT_SESSION_ID`
6666
- derive a stable `codex-{basename}-{session}` nick
6767
- post `online ...` immediately when Codex starts
6868
- post `offline ...` when Codex exits
6969
- 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
7178
7279
To disable the relay without uninstalling:
7380
7481
```bash
7582
SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay
7683
--- 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
--- skills/openai-relay/hooks/README.md
+++ skills/openai-relay/hooks/README.md
@@ -1,10 +1,11 @@
11
# Codex Hook Primer
22
33
These hooks are the pre-tool fallback path for a live Codex tool loop.
44
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.
67
78
If you need to add another runtime later, use
89
[`../../scuttlebot-relay/ADDING_AGENTS.md`](../../scuttlebot-relay/ADDING_AGENTS.md)
910
as the shared authoring contract.
1011
@@ -74,13 +75,18 @@
7475
- `curl` and `jq` available on `PATH`
7576
7677
Optional:
7778
- `SCUTTLEBOT_NICK`
7879
- `SCUTTLEBOT_SESSION_ID`
80
+- `SCUTTLEBOT_TRANSPORT`
81
+- `SCUTTLEBOT_IRC_ADDR`
82
+- `SCUTTLEBOT_IRC_PASS`
83
+- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
7984
- `SCUTTLEBOT_HOOKS_ENABLED`
8085
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE`
8186
- `SCUTTLEBOT_POLL_INTERVAL`
87
+- `SCUTTLEBOT_PRESENCE_HEARTBEAT`
8288
- `SCUTTLEBOT_CONFIG_FILE`
8389
- `SCUTTLEBOT_ACTIVITY_VIA_BROKER`
8490
8591
Example:
8692
@@ -95,13 +101,16 @@
95101
```bash
96102
cat > ~/.config/scuttlebot-relay.env <<'EOF'
97103
SCUTTLEBOT_URL=http://localhost:8080
98104
SCUTTLEBOT_TOKEN=...
99105
SCUTTLEBOT_CHANNEL=general
106
+SCUTTLEBOT_TRANSPORT=http
107
+SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
100108
SCUTTLEBOT_HOOKS_ENABLED=1
101109
SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
102110
SCUTTLEBOT_POLL_INTERVAL=2s
111
+SCUTTLEBOT_PRESENCE_HEARTBEAT=60s
103112
EOF
104113
```
105114
106115
Disable the hooks entirely:
107116
@@ -277,15 +286,18 @@
277286
```
278287
279288
## Operational notes
280289
281290
- `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.
282292
- `cmd/codex-relay` also tails the active session JSONL and mirrors assistant output plus tool activity into IRC.
283293
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions.
284294
- 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.
285295
- `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.
287299
- If scuttlebot is down or unreachable, the hooks soft-fail and return quickly.
288300
- `SCUTTLEBOT_HOOKS_ENABLED=0` disables both hooks explicitly.
289301
- `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` suppresses `scuttlebot-post.sh` so broker-launched sessions do not duplicate activity posts.
290302
- `../scripts/install-codex-relay.sh --disabled` writes that disabled state into the shared env file.
291303
- For fleet launch instructions, see [`../FLEET.md`](../FLEET.md).
292304
--- 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
--- skills/openai-relay/install.md
+++ skills/openai-relay/install.md
@@ -9,21 +9,21 @@
99
continuously while the session is running.
1010
1111
All source-of-truth code lives in this repo:
1212
- installer: [`scripts/install-codex-relay.sh`](scripts/install-codex-relay.sh)
1313
- broker: [`../../cmd/codex-relay/main.go`](../../cmd/codex-relay/main.go)
14
+- shared connector: [`../../pkg/sessionrelay/`](../../pkg/sessionrelay/)
1415
- dev wrapper: [`scripts/codex-relay.sh`](scripts/codex-relay.sh)
1516
- hook scripts: [`hooks/scuttlebot-post.sh`](hooks/scuttlebot-post.sh), [`hooks/scuttlebot-check.sh`](hooks/scuttlebot-check.sh)
1617
- fleet rollout guide: [`FLEET.md`](FLEET.md)
1718
1819
Files under `~/.codex/`, `~/.local/bin/`, and `~/.config/` are installed copies.
1920
The repo remains the source of truth.
2021
2122
## Prerequisites
2223
- `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
2525
- The `openai` backend configured on the daemon
2626
- Direct mode only: `OPENAI_API_KEY`
2727
2828
Quick connectivity check:
2929
```bash
@@ -56,12 +56,46 @@
5656
Runtime behavior:
5757
- `cmd/codex-relay` keeps Codex on a real PTY
5858
- it posts `online` immediately on launch
5959
- it mirrors assistant messages and tool activity from the active session log
6060
- it polls scuttlebot continuously for addressed operator messages
61
+- it uses the shared `pkg/sessionrelay` connector with selectable transport
6162
- by default it interrupts only when Codex appears busy; idle sessions are injected directly so the broker does not accidentally quit Codex
6263
- 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
+```
6397
6498
Disable the relay without uninstalling:
6599
66100
```bash
67101
SCUTTLEBOT_HOOKS_ENABLED=0 ~/.local/bin/codex-relay
@@ -124,13 +158,16 @@
124158
```bash
125159
cat > ~/.config/scuttlebot-relay.env <<'EOF'
126160
SCUTTLEBOT_URL=http://localhost:8080
127161
SCUTTLEBOT_TOKEN=<your-bearer-token>
128162
SCUTTLEBOT_CHANNEL=general
163
+SCUTTLEBOT_TRANSPORT=http
164
+SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
129165
SCUTTLEBOT_HOOKS_ENABLED=1
130166
SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
131167
SCUTTLEBOT_POLL_INTERVAL=2s
168
+SCUTTLEBOT_PRESENCE_HEARTBEAT=60s
132169
EOF
133170
```
134171
135172
Launch Codex through the broker:
136173
@@ -150,10 +187,15 @@
150187
- soft-fails if scuttlebot is disabled or unreachable
151188
152189
Optional broker env:
153190
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=0` disables the automatic busy-session interrupt before injected IRC instructions
154191
- `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
155197
156198
If you want `codex` itself to always use the wrapper, prefer a shell alias:
157199
158200
```bash
159201
alias codex="$HOME/.local/bin/codex-relay"
160202
--- 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 @@
1010
1111
Options:
1212
--url URL Set SCUTTLEBOT_URL in the shared env file.
1313
--token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file.
1414
--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.
1517
--enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
1618
--disabled Write SCUTTLEBOT_HOOKS_ENABLED=0.
1719
--config-file PATH Shared env file path. Default: ~/.config/scuttlebot-relay.env
1820
--hooks-dir PATH Codex hooks install dir. Default: ~/.codex/hooks
1921
--hooks-json PATH Codex hooks config JSON. Default: ~/.codex/hooks.json
@@ -23,13 +25,17 @@
2325
2426
Environment defaults:
2527
SCUTTLEBOT_URL
2628
SCUTTLEBOT_TOKEN
2729
SCUTTLEBOT_CHANNEL
30
+ SCUTTLEBOT_TRANSPORT
31
+ SCUTTLEBOT_IRC_ADDR
32
+ SCUTTLEBOT_IRC_PASS
2833
SCUTTLEBOT_HOOKS_ENABLED
2934
SCUTTLEBOT_INTERRUPT_ON_MESSAGE
3035
SCUTTLEBOT_POLL_INTERVAL
36
+ SCUTTLEBOT_PRESENCE_HEARTBEAT
3137
SCUTTLEBOT_CONFIG_FILE
3238
CODEX_HOOKS_DIR
3339
CODEX_HOOKS_JSON
3440
CODEX_CONFIG_TOML
3541
CODEX_BIN_DIR
@@ -46,13 +52,17 @@
4652
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
4753
4854
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
4955
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
5056
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:-}"
5160
SCUTTLEBOT_HOOKS_ENABLED_VALUE="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
5261
SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE="${SCUTTLEBOT_INTERRUPT_ON_MESSAGE:-1}"
5362
SCUTTLEBOT_POLL_INTERVAL_VALUE="${SCUTTLEBOT_POLL_INTERVAL:-2s}"
63
+SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE="${SCUTTLEBOT_PRESENCE_HEARTBEAT:-60s}"
5464
5565
CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
5666
HOOKS_DIR="${CODEX_HOOKS_DIR:-$HOME/.codex/hooks}"
5767
HOOKS_JSON="${CODEX_HOOKS_JSON:-$HOME/.codex/hooks.json}"
5868
CODEX_CONFIG="${CODEX_CONFIG_TOML:-$HOME/.codex/config.toml}"
@@ -69,10 +79,18 @@
6979
shift 2
7080
;;
7181
--channel)
7282
SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
7383
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
7492
;;
7593
--enabled)
7694
SCUTTLEBOT_HOOKS_ENABLED_VALUE=1
7795
shift
7896
;;
@@ -301,14 +319,20 @@
301319
if [ -n "$SCUTTLEBOT_TOKEN_VALUE" ]; then
302320
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
303321
fi
304322
if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
305323
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"
306329
fi
307330
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_HOOKS_ENABLED "$SCUTTLEBOT_HOOKS_ENABLED_VALUE"
308331
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_INTERRUPT_ON_MESSAGE "$SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE"
309332
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"
310334
311335
printf 'Installed Codex relay files:\n'
312336
printf ' hooks: %s\n' "$HOOKS_DIR"
313337
printf ' hooks.json: %s\n' "$HOOKS_JSON"
314338
printf ' config: %s\n' "$CODEX_CONFIG"
315339
--- 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 @@
11
# Adding Another Agent Runtime
22
33
This repo now has two concrete operator-control implementations:
44
- Claude hooks in `skills/scuttlebot-relay/hooks/`
55
- 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.
69
710
If you add another agent runtime, do not invent a new relay model. Follow the
811
same control contract so operators get one consistent experience.
912
1013
## The contract
@@ -35,10 +38,15 @@
3538
3639
Hooks remain useful for pre-action fallback and for runtimes that do not have a
3740
broker yet, but hook-only telemetry is not the production pattern for
3841
interactive sessions.
3942
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
+
4048
## Required environment contract
4149
4250
All adapters should use the same environment variables:
4351
- `SCUTTLEBOT_URL`
4452
- `SCUTTLEBOT_TOKEN`
4553
--- 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

Keyboard Shortcuts

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