ScuttleBot

Add multi-channel relay control and shared install skill

lmata 2026-04-01 14:36 trunk
Commit 1d3caa2033b59f26bcdfd39ab1bd53c9090ca274d0cb3e8fbd094aefb36e08d1
35 files changed +103 -5 +102 -4 +4 -4 +102 -5 +4 -4 +101 +119 -46 +91 -8 +38 -4 +141 -54 +7 -1 +1 +9 -1 +41 -7 +67 -30 +46 -10 +13 -1 +52 +7 +1 +9 -2 +67 -30 +46 -8 +14 -1 +52 +11 -2 +7 +75 +4 +11 -3 +67 -30 +47 -10 +14 -1 +52 +7 -1
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -71,10 +71,12 @@
7171
IRCAddr string
7272
IRCPass string
7373
IRCAgentType string
7474
IRCDeleteOnClose bool
7575
Channel string
76
+ Channels []string
77
+ ChannelStateFile string
7678
SessionID string
7779
Nick string
7880
HooksEnabled bool
7981
InterruptOnMessage bool
8082
PollInterval time.Duration
@@ -124,19 +126,22 @@
124126
fmt.Fprintf(os.Stderr, "claude-relay: nick %s\n", cfg.Nick)
125127
relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
126128
127129
ctx, cancel := context.WithCancel(context.Background())
128130
defer cancel()
131
+ _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
132
+ defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
129133
130134
var relay sessionrelay.Connector
131135
relayActive := false
132136
if relayRequested {
133137
conn, err := sessionrelay.New(sessionrelay.Config{
134138
Transport: cfg.Transport,
135139
URL: cfg.URL,
136140
Token: cfg.Token,
137141
Channel: cfg.Channel,
142
+ Channels: cfg.Channels,
138143
Nick: cfg.Nick,
139144
IRC: sessionrelay.IRCConfig{
140145
Addr: cfg.IRCAddr,
141146
Pass: cfg.IRCPass,
142147
AgentType: cfg.IRCAgentType,
@@ -151,10 +156,13 @@
151156
fmt.Fprintf(os.Stderr, "claude-relay: relay disabled: %v\n", err)
152157
_ = conn.Close(context.Background())
153158
} else {
154159
relay = conn
155160
relayActive = true
161
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
162
+ fmt.Fprintf(os.Stderr, "claude-relay: channel state disabled: %v\n", err)
163
+ }
156164
_ = relay.Post(context.Background(), fmt.Sprintf(
157165
"online in %s; mention %s to interrupt before the next action",
158166
filepath.Base(cfg.TargetCWD), cfg.Nick,
159167
))
160168
}
@@ -174,10 +182,12 @@
174182
cmd.Env = append(os.Environ(),
175183
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
176184
"SCUTTLEBOT_URL="+cfg.URL,
177185
"SCUTTLEBOT_TOKEN="+cfg.Token,
178186
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
187
+ "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
188
+ "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
179189
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
180190
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
181191
"SCUTTLEBOT_NICK="+cfg.Nick,
182192
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
183193
)
@@ -429,11 +439,11 @@
429439
}
430440
case "tool_use":
431441
if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
432442
out = append(out, msg)
433443
}
434
- // thinking blocks are intentionally skipped — too verbose for IRC
444
+ // thinking blocks are intentionally skipped — too verbose for IRC
435445
}
436446
}
437447
return out
438448
}
439449
@@ -581,11 +591,25 @@
581591
batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
582592
if len(batch) == 0 {
583593
continue
584594
}
585595
lastSeen = newest
586
- if err := injectMessages(ptyFile, cfg, state, batch); err != nil {
596
+ pending := make([]message, 0, len(batch))
597
+ for _, msg := range batch {
598
+ handled, err := handleRelayCommand(ctx, relay, cfg, msg)
599
+ if err != nil {
600
+ return
601
+ }
602
+ if handled {
603
+ continue
604
+ }
605
+ pending = append(pending, msg)
606
+ }
607
+ if len(pending) == 0 {
608
+ continue
609
+ }
610
+ if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
587611
return
588612
}
589613
}
590614
}
591615
}
@@ -605,18 +629,25 @@
605629
_ = relay.Touch(ctx)
606630
}
607631
}
608632
}
609633
610
-func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error {
634
+func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
611635
lines := make([]string, 0, len(batch))
612636
for _, msg := range batch {
613637
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
614638
if text == "" {
615639
text = strings.TrimSpace(msg.Text)
616640
}
617
- lines = append(lines, fmt.Sprintf("%s: %s", msg.Nick, text))
641
+ channelPrefix := ""
642
+ if msg.Channel != "" {
643
+ channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
644
+ }
645
+ if msg.Channel == "" || msg.Channel == controlChannel {
646
+ channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
647
+ }
648
+ lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
618649
}
619650
620651
var block strings.Builder
621652
block.WriteString("[IRC operator messages]\n")
622653
for _, line := range lines {
@@ -638,10 +669,62 @@
638669
return err
639670
}
640671
_, err := writer.Write([]byte{'\r'})
641672
return err
642673
}
674
+
675
+func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
676
+ text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
677
+ if text == "" {
678
+ text = strings.TrimSpace(msg.Text)
679
+ }
680
+
681
+ cmd, ok := sessionrelay.ParseBrokerCommand(text)
682
+ if !ok {
683
+ return false, nil
684
+ }
685
+
686
+ postStatus := func(channel, text string) error {
687
+ if channel == "" {
688
+ channel = relay.ControlChannel()
689
+ }
690
+ return relay.PostTo(ctx, channel, text)
691
+ }
692
+
693
+ switch cmd.Name {
694
+ case "channels":
695
+ return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
696
+ case "join":
697
+ if cmd.Channel == "" {
698
+ return true, postStatus(msg.Channel, "usage: /join #channel")
699
+ }
700
+ if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
701
+ return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
702
+ }
703
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
704
+ return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
705
+ }
706
+ return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
707
+ case "part":
708
+ if cmd.Channel == "" {
709
+ return true, postStatus(msg.Channel, "usage: /part #channel")
710
+ }
711
+ if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
712
+ return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
713
+ }
714
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
715
+ return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
716
+ }
717
+ replyChannel := msg.Channel
718
+ if sameChannel(replyChannel, cmd.Channel) {
719
+ replyChannel = relay.ControlChannel()
720
+ }
721
+ return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
722
+ default:
723
+ return false, nil
724
+ }
725
+}
643726
644727
func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
645728
buf := make([]byte, 4096)
646729
for {
647730
n, err := src.Read(buf)
@@ -722,17 +805,22 @@
722805
Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
723806
IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
724807
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
725808
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
726809
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
727
- Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"),
728810
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
729811
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
730812
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
731813
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
732814
Args: append([]string(nil), args...),
733815
}
816
+
817
+ controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
818
+ cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
819
+ if len(cfg.Channels) > 0 {
820
+ cfg.Channel = cfg.Channels[0]
821
+ }
734822
735823
target, err := targetCWD(args)
736824
if err != nil {
737825
return config{}, err
738826
}
@@ -747,19 +835,29 @@
747835
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
748836
if nick == "" {
749837
nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
750838
}
751839
cfg.Nick = sanitize(nick)
840
+ cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
752841
753842
if cfg.Channel == "" {
754843
cfg.Channel = defaultChannel
844
+ cfg.Channels = []string{defaultChannel}
755845
}
756846
if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
757847
cfg.HooksEnabled = false
758848
}
759849
return cfg, nil
760850
}
851
+
852
+func defaultChannelStateFile(nick string) string {
853
+ return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
854
+}
855
+
856
+func sameChannel(a, b string) bool {
857
+ return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
858
+}
761859
762860
func configFilePath() string {
763861
if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
764862
return value
765863
}
766864
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -71,10 +71,12 @@
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
@@ -124,19 +126,22 @@
124 fmt.Fprintf(os.Stderr, "claude-relay: nick %s\n", cfg.Nick)
125 relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
126
127 ctx, cancel := context.WithCancel(context.Background())
128 defer cancel()
 
 
129
130 var relay sessionrelay.Connector
131 relayActive := false
132 if relayRequested {
133 conn, err := sessionrelay.New(sessionrelay.Config{
134 Transport: cfg.Transport,
135 URL: cfg.URL,
136 Token: cfg.Token,
137 Channel: cfg.Channel,
 
138 Nick: cfg.Nick,
139 IRC: sessionrelay.IRCConfig{
140 Addr: cfg.IRCAddr,
141 Pass: cfg.IRCPass,
142 AgentType: cfg.IRCAgentType,
@@ -151,10 +156,13 @@
151 fmt.Fprintf(os.Stderr, "claude-relay: relay disabled: %v\n", err)
152 _ = conn.Close(context.Background())
153 } else {
154 relay = conn
155 relayActive = true
 
 
 
156 _ = relay.Post(context.Background(), fmt.Sprintf(
157 "online in %s; mention %s to interrupt before the next action",
158 filepath.Base(cfg.TargetCWD), cfg.Nick,
159 ))
160 }
@@ -174,10 +182,12 @@
174 cmd.Env = append(os.Environ(),
175 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
176 "SCUTTLEBOT_URL="+cfg.URL,
177 "SCUTTLEBOT_TOKEN="+cfg.Token,
178 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
 
 
179 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
180 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
181 "SCUTTLEBOT_NICK="+cfg.Nick,
182 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
183 )
@@ -429,11 +439,11 @@
429 }
430 case "tool_use":
431 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
432 out = append(out, msg)
433 }
434 // thinking blocks are intentionally skipped — too verbose for IRC
435 }
436 }
437 return out
438 }
439
@@ -581,11 +591,25 @@
581 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
582 if len(batch) == 0 {
583 continue
584 }
585 lastSeen = newest
586 if err := injectMessages(ptyFile, cfg, state, batch); err != nil {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587 return
588 }
589 }
590 }
591 }
@@ -605,18 +629,25 @@
605 _ = relay.Touch(ctx)
606 }
607 }
608 }
609
610 func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error {
611 lines := make([]string, 0, len(batch))
612 for _, msg := range batch {
613 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
614 if text == "" {
615 text = strings.TrimSpace(msg.Text)
616 }
617 lines = append(lines, fmt.Sprintf("%s: %s", msg.Nick, text))
 
 
 
 
 
 
 
618 }
619
620 var block strings.Builder
621 block.WriteString("[IRC operator messages]\n")
622 for _, line := range lines {
@@ -638,10 +669,62 @@
638 return err
639 }
640 _, err := writer.Write([]byte{'\r'})
641 return err
642 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
644 func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
645 buf := make([]byte, 4096)
646 for {
647 n, err := src.Read(buf)
@@ -722,17 +805,22 @@
722 Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
723 IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
724 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
725 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
726 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
727 Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"),
728 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
729 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
730 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
731 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
732 Args: append([]string(nil), args...),
733 }
 
 
 
 
 
 
734
735 target, err := targetCWD(args)
736 if err != nil {
737 return config{}, err
738 }
@@ -747,19 +835,29 @@
747 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
748 if nick == "" {
749 nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
750 }
751 cfg.Nick = sanitize(nick)
 
752
753 if cfg.Channel == "" {
754 cfg.Channel = defaultChannel
 
755 }
756 if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
757 cfg.HooksEnabled = false
758 }
759 return cfg, nil
760 }
 
 
 
 
 
 
 
 
761
762 func configFilePath() string {
763 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
764 return value
765 }
766
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -71,10 +71,12 @@
71 IRCAddr string
72 IRCPass string
73 IRCAgentType string
74 IRCDeleteOnClose bool
75 Channel string
76 Channels []string
77 ChannelStateFile string
78 SessionID string
79 Nick string
80 HooksEnabled bool
81 InterruptOnMessage bool
82 PollInterval time.Duration
@@ -124,19 +126,22 @@
126 fmt.Fprintf(os.Stderr, "claude-relay: nick %s\n", cfg.Nick)
127 relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
128
129 ctx, cancel := context.WithCancel(context.Background())
130 defer cancel()
131 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
132 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
133
134 var relay sessionrelay.Connector
135 relayActive := false
136 if relayRequested {
137 conn, err := sessionrelay.New(sessionrelay.Config{
138 Transport: cfg.Transport,
139 URL: cfg.URL,
140 Token: cfg.Token,
141 Channel: cfg.Channel,
142 Channels: cfg.Channels,
143 Nick: cfg.Nick,
144 IRC: sessionrelay.IRCConfig{
145 Addr: cfg.IRCAddr,
146 Pass: cfg.IRCPass,
147 AgentType: cfg.IRCAgentType,
@@ -151,10 +156,13 @@
156 fmt.Fprintf(os.Stderr, "claude-relay: relay disabled: %v\n", err)
157 _ = conn.Close(context.Background())
158 } else {
159 relay = conn
160 relayActive = true
161 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
162 fmt.Fprintf(os.Stderr, "claude-relay: channel state disabled: %v\n", err)
163 }
164 _ = relay.Post(context.Background(), fmt.Sprintf(
165 "online in %s; mention %s to interrupt before the next action",
166 filepath.Base(cfg.TargetCWD), cfg.Nick,
167 ))
168 }
@@ -174,10 +182,12 @@
182 cmd.Env = append(os.Environ(),
183 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
184 "SCUTTLEBOT_URL="+cfg.URL,
185 "SCUTTLEBOT_TOKEN="+cfg.Token,
186 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
187 "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
188 "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
189 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
190 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
191 "SCUTTLEBOT_NICK="+cfg.Nick,
192 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
193 )
@@ -429,11 +439,11 @@
439 }
440 case "tool_use":
441 if msg := summarizeToolUse(block.Name, block.Input); msg != "" {
442 out = append(out, msg)
443 }
444 // thinking blocks are intentionally skipped — too verbose for IRC
445 }
446 }
447 return out
448 }
449
@@ -581,11 +591,25 @@
591 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
592 if len(batch) == 0 {
593 continue
594 }
595 lastSeen = newest
596 pending := make([]message, 0, len(batch))
597 for _, msg := range batch {
598 handled, err := handleRelayCommand(ctx, relay, cfg, msg)
599 if err != nil {
600 return
601 }
602 if handled {
603 continue
604 }
605 pending = append(pending, msg)
606 }
607 if len(pending) == 0 {
608 continue
609 }
610 if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
611 return
612 }
613 }
614 }
615 }
@@ -605,18 +629,25 @@
629 _ = relay.Touch(ctx)
630 }
631 }
632 }
633
634 func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
635 lines := make([]string, 0, len(batch))
636 for _, msg := range batch {
637 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
638 if text == "" {
639 text = strings.TrimSpace(msg.Text)
640 }
641 channelPrefix := ""
642 if msg.Channel != "" {
643 channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
644 }
645 if msg.Channel == "" || msg.Channel == controlChannel {
646 channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
647 }
648 lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
649 }
650
651 var block strings.Builder
652 block.WriteString("[IRC operator messages]\n")
653 for _, line := range lines {
@@ -638,10 +669,62 @@
669 return err
670 }
671 _, err := writer.Write([]byte{'\r'})
672 return err
673 }
674
675 func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
676 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
677 if text == "" {
678 text = strings.TrimSpace(msg.Text)
679 }
680
681 cmd, ok := sessionrelay.ParseBrokerCommand(text)
682 if !ok {
683 return false, nil
684 }
685
686 postStatus := func(channel, text string) error {
687 if channel == "" {
688 channel = relay.ControlChannel()
689 }
690 return relay.PostTo(ctx, channel, text)
691 }
692
693 switch cmd.Name {
694 case "channels":
695 return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
696 case "join":
697 if cmd.Channel == "" {
698 return true, postStatus(msg.Channel, "usage: /join #channel")
699 }
700 if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
701 return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
702 }
703 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
704 return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
705 }
706 return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
707 case "part":
708 if cmd.Channel == "" {
709 return true, postStatus(msg.Channel, "usage: /part #channel")
710 }
711 if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
712 return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
713 }
714 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
715 return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
716 }
717 replyChannel := msg.Channel
718 if sameChannel(replyChannel, cmd.Channel) {
719 replyChannel = relay.ControlChannel()
720 }
721 return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
722 default:
723 return false, nil
724 }
725 }
726
727 func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
728 buf := make([]byte, 4096)
729 for {
730 n, err := src.Read(buf)
@@ -722,17 +805,22 @@
805 Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
806 IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
807 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
808 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
809 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
 
810 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
811 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
812 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
813 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
814 Args: append([]string(nil), args...),
815 }
816
817 controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
818 cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
819 if len(cfg.Channels) > 0 {
820 cfg.Channel = cfg.Channels[0]
821 }
822
823 target, err := targetCWD(args)
824 if err != nil {
825 return config{}, err
826 }
@@ -747,19 +835,29 @@
835 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
836 if nick == "" {
837 nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
838 }
839 cfg.Nick = sanitize(nick)
840 cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
841
842 if cfg.Channel == "" {
843 cfg.Channel = defaultChannel
844 cfg.Channels = []string{defaultChannel}
845 }
846 if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
847 cfg.HooksEnabled = false
848 }
849 return cfg, nil
850 }
851
852 func defaultChannelStateFile(nick string) string {
853 return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
854 }
855
856 func sameChannel(a, b string) bool {
857 return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
858 }
859
860 func configFilePath() string {
861 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
862 return value
863 }
864
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -71,10 +71,12 @@
7171
IRCAddr string
7272
IRCPass string
7373
IRCAgentType string
7474
IRCDeleteOnClose bool
7575
Channel string
76
+ Channels []string
77
+ ChannelStateFile string
7678
SessionID string
7779
Nick string
7880
HooksEnabled bool
7981
InterruptOnMessage bool
8082
PollInterval time.Duration
@@ -145,19 +147,22 @@
145147
fmt.Fprintf(os.Stderr, "codex-relay: nick %s\n", cfg.Nick)
146148
relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
147149
148150
ctx, cancel := context.WithCancel(context.Background())
149151
defer cancel()
152
+ _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
153
+ defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
150154
151155
var relay sessionrelay.Connector
152156
relayActive := false
153157
if relayRequested {
154158
conn, err := sessionrelay.New(sessionrelay.Config{
155159
Transport: cfg.Transport,
156160
URL: cfg.URL,
157161
Token: cfg.Token,
158162
Channel: cfg.Channel,
163
+ Channels: cfg.Channels,
159164
Nick: cfg.Nick,
160165
IRC: sessionrelay.IRCConfig{
161166
Addr: cfg.IRCAddr,
162167
Pass: cfg.IRCPass,
163168
AgentType: cfg.IRCAgentType,
@@ -172,10 +177,13 @@
172177
fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err)
173178
_ = conn.Close(context.Background())
174179
} else {
175180
relay = conn
176181
relayActive = true
182
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
183
+ fmt.Fprintf(os.Stderr, "codex-relay: channel state disabled: %v\n", err)
184
+ }
177185
_ = relay.Post(context.Background(), fmt.Sprintf(
178186
"online in %s; mention %s to interrupt before the next action",
179187
filepath.Base(cfg.TargetCWD), cfg.Nick,
180188
))
181189
}
@@ -195,10 +203,12 @@
195203
cmd.Env = append(os.Environ(),
196204
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
197205
"SCUTTLEBOT_URL="+cfg.URL,
198206
"SCUTTLEBOT_TOKEN="+cfg.Token,
199207
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
208
+ "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
209
+ "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
200210
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
201211
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
202212
"SCUTTLEBOT_NICK="+cfg.Nick,
203213
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
204214
)
@@ -288,11 +298,25 @@
288298
batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
289299
if len(batch) == 0 {
290300
continue
291301
}
292302
lastSeen = newest
293
- if err := injectMessages(ptyFile, cfg, state, batch); err != nil {
303
+ pending := make([]message, 0, len(batch))
304
+ for _, msg := range batch {
305
+ handled, err := handleRelayCommand(ctx, relay, cfg, msg)
306
+ if err != nil {
307
+ return
308
+ }
309
+ if handled {
310
+ continue
311
+ }
312
+ pending = append(pending, msg)
313
+ }
314
+ if len(pending) == 0 {
315
+ continue
316
+ }
317
+ if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
294318
return
295319
}
296320
}
297321
}
298322
}
@@ -312,18 +336,25 @@
312336
_ = relay.Touch(ctx)
313337
}
314338
}
315339
}
316340
317
-func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error {
341
+func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
318342
lines := make([]string, 0, len(batch))
319343
for _, msg := range batch {
320344
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
321345
if text == "" {
322346
text = strings.TrimSpace(msg.Text)
323347
}
324
- lines = append(lines, fmt.Sprintf("%s: %s", msg.Nick, text))
348
+ channelPrefix := ""
349
+ if msg.Channel != "" {
350
+ channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
351
+ }
352
+ if msg.Channel == "" || msg.Channel == controlChannel {
353
+ channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
354
+ }
355
+ lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
325356
}
326357
327358
var block strings.Builder
328359
block.WriteString("[IRC operator messages]\n")
329360
for _, line := range lines {
@@ -345,10 +376,62 @@
345376
return err
346377
}
347378
_, err := writer.Write([]byte{'\r'})
348379
return err
349380
}
381
+
382
+func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
383
+ text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
384
+ if text == "" {
385
+ text = strings.TrimSpace(msg.Text)
386
+ }
387
+
388
+ cmd, ok := sessionrelay.ParseBrokerCommand(text)
389
+ if !ok {
390
+ return false, nil
391
+ }
392
+
393
+ postStatus := func(channel, text string) error {
394
+ if channel == "" {
395
+ channel = relay.ControlChannel()
396
+ }
397
+ return relay.PostTo(ctx, channel, text)
398
+ }
399
+
400
+ switch cmd.Name {
401
+ case "channels":
402
+ return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
403
+ case "join":
404
+ if cmd.Channel == "" {
405
+ return true, postStatus(msg.Channel, "usage: /join #channel")
406
+ }
407
+ if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
408
+ return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
409
+ }
410
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
411
+ return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
412
+ }
413
+ return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
414
+ case "part":
415
+ if cmd.Channel == "" {
416
+ return true, postStatus(msg.Channel, "usage: /part #channel")
417
+ }
418
+ if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
419
+ return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
420
+ }
421
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
422
+ return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
423
+ }
424
+ replyChannel := msg.Channel
425
+ if sameChannel(replyChannel, cmd.Channel) {
426
+ replyChannel = relay.ControlChannel()
427
+ }
428
+ return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
429
+ default:
430
+ return false, nil
431
+ }
432
+}
350433
351434
func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
352435
buf := make([]byte, 4096)
353436
for {
354437
n, err := src.Read(buf)
@@ -426,17 +509,22 @@
426509
Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
427510
IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
428511
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
429512
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
430513
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
431
- Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"),
432514
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
433515
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
434516
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
435517
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
436518
Args: append([]string(nil), args...),
437519
}
520
+
521
+ controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
522
+ cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
523
+ if len(cfg.Channels) > 0 {
524
+ cfg.Channel = cfg.Channels[0]
525
+ }
438526
439527
target, err := targetCWD(args)
440528
if err != nil {
441529
return config{}, err
442530
}
@@ -454,19 +542,29 @@
454542
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
455543
if nick == "" {
456544
nick = fmt.Sprintf("codex-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
457545
}
458546
cfg.Nick = sanitize(nick)
547
+ cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
459548
460549
if cfg.Channel == "" {
461550
cfg.Channel = defaultChannel
551
+ cfg.Channels = []string{defaultChannel}
462552
}
463553
if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
464554
cfg.HooksEnabled = false
465555
}
466556
return cfg, nil
467557
}
558
+
559
+func defaultChannelStateFile(nick string) string {
560
+ return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
561
+}
562
+
563
+func sameChannel(a, b string) bool {
564
+ return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
565
+}
468566
469567
func configFilePath() string {
470568
if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
471569
return value
472570
}
473571
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -71,10 +71,12 @@
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
@@ -145,19 +147,22 @@
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,
@@ -172,10 +177,13 @@
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 }
@@ -195,10 +203,12 @@
195 cmd.Env = append(os.Environ(),
196 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
197 "SCUTTLEBOT_URL="+cfg.URL,
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 )
@@ -288,11 +298,25 @@
288 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
289 if len(batch) == 0 {
290 continue
291 }
292 lastSeen = newest
293 if err := injectMessages(ptyFile, cfg, state, batch); err != nil {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294 return
295 }
296 }
297 }
298 }
@@ -312,18 +336,25 @@
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)
321 if text == "" {
322 text = strings.TrimSpace(msg.Text)
323 }
324 lines = append(lines, fmt.Sprintf("%s: %s", msg.Nick, text))
 
 
 
 
 
 
 
325 }
326
327 var block strings.Builder
328 block.WriteString("[IRC operator messages]\n")
329 for _, line := range lines {
@@ -345,10 +376,62 @@
345 return err
346 }
347 _, err := writer.Write([]byte{'\r'})
348 return err
349 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
351 func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
352 buf := make([]byte, 4096)
353 for {
354 n, err := src.Read(buf)
@@ -426,17 +509,22 @@
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 {
441 return config{}, err
442 }
@@ -454,19 +542,29 @@
454 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
455 if nick == "" {
456 nick = fmt.Sprintf("codex-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
457 }
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
469 func configFilePath() string {
470 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
471 return value
472 }
473
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -71,10 +71,12 @@
71 IRCAddr string
72 IRCPass string
73 IRCAgentType string
74 IRCDeleteOnClose bool
75 Channel string
76 Channels []string
77 ChannelStateFile string
78 SessionID string
79 Nick string
80 HooksEnabled bool
81 InterruptOnMessage bool
82 PollInterval time.Duration
@@ -145,19 +147,22 @@
147 fmt.Fprintf(os.Stderr, "codex-relay: nick %s\n", cfg.Nick)
148 relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
149
150 ctx, cancel := context.WithCancel(context.Background())
151 defer cancel()
152 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
153 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
154
155 var relay sessionrelay.Connector
156 relayActive := false
157 if relayRequested {
158 conn, err := sessionrelay.New(sessionrelay.Config{
159 Transport: cfg.Transport,
160 URL: cfg.URL,
161 Token: cfg.Token,
162 Channel: cfg.Channel,
163 Channels: cfg.Channels,
164 Nick: cfg.Nick,
165 IRC: sessionrelay.IRCConfig{
166 Addr: cfg.IRCAddr,
167 Pass: cfg.IRCPass,
168 AgentType: cfg.IRCAgentType,
@@ -172,10 +177,13 @@
177 fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err)
178 _ = conn.Close(context.Background())
179 } else {
180 relay = conn
181 relayActive = true
182 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
183 fmt.Fprintf(os.Stderr, "codex-relay: channel state disabled: %v\n", err)
184 }
185 _ = relay.Post(context.Background(), fmt.Sprintf(
186 "online in %s; mention %s to interrupt before the next action",
187 filepath.Base(cfg.TargetCWD), cfg.Nick,
188 ))
189 }
@@ -195,10 +203,12 @@
203 cmd.Env = append(os.Environ(),
204 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
205 "SCUTTLEBOT_URL="+cfg.URL,
206 "SCUTTLEBOT_TOKEN="+cfg.Token,
207 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
208 "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
209 "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
210 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
211 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
212 "SCUTTLEBOT_NICK="+cfg.Nick,
213 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
214 )
@@ -288,11 +298,25 @@
298 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
299 if len(batch) == 0 {
300 continue
301 }
302 lastSeen = newest
303 pending := make([]message, 0, len(batch))
304 for _, msg := range batch {
305 handled, err := handleRelayCommand(ctx, relay, cfg, msg)
306 if err != nil {
307 return
308 }
309 if handled {
310 continue
311 }
312 pending = append(pending, msg)
313 }
314 if len(pending) == 0 {
315 continue
316 }
317 if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
318 return
319 }
320 }
321 }
322 }
@@ -312,18 +336,25 @@
336 _ = relay.Touch(ctx)
337 }
338 }
339 }
340
341 func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
342 lines := make([]string, 0, len(batch))
343 for _, msg := range batch {
344 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
345 if text == "" {
346 text = strings.TrimSpace(msg.Text)
347 }
348 channelPrefix := ""
349 if msg.Channel != "" {
350 channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
351 }
352 if msg.Channel == "" || msg.Channel == controlChannel {
353 channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
354 }
355 lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
356 }
357
358 var block strings.Builder
359 block.WriteString("[IRC operator messages]\n")
360 for _, line := range lines {
@@ -345,10 +376,62 @@
376 return err
377 }
378 _, err := writer.Write([]byte{'\r'})
379 return err
380 }
381
382 func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
383 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
384 if text == "" {
385 text = strings.TrimSpace(msg.Text)
386 }
387
388 cmd, ok := sessionrelay.ParseBrokerCommand(text)
389 if !ok {
390 return false, nil
391 }
392
393 postStatus := func(channel, text string) error {
394 if channel == "" {
395 channel = relay.ControlChannel()
396 }
397 return relay.PostTo(ctx, channel, text)
398 }
399
400 switch cmd.Name {
401 case "channels":
402 return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
403 case "join":
404 if cmd.Channel == "" {
405 return true, postStatus(msg.Channel, "usage: /join #channel")
406 }
407 if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
408 return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
409 }
410 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
411 return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
412 }
413 return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
414 case "part":
415 if cmd.Channel == "" {
416 return true, postStatus(msg.Channel, "usage: /part #channel")
417 }
418 if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
419 return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
420 }
421 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
422 return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
423 }
424 replyChannel := msg.Channel
425 if sameChannel(replyChannel, cmd.Channel) {
426 replyChannel = relay.ControlChannel()
427 }
428 return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
429 default:
430 return false, nil
431 }
432 }
433
434 func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
435 buf := make([]byte, 4096)
436 for {
437 n, err := src.Read(buf)
@@ -426,17 +509,22 @@
509 Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
510 IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
511 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
512 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
513 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
 
514 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
515 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
516 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
517 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
518 Args: append([]string(nil), args...),
519 }
520
521 controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
522 cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
523 if len(cfg.Channels) > 0 {
524 cfg.Channel = cfg.Channels[0]
525 }
526
527 target, err := targetCWD(args)
528 if err != nil {
529 return config{}, err
530 }
@@ -454,19 +542,29 @@
542 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
543 if nick == "" {
544 nick = fmt.Sprintf("codex-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
545 }
546 cfg.Nick = sanitize(nick)
547 cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
548
549 if cfg.Channel == "" {
550 cfg.Channel = defaultChannel
551 cfg.Channels = []string{defaultChannel}
552 }
553 if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
554 cfg.HooksEnabled = false
555 }
556 return cfg, nil
557 }
558
559 func defaultChannelStateFile(nick string) string {
560 return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
561 }
562
563 func sameChannel(a, b string) bool {
564 return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
565 }
566
567 func configFilePath() string {
568 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
569 return value
570 }
571
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -83,15 +83,15 @@
8383
batch := []message{{
8484
Nick: "glengoolie",
8585
Text: "codex-scuttlebot-1234: check README.md",
8686
}}
8787
88
- if err := injectMessages(&writer, cfg, state, batch); err != nil {
88
+ if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
8989
t.Fatal(err)
9090
}
9191
92
- want := "[IRC operator messages]\nglengoolie: check README.md\n\r"
92
+ want := "[IRC operator messages]\n[general] glengoolie: check README.md\n\r"
9393
if writer.String() != want {
9494
t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want)
9595
}
9696
}
9797
@@ -108,15 +108,15 @@
108108
batch := []message{{
109109
Nick: "glengoolie",
110110
Text: "codex-scuttlebot-1234: stop and re-read bridge.go",
111111
}}
112112
113
- if err := injectMessages(&writer, cfg, state, batch); err != nil {
113
+ if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
114114
t.Fatal(err)
115115
}
116116
117
- want := string([]byte{3}) + "[IRC operator messages]\nglengoolie: stop and re-read bridge.go\n\r"
117
+ want := string([]byte{3}) + "[IRC operator messages]\n[general] glengoolie: stop and re-read bridge.go\n\r"
118118
if writer.String() != want {
119119
t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want)
120120
}
121121
}
122122
123123
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -83,15 +83,15 @@
83 batch := []message{{
84 Nick: "glengoolie",
85 Text: "codex-scuttlebot-1234: check README.md",
86 }}
87
88 if err := injectMessages(&writer, cfg, state, batch); err != nil {
89 t.Fatal(err)
90 }
91
92 want := "[IRC operator messages]\nglengoolie: check README.md\n\r"
93 if writer.String() != want {
94 t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want)
95 }
96 }
97
@@ -108,15 +108,15 @@
108 batch := []message{{
109 Nick: "glengoolie",
110 Text: "codex-scuttlebot-1234: stop and re-read bridge.go",
111 }}
112
113 if err := injectMessages(&writer, cfg, state, batch); err != nil {
114 t.Fatal(err)
115 }
116
117 want := string([]byte{3}) + "[IRC operator messages]\nglengoolie: stop and re-read bridge.go\n\r"
118 if writer.String() != want {
119 t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want)
120 }
121 }
122
123
--- cmd/codex-relay/main_test.go
+++ cmd/codex-relay/main_test.go
@@ -83,15 +83,15 @@
83 batch := []message{{
84 Nick: "glengoolie",
85 Text: "codex-scuttlebot-1234: check README.md",
86 }}
87
88 if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
89 t.Fatal(err)
90 }
91
92 want := "[IRC operator messages]\n[general] glengoolie: check README.md\n\r"
93 if writer.String() != want {
94 t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want)
95 }
96 }
97
@@ -108,15 +108,15 @@
108 batch := []message{{
109 Nick: "glengoolie",
110 Text: "codex-scuttlebot-1234: stop and re-read bridge.go",
111 }}
112
113 if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
114 t.Fatal(err)
115 }
116
117 want := string([]byte{3}) + "[IRC operator messages]\n[general] glengoolie: stop and re-read bridge.go\n\r"
118 if writer.String() != want {
119 t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want)
120 }
121 }
122
123
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -61,10 +61,12 @@
6161
IRCAddr string
6262
IRCPass string
6363
IRCAgentType string
6464
IRCDeleteOnClose bool
6565
Channel string
66
+ Channels []string
67
+ ChannelStateFile string
6668
SessionID string
6769
Nick string
6870
HooksEnabled bool
6971
InterruptOnMessage bool
7072
PollInterval time.Duration
@@ -97,19 +99,22 @@
9799
fmt.Fprintf(os.Stderr, "gemini-relay: nick %s\n", cfg.Nick)
98100
relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
99101
100102
ctx, cancel := context.WithCancel(context.Background())
101103
defer cancel()
104
+ _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
105
+ defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
102106
103107
var relay sessionrelay.Connector
104108
relayActive := false
105109
if relayRequested {
106110
conn, err := sessionrelay.New(sessionrelay.Config{
107111
Transport: cfg.Transport,
108112
URL: cfg.URL,
109113
Token: cfg.Token,
110114
Channel: cfg.Channel,
115
+ Channels: cfg.Channels,
111116
Nick: cfg.Nick,
112117
IRC: sessionrelay.IRCConfig{
113118
Addr: cfg.IRCAddr,
114119
Pass: cfg.IRCPass,
115120
AgentType: cfg.IRCAgentType,
@@ -124,10 +129,13 @@
124129
fmt.Fprintf(os.Stderr, "gemini-relay: relay disabled: %v\n", err)
125130
_ = conn.Close(context.Background())
126131
} else {
127132
relay = conn
128133
relayActive = true
134
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
135
+ fmt.Fprintf(os.Stderr, "gemini-relay: channel state disabled: %v\n", err)
136
+ }
129137
_ = relay.Post(context.Background(), fmt.Sprintf(
130138
"online in %s; mention %s to interrupt before the next action",
131139
filepath.Base(cfg.TargetCWD), cfg.Nick,
132140
))
133141
}
@@ -146,14 +154,15 @@
146154
cmd.Env = append(os.Environ(),
147155
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
148156
"SCUTTLEBOT_URL="+cfg.URL,
149157
"SCUTTLEBOT_TOKEN="+cfg.Token,
150158
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
159
+ "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
160
+ "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
151161
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
152162
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
153163
"SCUTTLEBOT_NICK="+cfg.Nick,
154
- "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
155164
)
156165
if relayActive {
157166
go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
158167
}
159168
@@ -238,11 +247,25 @@
238247
batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
239248
if len(batch) == 0 {
240249
continue
241250
}
242251
lastSeen = newest
243
- if err := injectMessages(ptyFile, cfg, state, batch); err != nil {
252
+ pending := make([]message, 0, len(batch))
253
+ for _, msg := range batch {
254
+ handled, err := handleRelayCommand(ctx, relay, cfg, msg)
255
+ if err != nil {
256
+ return
257
+ }
258
+ if handled {
259
+ continue
260
+ }
261
+ pending = append(pending, msg)
262
+ }
263
+ if len(pending) == 0 {
264
+ continue
265
+ }
266
+ if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
244267
return
245268
}
246269
}
247270
}
248271
}
@@ -262,18 +285,25 @@
262285
_ = relay.Touch(ctx)
263286
}
264287
}
265288
}
266289
267
-func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error {
290
+func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
268291
lines := make([]string, 0, len(batch))
269292
for _, msg := range batch {
270293
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
271294
if text == "" {
272295
text = strings.TrimSpace(msg.Text)
273296
}
274
- lines = append(lines, fmt.Sprintf("%s: %s", msg.Nick, text))
297
+ channelPrefix := ""
298
+ if msg.Channel != "" {
299
+ channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
300
+ }
301
+ if msg.Channel == "" || msg.Channel == controlChannel {
302
+ channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
303
+ }
304
+ lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
275305
}
276306
277307
var block strings.Builder
278308
block.WriteString("[IRC operator messages]\n")
279309
for _, line := range lines {
@@ -299,10 +329,62 @@
299329
}
300330
time.Sleep(defaultInjectDelay)
301331
_, err := writer.Write([]byte{'\r'})
302332
return err
303333
}
334
+
335
+func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
336
+ text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
337
+ if text == "" {
338
+ text = strings.TrimSpace(msg.Text)
339
+ }
340
+
341
+ cmd, ok := sessionrelay.ParseBrokerCommand(text)
342
+ if !ok {
343
+ return false, nil
344
+ }
345
+
346
+ postStatus := func(channel, text string) error {
347
+ if channel == "" {
348
+ channel = relay.ControlChannel()
349
+ }
350
+ return relay.PostTo(ctx, channel, text)
351
+ }
352
+
353
+ switch cmd.Name {
354
+ case "channels":
355
+ return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
356
+ case "join":
357
+ if cmd.Channel == "" {
358
+ return true, postStatus(msg.Channel, "usage: /join #channel")
359
+ }
360
+ if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
361
+ return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
362
+ }
363
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
364
+ return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
365
+ }
366
+ return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
367
+ case "part":
368
+ if cmd.Channel == "" {
369
+ return true, postStatus(msg.Channel, "usage: /part #channel")
370
+ }
371
+ if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
372
+ return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
373
+ }
374
+ if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
375
+ return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
376
+ }
377
+ replyChannel := msg.Channel
378
+ if sameChannel(replyChannel, cmd.Channel) {
379
+ replyChannel = relay.ControlChannel()
380
+ }
381
+ return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
382
+ default:
383
+ return false, nil
384
+ }
385
+}
304386
305387
func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
306388
buf := make([]byte, 4096)
307389
for {
308390
n, err := src.Read(buf)
@@ -383,17 +465,22 @@
383465
Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
384466
IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
385467
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
386468
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
387469
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
388
- Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"),
389470
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
390471
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
391472
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
392473
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
393474
Args: append([]string(nil), args...),
394475
}
476
+
477
+ controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
478
+ cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
479
+ if len(cfg.Channels) > 0 {
480
+ cfg.Channel = cfg.Channels[0]
481
+ }
395482
396483
target, err := targetCWD(args)
397484
if err != nil {
398485
return config{}, err
399486
}
@@ -411,19 +498,29 @@
411498
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
412499
if nick == "" {
413500
nick = fmt.Sprintf("gemini-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
414501
}
415502
cfg.Nick = sanitize(nick)
503
+ cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
416504
417505
if cfg.Channel == "" {
418506
cfg.Channel = defaultChannel
507
+ cfg.Channels = []string{defaultChannel}
419508
}
420509
if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
421510
cfg.HooksEnabled = false
422511
}
423512
return cfg, nil
424513
}
514
+
515
+func defaultChannelStateFile(nick string) string {
516
+ return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
517
+}
518
+
519
+func sameChannel(a, b string) bool {
520
+ return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
521
+}
425522
426523
func configFilePath() string {
427524
if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
428525
return value
429526
}
430527
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -61,10 +61,12 @@
61 IRCAddr string
62 IRCPass string
63 IRCAgentType string
64 IRCDeleteOnClose bool
65 Channel string
 
 
66 SessionID string
67 Nick string
68 HooksEnabled bool
69 InterruptOnMessage bool
70 PollInterval time.Duration
@@ -97,19 +99,22 @@
97 fmt.Fprintf(os.Stderr, "gemini-relay: nick %s\n", cfg.Nick)
98 relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
99
100 ctx, cancel := context.WithCancel(context.Background())
101 defer cancel()
 
 
102
103 var relay sessionrelay.Connector
104 relayActive := false
105 if relayRequested {
106 conn, err := sessionrelay.New(sessionrelay.Config{
107 Transport: cfg.Transport,
108 URL: cfg.URL,
109 Token: cfg.Token,
110 Channel: cfg.Channel,
 
111 Nick: cfg.Nick,
112 IRC: sessionrelay.IRCConfig{
113 Addr: cfg.IRCAddr,
114 Pass: cfg.IRCPass,
115 AgentType: cfg.IRCAgentType,
@@ -124,10 +129,13 @@
124 fmt.Fprintf(os.Stderr, "gemini-relay: relay disabled: %v\n", err)
125 _ = conn.Close(context.Background())
126 } else {
127 relay = conn
128 relayActive = true
 
 
 
129 _ = relay.Post(context.Background(), fmt.Sprintf(
130 "online in %s; mention %s to interrupt before the next action",
131 filepath.Base(cfg.TargetCWD), cfg.Nick,
132 ))
133 }
@@ -146,14 +154,15 @@
146 cmd.Env = append(os.Environ(),
147 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
148 "SCUTTLEBOT_URL="+cfg.URL,
149 "SCUTTLEBOT_TOKEN="+cfg.Token,
150 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
 
 
151 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
152 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
153 "SCUTTLEBOT_NICK="+cfg.Nick,
154 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
155 )
156 if relayActive {
157 go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
158 }
159
@@ -238,11 +247,25 @@
238 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
239 if len(batch) == 0 {
240 continue
241 }
242 lastSeen = newest
243 if err := injectMessages(ptyFile, cfg, state, batch); err != nil {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244 return
245 }
246 }
247 }
248 }
@@ -262,18 +285,25 @@
262 _ = relay.Touch(ctx)
263 }
264 }
265 }
266
267 func injectMessages(writer io.Writer, cfg config, state *relayState, batch []message) error {
268 lines := make([]string, 0, len(batch))
269 for _, msg := range batch {
270 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
271 if text == "" {
272 text = strings.TrimSpace(msg.Text)
273 }
274 lines = append(lines, fmt.Sprintf("%s: %s", msg.Nick, text))
 
 
 
 
 
 
 
275 }
276
277 var block strings.Builder
278 block.WriteString("[IRC operator messages]\n")
279 for _, line := range lines {
@@ -299,10 +329,62 @@
299 }
300 time.Sleep(defaultInjectDelay)
301 _, err := writer.Write([]byte{'\r'})
302 return err
303 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
305 func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
306 buf := make([]byte, 4096)
307 for {
308 n, err := src.Read(buf)
@@ -383,17 +465,22 @@
383 Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
384 IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
385 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
386 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
387 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
388 Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"),
389 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
390 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
391 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
392 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
393 Args: append([]string(nil), args...),
394 }
 
 
 
 
 
 
395
396 target, err := targetCWD(args)
397 if err != nil {
398 return config{}, err
399 }
@@ -411,19 +498,29 @@
411 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
412 if nick == "" {
413 nick = fmt.Sprintf("gemini-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
414 }
415 cfg.Nick = sanitize(nick)
 
416
417 if cfg.Channel == "" {
418 cfg.Channel = defaultChannel
 
419 }
420 if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
421 cfg.HooksEnabled = false
422 }
423 return cfg, nil
424 }
 
 
 
 
 
 
 
 
425
426 func configFilePath() string {
427 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
428 return value
429 }
430
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -61,10 +61,12 @@
61 IRCAddr string
62 IRCPass string
63 IRCAgentType string
64 IRCDeleteOnClose bool
65 Channel string
66 Channels []string
67 ChannelStateFile string
68 SessionID string
69 Nick string
70 HooksEnabled bool
71 InterruptOnMessage bool
72 PollInterval time.Duration
@@ -97,19 +99,22 @@
99 fmt.Fprintf(os.Stderr, "gemini-relay: nick %s\n", cfg.Nick)
100 relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
101
102 ctx, cancel := context.WithCancel(context.Background())
103 defer cancel()
104 _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
105 defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
106
107 var relay sessionrelay.Connector
108 relayActive := false
109 if relayRequested {
110 conn, err := sessionrelay.New(sessionrelay.Config{
111 Transport: cfg.Transport,
112 URL: cfg.URL,
113 Token: cfg.Token,
114 Channel: cfg.Channel,
115 Channels: cfg.Channels,
116 Nick: cfg.Nick,
117 IRC: sessionrelay.IRCConfig{
118 Addr: cfg.IRCAddr,
119 Pass: cfg.IRCPass,
120 AgentType: cfg.IRCAgentType,
@@ -124,10 +129,13 @@
129 fmt.Fprintf(os.Stderr, "gemini-relay: relay disabled: %v\n", err)
130 _ = conn.Close(context.Background())
131 } else {
132 relay = conn
133 relayActive = true
134 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
135 fmt.Fprintf(os.Stderr, "gemini-relay: channel state disabled: %v\n", err)
136 }
137 _ = relay.Post(context.Background(), fmt.Sprintf(
138 "online in %s; mention %s to interrupt before the next action",
139 filepath.Base(cfg.TargetCWD), cfg.Nick,
140 ))
141 }
@@ -146,14 +154,15 @@
154 cmd.Env = append(os.Environ(),
155 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
156 "SCUTTLEBOT_URL="+cfg.URL,
157 "SCUTTLEBOT_TOKEN="+cfg.Token,
158 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
159 "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
160 "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
161 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
162 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
163 "SCUTTLEBOT_NICK="+cfg.Nick,
 
164 )
165 if relayActive {
166 go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
167 }
168
@@ -238,11 +247,25 @@
247 batch, newest := filterMessages(messages, lastSeen, cfg.Nick)
248 if len(batch) == 0 {
249 continue
250 }
251 lastSeen = newest
252 pending := make([]message, 0, len(batch))
253 for _, msg := range batch {
254 handled, err := handleRelayCommand(ctx, relay, cfg, msg)
255 if err != nil {
256 return
257 }
258 if handled {
259 continue
260 }
261 pending = append(pending, msg)
262 }
263 if len(pending) == 0 {
264 continue
265 }
266 if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
267 return
268 }
269 }
270 }
271 }
@@ -262,18 +285,25 @@
285 _ = relay.Touch(ctx)
286 }
287 }
288 }
289
290 func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
291 lines := make([]string, 0, len(batch))
292 for _, msg := range batch {
293 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
294 if text == "" {
295 text = strings.TrimSpace(msg.Text)
296 }
297 channelPrefix := ""
298 if msg.Channel != "" {
299 channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
300 }
301 if msg.Channel == "" || msg.Channel == controlChannel {
302 channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
303 }
304 lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
305 }
306
307 var block strings.Builder
308 block.WriteString("[IRC operator messages]\n")
309 for _, line := range lines {
@@ -299,10 +329,62 @@
329 }
330 time.Sleep(defaultInjectDelay)
331 _, err := writer.Write([]byte{'\r'})
332 return err
333 }
334
335 func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
336 text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
337 if text == "" {
338 text = strings.TrimSpace(msg.Text)
339 }
340
341 cmd, ok := sessionrelay.ParseBrokerCommand(text)
342 if !ok {
343 return false, nil
344 }
345
346 postStatus := func(channel, text string) error {
347 if channel == "" {
348 channel = relay.ControlChannel()
349 }
350 return relay.PostTo(ctx, channel, text)
351 }
352
353 switch cmd.Name {
354 case "channels":
355 return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
356 case "join":
357 if cmd.Channel == "" {
358 return true, postStatus(msg.Channel, "usage: /join #channel")
359 }
360 if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
361 return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
362 }
363 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
364 return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
365 }
366 return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
367 case "part":
368 if cmd.Channel == "" {
369 return true, postStatus(msg.Channel, "usage: /part #channel")
370 }
371 if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
372 return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
373 }
374 if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
375 return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
376 }
377 replyChannel := msg.Channel
378 if sameChannel(replyChannel, cmd.Channel) {
379 replyChannel = relay.ControlChannel()
380 }
381 return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
382 default:
383 return false, nil
384 }
385 }
386
387 func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
388 buf := make([]byte, 4096)
389 for {
390 n, err := src.Read(buf)
@@ -383,17 +465,22 @@
465 Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
466 IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
467 IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
468 IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
469 IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
 
470 HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
471 InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
472 PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
473 HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
474 Args: append([]string(nil), args...),
475 }
476
477 controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
478 cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
479 if len(cfg.Channels) > 0 {
480 cfg.Channel = cfg.Channels[0]
481 }
482
483 target, err := targetCWD(args)
484 if err != nil {
485 return config{}, err
486 }
@@ -411,19 +498,29 @@
498 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
499 if nick == "" {
500 nick = fmt.Sprintf("gemini-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
501 }
502 cfg.Nick = sanitize(nick)
503 cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
504
505 if cfg.Channel == "" {
506 cfg.Channel = defaultChannel
507 cfg.Channels = []string{defaultChannel}
508 }
509 if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
510 cfg.HooksEnabled = false
511 }
512 return cfg, nil
513 }
514
515 func defaultChannelStateFile(nick string) string {
516 return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
517 }
518
519 func sameChannel(a, b string) bool {
520 return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
521 }
522
523 func configFilePath() string {
524 if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
525 return value
526 }
527
--- cmd/gemini-relay/main_test.go
+++ cmd/gemini-relay/main_test.go
@@ -90,15 +90,15 @@
9090
batch := []message{{
9191
Nick: "glengoolie",
9292
Text: "gemini-scuttlebot-1234: check README.md",
9393
}}
9494
95
- if err := injectMessages(&writer, cfg, state, batch); err != nil {
95
+ if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
9696
t.Fatal(err)
9797
}
9898
99
- want := bracketedPasteStart + "[IRC operator messages]\nglengoolie: check README.md\n" + bracketedPasteEnd + "\r"
99
+ want := bracketedPasteStart + "[IRC operator messages]\n[general] glengoolie: check README.md\n" + bracketedPasteEnd + "\r"
100100
if writer.String() != want {
101101
t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want)
102102
}
103103
}
104104
@@ -115,14 +115,14 @@
115115
batch := []message{{
116116
Nick: "glengoolie",
117117
Text: "gemini-scuttlebot-1234: stop and re-read bridge.go",
118118
}}
119119
120
- if err := injectMessages(&writer, cfg, state, batch); err != nil {
120
+ if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
121121
t.Fatal(err)
122122
}
123123
124
- want := string([]byte{3}) + bracketedPasteStart + "[IRC operator messages]\nglengoolie: stop and re-read bridge.go\n" + bracketedPasteEnd + "\r"
124
+ want := string([]byte{3}) + bracketedPasteStart + "[IRC operator messages]\n[general] glengoolie: stop and re-read bridge.go\n" + bracketedPasteEnd + "\r"
125125
if writer.String() != want {
126126
t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want)
127127
}
128128
}
129129
130130
ADDED pkg/sessionrelay/channelstate.go
--- cmd/gemini-relay/main_test.go
+++ cmd/gemini-relay/main_test.go
@@ -90,15 +90,15 @@
90 batch := []message{{
91 Nick: "glengoolie",
92 Text: "gemini-scuttlebot-1234: check README.md",
93 }}
94
95 if err := injectMessages(&writer, cfg, state, batch); err != nil {
96 t.Fatal(err)
97 }
98
99 want := bracketedPasteStart + "[IRC operator messages]\nglengoolie: check README.md\n" + bracketedPasteEnd + "\r"
100 if writer.String() != want {
101 t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want)
102 }
103 }
104
@@ -115,14 +115,14 @@
115 batch := []message{{
116 Nick: "glengoolie",
117 Text: "gemini-scuttlebot-1234: stop and re-read bridge.go",
118 }}
119
120 if err := injectMessages(&writer, cfg, state, batch); err != nil {
121 t.Fatal(err)
122 }
123
124 want := string([]byte{3}) + bracketedPasteStart + "[IRC operator messages]\nglengoolie: stop and re-read bridge.go\n" + bracketedPasteEnd + "\r"
125 if writer.String() != want {
126 t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want)
127 }
128 }
129
130 DDED pkg/sessionrelay/channelstate.go
--- cmd/gemini-relay/main_test.go
+++ cmd/gemini-relay/main_test.go
@@ -90,15 +90,15 @@
90 batch := []message{{
91 Nick: "glengoolie",
92 Text: "gemini-scuttlebot-1234: check README.md",
93 }}
94
95 if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
96 t.Fatal(err)
97 }
98
99 want := bracketedPasteStart + "[IRC operator messages]\n[general] glengoolie: check README.md\n" + bracketedPasteEnd + "\r"
100 if writer.String() != want {
101 t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want)
102 }
103 }
104
@@ -115,14 +115,14 @@
115 batch := []message{{
116 Nick: "glengoolie",
117 Text: "gemini-scuttlebot-1234: stop and re-read bridge.go",
118 }}
119
120 if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil {
121 t.Fatal(err)
122 }
123
124 want := string([]byte{3}) + bracketedPasteStart + "[IRC operator messages]\n[general] glengoolie: stop and re-read bridge.go\n" + bracketedPasteEnd + "\r"
125 if writer.String() != want {
126 t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want)
127 }
128 }
129
130 DDED pkg/sessionrelay/channelstate.go
--- a/pkg/sessionrelay/channelstate.go
+++ b/pkg/sessionrelay/channelstate.go
@@ -0,0 +1,101 @@
1
+package sessionrelay
2
+
3
+import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+)
9
+
10
+type BrokerCommand struct {
11
+ Name string
12
+ Channel string
13
+}
14
+
15
+func ParseEnvChannels(primary, raw string) []string {
16
+ if strings.TrimSpace(raw) == "" {
17
+ return normalizeChannels(primary, nil)
18
+ }
19
+
20
+ parts := strings.Split(raw, ",")
21
+ return normalizeChannels(primary, parts)
22
+}
23
+
24
+func ChannelSlugs(channels []string) []string {
25
+ out := make([]string, 0, len(channels))
26
+ for _, channel := range channels {
27
+ if slug := channelSlug(channel); slug != "" {
28
+ out = append(out, slug)
29
+ }
30
+ }
31
+ return out
32
+}
33
+
34
+func FormatChannels(channels []string) string {
35
+ if len(channels) == 0 {
36
+ return "(none)"
37
+ }
38
+ return strings.Join(normalizeChannels("", channels), ", ")
39
+}
40
+
41
+func WriteChannelStateFile(path, control string, channels []string) error {
42
+ if path == "" {
43
+ return nil
44
+ }
45
+ control = channelSlug(control)
46
+ channels = normalizeChannels(control, channels)
47
+ if len(channels) == 0 {
48
+ return fmt.Errorf("sessionrelay: channel state requires at least one channel")
49
+ }
50
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
51
+ return err
52
+ }
53
+
54
+ data := strings.Join([]string{
55
+ "SCUTTLEBOT_CHANNEL=" + control,
56
+ "SCUTTLEBOT_CHANNELS=" + strings.Join(ChannelSlugs(channels), ","),
57
+ "",
58
+ }, "\n")
59
+
60
+ tmp := path + ".tmp"
61
+ if err := os.WriteFile(tmp, []byte(data), 0o600); err != nil {
62
+ return err
63
+ }
64
+ return os.Rename(tmp, path)
65
+}
66
+
67
+func RemoveChannelStateFile(path string) error {
68
+ if path == "" {
69
+ return nil
70
+ }
71
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
72
+ return err
73
+ }
74
+ return nil
75
+}
76
+
77
+func ParseBrokerCommand(text string) (BrokerCommand, bool) {
78
+ fields := strings.Fields(strings.TrimSpace(text))
79
+ if len(fields) == 0 {
80
+ return BrokerCommand{}, false
81
+ }
82
+
83
+ switch strings.ToLower(fields[0]) {
84
+ case "/join":
85
+ cmd := BrokerCommand{Name: "join"}
86
+ if len(fields) > 1 {
87
+ cmd.Channel = normalizeChannel(fields[1])
88
+ }
89
+ return cmd, true
90
+ case "/part":
91
+ cmd := BrokerCommand{Name: "part"}
92
+ if len(fields) > 1 {
93
+ cmd.Channel = normalizeChannel(fields[1])
94
+ }
95
+ return cmd, true
96
+ case "/channels":
97
+ return BrokerCommand{Name: "channels"}, true
98
+ default:
99
+ return BrokerCommand{}, false
100
+ }
101
+}
--- a/pkg/sessionrelay/channelstate.go
+++ b/pkg/sessionrelay/channelstate.go
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/sessionrelay/channelstate.go
+++ b/pkg/sessionrelay/channelstate.go
@@ -0,0 +1,101 @@
1 package sessionrelay
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 )
9
10 type BrokerCommand struct {
11 Name string
12 Channel string
13 }
14
15 func ParseEnvChannels(primary, raw string) []string {
16 if strings.TrimSpace(raw) == "" {
17 return normalizeChannels(primary, nil)
18 }
19
20 parts := strings.Split(raw, ",")
21 return normalizeChannels(primary, parts)
22 }
23
24 func ChannelSlugs(channels []string) []string {
25 out := make([]string, 0, len(channels))
26 for _, channel := range channels {
27 if slug := channelSlug(channel); slug != "" {
28 out = append(out, slug)
29 }
30 }
31 return out
32 }
33
34 func FormatChannels(channels []string) string {
35 if len(channels) == 0 {
36 return "(none)"
37 }
38 return strings.Join(normalizeChannels("", channels), ", ")
39 }
40
41 func WriteChannelStateFile(path, control string, channels []string) error {
42 if path == "" {
43 return nil
44 }
45 control = channelSlug(control)
46 channels = normalizeChannels(control, channels)
47 if len(channels) == 0 {
48 return fmt.Errorf("sessionrelay: channel state requires at least one channel")
49 }
50 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
51 return err
52 }
53
54 data := strings.Join([]string{
55 "SCUTTLEBOT_CHANNEL=" + control,
56 "SCUTTLEBOT_CHANNELS=" + strings.Join(ChannelSlugs(channels), ","),
57 "",
58 }, "\n")
59
60 tmp := path + ".tmp"
61 if err := os.WriteFile(tmp, []byte(data), 0o600); err != nil {
62 return err
63 }
64 return os.Rename(tmp, path)
65 }
66
67 func RemoveChannelStateFile(path string) error {
68 if path == "" {
69 return nil
70 }
71 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
72 return err
73 }
74 return nil
75 }
76
77 func ParseBrokerCommand(text string) (BrokerCommand, bool) {
78 fields := strings.Fields(strings.TrimSpace(text))
79 if len(fields) == 0 {
80 return BrokerCommand{}, false
81 }
82
83 switch strings.ToLower(fields[0]) {
84 case "/join":
85 cmd := BrokerCommand{Name: "join"}
86 if len(fields) > 1 {
87 cmd.Channel = normalizeChannel(fields[1])
88 }
89 return cmd, true
90 case "/part":
91 cmd := BrokerCommand{Name: "part"}
92 if len(fields) > 1 {
93 cmd.Channel = normalizeChannel(fields[1])
94 }
95 return cmd, true
96 case "/channels":
97 return BrokerCommand{Name: "channels"}, true
98 default:
99 return BrokerCommand{}, false
100 }
101 }
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -5,19 +5,25 @@
55
"context"
66
"encoding/json"
77
"errors"
88
"fmt"
99
"net/http"
10
+ "slices"
11
+ "sort"
12
+ "sync"
1013
"time"
1114
)
1215
1316
type httpConnector struct {
1417
http *http.Client
1518
baseURL string
1619
token string
17
- channel string
20
+ primary string
1821
nick string
22
+
23
+ mu sync.RWMutex
24
+ channels []string
1925
}
2026
2127
type httpMessage struct {
2228
At string `json:"at"`
2329
Nick string `json:"nick"`
@@ -24,15 +30,16 @@
2430
Text string `json:"text"`
2531
}
2632
2733
func newHTTPConnector(cfg Config) Connector {
2834
return &httpConnector{
29
- http: cfg.HTTPClient,
30
- baseURL: stringsTrimRightSlash(cfg.URL),
31
- token: cfg.Token,
32
- channel: channelSlug(cfg.Channel),
33
- nick: cfg.Nick,
35
+ http: cfg.HTTPClient,
36
+ baseURL: stringsTrimRightSlash(cfg.URL),
37
+ token: cfg.Token,
38
+ primary: normalizeChannel(cfg.Channel),
39
+ nick: cfg.Nick,
40
+ channels: append([]string(nil), cfg.Channels...),
3441
}
3542
}
3643
3744
func (c *httpConnector) Connect(context.Context) error {
3845
if c.baseURL == "" {
@@ -43,63 +50,129 @@
4350
}
4451
return nil
4552
}
4653
4754
func (c *httpConnector) Post(ctx context.Context, text string) error {
48
- return c.postJSON(ctx, "/v1/channels/"+c.channel+"/messages", map[string]string{
55
+ for _, channel := range c.Channels() {
56
+ if err := c.PostTo(ctx, channel, text); err != nil {
57
+ return err
58
+ }
59
+ }
60
+ return nil
61
+}
62
+
63
+func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
64
+ channel = channelSlug(channel)
65
+ if channel == "" {
66
+ return fmt.Errorf("sessionrelay: post channel is required")
67
+ }
68
+ return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", map[string]string{
4969
"nick": c.nick,
5070
"text": text,
5171
})
5272
}
5373
5474
func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
55
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/channels/"+c.channel+"/messages", nil)
56
- if err != nil {
57
- return nil, err
58
- }
59
- c.authorize(req)
60
-
61
- resp, err := c.http.Do(req)
62
- if err != nil {
63
- return nil, err
64
- }
65
- defer resp.Body.Close()
66
- if resp.StatusCode/100 != 2 {
67
- return nil, fmt.Errorf("sessionrelay: http messages: %s", resp.Status)
68
- }
69
-
70
- var payload struct {
71
- Messages []httpMessage `json:"messages"`
72
- }
73
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
74
- return nil, err
75
- }
76
-
77
- out := make([]Message, 0, len(payload.Messages))
78
- for _, msg := range payload.Messages {
79
- at, err := time.Parse(time.RFC3339Nano, msg.At)
80
- if err != nil {
81
- continue
82
- }
83
- if !at.After(since) {
84
- continue
85
- }
86
- out = append(out, Message{At: at, Nick: msg.Nick, Text: msg.Text})
87
- }
75
+ out := make([]Message, 0, 32)
76
+ for _, channel := range c.Channels() {
77
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/channels/"+channelSlug(channel)+"/messages", nil)
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ c.authorize(req)
82
+
83
+ resp, err := c.http.Do(req)
84
+ if err != nil {
85
+ return nil, err
86
+ }
87
+ if resp.StatusCode/100 != 2 {
88
+ resp.Body.Close()
89
+ return nil, fmt.Errorf("sessionrelay: http messages: %s", resp.Status)
90
+ }
91
+
92
+ var payload struct {
93
+ Messages []httpMessage `json:"messages"`
94
+ }
95
+ err = json.NewDecoder(resp.Body).Decode(&payload)
96
+ resp.Body.Close()
97
+ if err != nil {
98
+ return nil, err
99
+ }
100
+
101
+ for _, msg := range payload.Messages {
102
+ at, err := time.Parse(time.RFC3339Nano, msg.At)
103
+ if err != nil {
104
+ continue
105
+ }
106
+ if !at.After(since) {
107
+ continue
108
+ }
109
+ out = append(out, Message{At: at, Channel: channel, Nick: msg.Nick, Text: msg.Text})
110
+ }
111
+ }
112
+ sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
88113
return out, nil
89114
}
90115
91116
func (c *httpConnector) Touch(ctx context.Context) error {
92
- err := c.postJSON(ctx, "/v1/channels/"+c.channel+"/presence", map[string]string{"nick": c.nick})
93
- if err == nil {
94
- return nil
117
+ for _, channel := range c.Channels() {
118
+ err := c.postJSON(ctx, "/v1/channels/"+channelSlug(channel)+"/presence", map[string]string{"nick": c.nick})
119
+ if err == nil {
120
+ continue
121
+ }
122
+ var statusErr *statusError
123
+ if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) {
124
+ continue
125
+ }
126
+ return err
127
+ }
128
+ return nil
129
+}
130
+
131
+func (c *httpConnector) JoinChannel(_ context.Context, channel string) error {
132
+ channel = normalizeChannel(channel)
133
+ if channel == "" {
134
+ return fmt.Errorf("sessionrelay: join channel is required")
95135
}
96
- var statusErr *statusError
97
- if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) {
136
+ c.mu.Lock()
137
+ defer c.mu.Unlock()
138
+ if slices.Contains(c.channels, channel) {
98139
return nil
99140
}
100
- return err
141
+ c.channels = append(c.channels, channel)
142
+ return nil
143
+}
144
+
145
+func (c *httpConnector) PartChannel(_ context.Context, channel string) error {
146
+ channel = normalizeChannel(channel)
147
+ if channel == "" {
148
+ return fmt.Errorf("sessionrelay: part channel is required")
149
+ }
150
+ if channel == c.primary {
151
+ return fmt.Errorf("sessionrelay: cannot part control channel %s", channel)
152
+ }
153
+ c.mu.Lock()
154
+ defer c.mu.Unlock()
155
+ filtered := c.channels[:0]
156
+ for _, existing := range c.channels {
157
+ if existing == channel {
158
+ continue
159
+ }
160
+ filtered = append(filtered, existing)
161
+ }
162
+ c.channels = filtered
163
+ return nil
164
+}
165
+
166
+func (c *httpConnector) Channels() []string {
167
+ c.mu.RLock()
168
+ defer c.mu.RUnlock()
169
+ return append([]string(nil), c.channels...)
170
+}
171
+
172
+func (c *httpConnector) ControlChannel() string {
173
+ return c.primary
101174
}
102175
103176
func (c *httpConnector) Close(context.Context) error {
104177
return nil
105178
}
106179
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -5,19 +5,25 @@
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
 
 
 
10 "time"
11 )
12
13 type httpConnector struct {
14 http *http.Client
15 baseURL string
16 token string
17 channel string
18 nick string
 
 
 
19 }
20
21 type httpMessage struct {
22 At string `json:"at"`
23 Nick string `json:"nick"`
@@ -24,15 +30,16 @@
24 Text string `json:"text"`
25 }
26
27 func newHTTPConnector(cfg Config) Connector {
28 return &httpConnector{
29 http: cfg.HTTPClient,
30 baseURL: stringsTrimRightSlash(cfg.URL),
31 token: cfg.Token,
32 channel: channelSlug(cfg.Channel),
33 nick: cfg.Nick,
 
34 }
35 }
36
37 func (c *httpConnector) Connect(context.Context) error {
38 if c.baseURL == "" {
@@ -43,63 +50,129 @@
43 }
44 return nil
45 }
46
47 func (c *httpConnector) Post(ctx context.Context, text string) error {
48 return c.postJSON(ctx, "/v1/channels/"+c.channel+"/messages", map[string]string{
 
 
 
 
 
 
 
 
 
 
 
 
 
49 "nick": c.nick,
50 "text": text,
51 })
52 }
53
54 func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
55 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/channels/"+c.channel+"/messages", nil)
56 if err != nil {
57 return nil, err
58 }
59 c.authorize(req)
60
61 resp, err := c.http.Do(req)
62 if err != nil {
63 return nil, err
64 }
65 defer resp.Body.Close()
66 if resp.StatusCode/100 != 2 {
67 return nil, fmt.Errorf("sessionrelay: http messages: %s", resp.Status)
68 }
69
70 var payload struct {
71 Messages []httpMessage `json:"messages"`
72 }
73 if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
74 return nil, err
75 }
76
77 out := make([]Message, 0, len(payload.Messages))
78 for _, msg := range payload.Messages {
79 at, err := time.Parse(time.RFC3339Nano, msg.At)
80 if err != nil {
81 continue
82 }
83 if !at.After(since) {
84 continue
85 }
86 out = append(out, Message{At: at, Nick: msg.Nick, Text: msg.Text})
87 }
 
 
 
 
 
88 return out, nil
89 }
90
91 func (c *httpConnector) Touch(ctx context.Context) error {
92 err := c.postJSON(ctx, "/v1/channels/"+c.channel+"/presence", map[string]string{"nick": c.nick})
93 if err == nil {
94 return nil
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95 }
96 var statusErr *statusError
97 if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) {
 
98 return nil
99 }
100 return err
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101 }
102
103 func (c *httpConnector) Close(context.Context) error {
104 return nil
105 }
106
--- pkg/sessionrelay/http.go
+++ pkg/sessionrelay/http.go
@@ -5,19 +5,25 @@
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 baseURL string
19 token string
20 primary string
21 nick string
22
23 mu sync.RWMutex
24 channels []string
25 }
26
27 type httpMessage struct {
28 At string `json:"at"`
29 Nick string `json:"nick"`
@@ -24,15 +30,16 @@
30 Text string `json:"text"`
31 }
32
33 func newHTTPConnector(cfg Config) Connector {
34 return &httpConnector{
35 http: cfg.HTTPClient,
36 baseURL: stringsTrimRightSlash(cfg.URL),
37 token: cfg.Token,
38 primary: normalizeChannel(cfg.Channel),
39 nick: cfg.Nick,
40 channels: append([]string(nil), cfg.Channels...),
41 }
42 }
43
44 func (c *httpConnector) Connect(context.Context) error {
45 if c.baseURL == "" {
@@ -43,63 +50,129 @@
50 }
51 return nil
52 }
53
54 func (c *httpConnector) Post(ctx context.Context, text string) error {
55 for _, channel := range c.Channels() {
56 if err := c.PostTo(ctx, channel, text); err != nil {
57 return err
58 }
59 }
60 return nil
61 }
62
63 func (c *httpConnector) PostTo(ctx context.Context, channel, text string) error {
64 channel = channelSlug(channel)
65 if channel == "" {
66 return fmt.Errorf("sessionrelay: post channel is required")
67 }
68 return c.postJSON(ctx, "/v1/channels/"+channel+"/messages", map[string]string{
69 "nick": c.nick,
70 "text": text,
71 })
72 }
73
74 func (c *httpConnector) MessagesSince(ctx context.Context, since time.Time) ([]Message, error) {
75 out := make([]Message, 0, 32)
76 for _, channel := range c.Channels() {
77 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/channels/"+channelSlug(channel)+"/messages", nil)
78 if err != nil {
79 return nil, err
80 }
81 c.authorize(req)
82
83 resp, err := c.http.Do(req)
84 if err != nil {
85 return nil, err
86 }
87 if resp.StatusCode/100 != 2 {
88 resp.Body.Close()
89 return nil, fmt.Errorf("sessionrelay: http messages: %s", resp.Status)
90 }
91
92 var payload struct {
93 Messages []httpMessage `json:"messages"`
94 }
95 err = json.NewDecoder(resp.Body).Decode(&payload)
96 resp.Body.Close()
97 if err != nil {
98 return nil, err
99 }
100
101 for _, msg := range payload.Messages {
102 at, err := time.Parse(time.RFC3339Nano, msg.At)
103 if err != nil {
104 continue
105 }
106 if !at.After(since) {
107 continue
108 }
109 out = append(out, Message{At: at, Channel: channel, Nick: msg.Nick, Text: msg.Text})
110 }
111 }
112 sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
113 return out, nil
114 }
115
116 func (c *httpConnector) Touch(ctx context.Context) error {
117 for _, channel := range c.Channels() {
118 err := c.postJSON(ctx, "/v1/channels/"+channelSlug(channel)+"/presence", map[string]string{"nick": c.nick})
119 if err == nil {
120 continue
121 }
122 var statusErr *statusError
123 if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) {
124 continue
125 }
126 return err
127 }
128 return nil
129 }
130
131 func (c *httpConnector) JoinChannel(_ context.Context, channel string) error {
132 channel = normalizeChannel(channel)
133 if channel == "" {
134 return fmt.Errorf("sessionrelay: join channel is required")
135 }
136 c.mu.Lock()
137 defer c.mu.Unlock()
138 if slices.Contains(c.channels, channel) {
139 return nil
140 }
141 c.channels = append(c.channels, channel)
142 return nil
143 }
144
145 func (c *httpConnector) PartChannel(_ context.Context, channel string) error {
146 channel = normalizeChannel(channel)
147 if channel == "" {
148 return fmt.Errorf("sessionrelay: part channel is required")
149 }
150 if channel == c.primary {
151 return fmt.Errorf("sessionrelay: cannot part control channel %s", channel)
152 }
153 c.mu.Lock()
154 defer c.mu.Unlock()
155 filtered := c.channels[:0]
156 for _, existing := range c.channels {
157 if existing == channel {
158 continue
159 }
160 filtered = append(filtered, existing)
161 }
162 c.channels = filtered
163 return nil
164 }
165
166 func (c *httpConnector) Channels() []string {
167 c.mu.RLock()
168 defer c.mu.RUnlock()
169 return append([]string(nil), c.channels...)
170 }
171
172 func (c *httpConnector) ControlChannel() string {
173 return c.primary
174 }
175
176 func (c *httpConnector) Close(context.Context) error {
177 return nil
178 }
179
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -5,10 +5,11 @@
55
"context"
66
"encoding/json"
77
"fmt"
88
"net"
99
"net/http"
10
+ "slices"
1011
"strconv"
1112
"strings"
1213
"sync"
1314
"time"
1415
@@ -17,18 +18,19 @@
1718
1819
type ircConnector struct {
1920
http *http.Client
2021
apiURL string
2122
token string
22
- channel string
23
+ primary string
2324
nick string
2425
addr string
2526
agentType string
2627
pass string
2728
deleteOnClose bool
2829
2930
mu sync.RWMutex
31
+ channels []string
3032
messages []Message
3133
client *girc.Client
3234
errCh chan error
3335
3436
registeredByRelay bool
@@ -40,16 +42,17 @@
4042
}
4143
return &ircConnector{
4244
http: cfg.HTTPClient,
4345
apiURL: stringsTrimRightSlash(cfg.URL),
4446
token: cfg.Token,
45
- channel: normalizeChannel(cfg.Channel),
47
+ primary: normalizeChannel(cfg.Channel),
4648
nick: cfg.Nick,
4749
addr: cfg.IRC.Addr,
4850
agentType: cfg.IRC.AgentType,
4951
pass: cfg.IRC.Pass,
5052
deleteOnClose: cfg.IRC.DeleteOnClose,
53
+ channels: append([]string(nil), cfg.Channels...),
5154
messages: make([]Message, 0, defaultBufferSize),
5255
errCh: make(chan error, 1),
5356
}, nil
5457
}
5558
@@ -72,27 +75,29 @@
7275
User: c.nick,
7376
Name: c.nick + " (session relay)",
7477
SASL: &girc.SASLPlain{User: c.nick, Pass: c.pass},
7578
})
7679
client.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
77
- cl.Cmd.Join(c.channel)
80
+ for _, channel := range c.Channels() {
81
+ cl.Cmd.Join(channel)
82
+ }
7883
})
7984
client.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
8085
if len(e.Params) < 1 || e.Source == nil || e.Source.Name != c.nick {
8186
return
8287
}
83
- if normalizeChannel(e.Params[0]) != c.channel {
88
+ if normalizeChannel(e.Params[0]) != c.primary {
8489
return
8590
}
8691
joinOnce.Do(func() { close(joined) })
8792
})
8893
client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
8994
if len(e.Params) < 1 || e.Source == nil {
9095
return
9196
}
9297
target := normalizeChannel(e.Params[0])
93
- if target != c.channel {
98
+ if !c.hasChannel(target) {
9499
return
95100
}
96101
sender := e.Source.Name
97102
text := strings.TrimSpace(e.Last())
98103
if sender == "bridge" && strings.HasPrefix(text, "[") {
@@ -99,11 +104,11 @@
99104
if end := strings.Index(text, "] "); end != -1 {
100105
sender = text[1:end]
101106
text = strings.TrimSpace(text[end+2:])
102107
}
103108
}
104
- c.appendMessage(Message{At: time.Now(), Nick: sender, Text: text})
109
+ c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
105110
})
106111
107112
c.client = client
108113
go func() {
109114
if err := client.Connect(); err != nil && ctx.Err() == nil {
@@ -128,11 +133,25 @@
128133
129134
func (c *ircConnector) Post(_ context.Context, text string) error {
130135
if c.client == nil {
131136
return fmt.Errorf("sessionrelay: irc client not connected")
132137
}
133
- c.client.Cmd.Message(c.channel, text)
138
+ for _, channel := range c.Channels() {
139
+ c.client.Cmd.Message(channel, text)
140
+ }
141
+ return nil
142
+}
143
+
144
+func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
145
+ if c.client == nil {
146
+ return fmt.Errorf("sessionrelay: irc client not connected")
147
+ }
148
+ channel = normalizeChannel(channel)
149
+ if channel == "" {
150
+ return fmt.Errorf("sessionrelay: post channel is required")
151
+ }
152
+ c.client.Cmd.Message(channel, text)
134153
return nil
135154
}
136155
137156
func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
138157
c.mu.RLock()
@@ -148,10 +167,68 @@
148167
}
149168
150169
func (c *ircConnector) Touch(context.Context) error {
151170
return nil
152171
}
172
+
173
+func (c *ircConnector) JoinChannel(_ context.Context, channel string) error {
174
+ channel = normalizeChannel(channel)
175
+ if channel == "" {
176
+ return fmt.Errorf("sessionrelay: join channel is required")
177
+ }
178
+ c.mu.Lock()
179
+ if slices.Contains(c.channels, channel) {
180
+ c.mu.Unlock()
181
+ return nil
182
+ }
183
+ c.channels = append(c.channels, channel)
184
+ client := c.client
185
+ c.mu.Unlock()
186
+ if client != nil {
187
+ client.Cmd.Join(channel)
188
+ }
189
+ return nil
190
+}
191
+
192
+func (c *ircConnector) PartChannel(_ context.Context, channel string) error {
193
+ channel = normalizeChannel(channel)
194
+ if channel == "" {
195
+ return fmt.Errorf("sessionrelay: part channel is required")
196
+ }
197
+ if channel == c.primary {
198
+ return fmt.Errorf("sessionrelay: cannot part control channel %s", channel)
199
+ }
200
+ c.mu.Lock()
201
+ if !slices.Contains(c.channels, channel) {
202
+ c.mu.Unlock()
203
+ return nil
204
+ }
205
+ filtered := c.channels[:0]
206
+ for _, existing := range c.channels {
207
+ if existing == channel {
208
+ continue
209
+ }
210
+ filtered = append(filtered, existing)
211
+ }
212
+ c.channels = filtered
213
+ client := c.client
214
+ c.mu.Unlock()
215
+ if client != nil {
216
+ client.Cmd.Part(channel)
217
+ }
218
+ return nil
219
+}
220
+
221
+func (c *ircConnector) Channels() []string {
222
+ c.mu.RLock()
223
+ defer c.mu.RUnlock()
224
+ return append([]string(nil), c.channels...)
225
+}
226
+
227
+func (c *ircConnector) ControlChannel() string {
228
+ return c.primary
229
+}
153230
154231
func (c *ircConnector) Close(ctx context.Context) error {
155232
if c.client != nil {
156233
c.client.Close()
157234
}
@@ -187,11 +264,11 @@
187264
188265
func (c *ircConnector) registerOrRotate(ctx context.Context) (bool, string, error) {
189266
body, _ := json.Marshal(map[string]any{
190267
"nick": c.nick,
191268
"type": c.agentType,
192
- "channels": []string{c.channel},
269
+ "channels": c.Channels(),
193270
})
194271
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/v1/agents/register", bytes.NewReader(body))
195272
if err != nil {
196273
return false, "", err
197274
}
@@ -266,10 +343,16 @@
266343
return fmt.Errorf("sessionrelay: delete %s: %s", c.nick, resp.Status)
267344
}
268345
c.registeredByRelay = false
269346
return nil
270347
}
348
+
349
+func (c *ircConnector) hasChannel(channel string) bool {
350
+ c.mu.RLock()
351
+ defer c.mu.RUnlock()
352
+ return slices.Contains(c.channels, channel)
353
+}
271354
272355
func splitHostPort(addr string) (string, int, error) {
273356
host, portStr, err := net.SplitHostPort(addr)
274357
if err != nil {
275358
return "", 0, fmt.Errorf("sessionrelay: invalid irc address %q: %w", addr, err)
276359
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -5,10 +5,11 @@
5 "context"
6 "encoding/json"
7 "fmt"
8 "net"
9 "net/http"
 
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
@@ -17,18 +18,19 @@
17
18 type ircConnector struct {
19 http *http.Client
20 apiURL string
21 token string
22 channel string
23 nick string
24 addr string
25 agentType string
26 pass string
27 deleteOnClose bool
28
29 mu sync.RWMutex
 
30 messages []Message
31 client *girc.Client
32 errCh chan error
33
34 registeredByRelay bool
@@ -40,16 +42,17 @@
40 }
41 return &ircConnector{
42 http: cfg.HTTPClient,
43 apiURL: stringsTrimRightSlash(cfg.URL),
44 token: cfg.Token,
45 channel: normalizeChannel(cfg.Channel),
46 nick: cfg.Nick,
47 addr: cfg.IRC.Addr,
48 agentType: cfg.IRC.AgentType,
49 pass: cfg.IRC.Pass,
50 deleteOnClose: cfg.IRC.DeleteOnClose,
 
51 messages: make([]Message, 0, defaultBufferSize),
52 errCh: make(chan error, 1),
53 }, nil
54 }
55
@@ -72,27 +75,29 @@
72 User: c.nick,
73 Name: c.nick + " (session relay)",
74 SASL: &girc.SASLPlain{User: c.nick, Pass: c.pass},
75 })
76 client.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
77 cl.Cmd.Join(c.channel)
 
 
78 })
79 client.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
80 if len(e.Params) < 1 || e.Source == nil || e.Source.Name != c.nick {
81 return
82 }
83 if normalizeChannel(e.Params[0]) != c.channel {
84 return
85 }
86 joinOnce.Do(func() { close(joined) })
87 })
88 client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
89 if len(e.Params) < 1 || e.Source == nil {
90 return
91 }
92 target := normalizeChannel(e.Params[0])
93 if target != c.channel {
94 return
95 }
96 sender := e.Source.Name
97 text := strings.TrimSpace(e.Last())
98 if sender == "bridge" && strings.HasPrefix(text, "[") {
@@ -99,11 +104,11 @@
99 if end := strings.Index(text, "] "); end != -1 {
100 sender = text[1:end]
101 text = strings.TrimSpace(text[end+2:])
102 }
103 }
104 c.appendMessage(Message{At: time.Now(), Nick: sender, Text: text})
105 })
106
107 c.client = client
108 go func() {
109 if err := client.Connect(); err != nil && ctx.Err() == nil {
@@ -128,11 +133,25 @@
128
129 func (c *ircConnector) Post(_ context.Context, text string) error {
130 if c.client == nil {
131 return fmt.Errorf("sessionrelay: irc client not connected")
132 }
133 c.client.Cmd.Message(c.channel, text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134 return nil
135 }
136
137 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
138 c.mu.RLock()
@@ -148,10 +167,68 @@
148 }
149
150 func (c *ircConnector) Touch(context.Context) error {
151 return nil
152 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
154 func (c *ircConnector) Close(ctx context.Context) error {
155 if c.client != nil {
156 c.client.Close()
157 }
@@ -187,11 +264,11 @@
187
188 func (c *ircConnector) registerOrRotate(ctx context.Context) (bool, string, error) {
189 body, _ := json.Marshal(map[string]any{
190 "nick": c.nick,
191 "type": c.agentType,
192 "channels": []string{c.channel},
193 })
194 req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/v1/agents/register", bytes.NewReader(body))
195 if err != nil {
196 return false, "", err
197 }
@@ -266,10 +343,16 @@
266 return fmt.Errorf("sessionrelay: delete %s: %s", c.nick, resp.Status)
267 }
268 c.registeredByRelay = false
269 return nil
270 }
 
 
 
 
 
 
271
272 func splitHostPort(addr string) (string, int, error) {
273 host, portStr, err := net.SplitHostPort(addr)
274 if err != nil {
275 return "", 0, fmt.Errorf("sessionrelay: invalid irc address %q: %w", addr, err)
276
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -5,10 +5,11 @@
5 "context"
6 "encoding/json"
7 "fmt"
8 "net"
9 "net/http"
10 "slices"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
@@ -17,18 +18,19 @@
18
19 type ircConnector struct {
20 http *http.Client
21 apiURL string
22 token string
23 primary string
24 nick string
25 addr string
26 agentType string
27 pass string
28 deleteOnClose bool
29
30 mu sync.RWMutex
31 channels []string
32 messages []Message
33 client *girc.Client
34 errCh chan error
35
36 registeredByRelay bool
@@ -40,16 +42,17 @@
42 }
43 return &ircConnector{
44 http: cfg.HTTPClient,
45 apiURL: stringsTrimRightSlash(cfg.URL),
46 token: cfg.Token,
47 primary: normalizeChannel(cfg.Channel),
48 nick: cfg.Nick,
49 addr: cfg.IRC.Addr,
50 agentType: cfg.IRC.AgentType,
51 pass: cfg.IRC.Pass,
52 deleteOnClose: cfg.IRC.DeleteOnClose,
53 channels: append([]string(nil), cfg.Channels...),
54 messages: make([]Message, 0, defaultBufferSize),
55 errCh: make(chan error, 1),
56 }, nil
57 }
58
@@ -72,27 +75,29 @@
75 User: c.nick,
76 Name: c.nick + " (session relay)",
77 SASL: &girc.SASLPlain{User: c.nick, Pass: c.pass},
78 })
79 client.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
80 for _, channel := range c.Channels() {
81 cl.Cmd.Join(channel)
82 }
83 })
84 client.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
85 if len(e.Params) < 1 || e.Source == nil || e.Source.Name != c.nick {
86 return
87 }
88 if normalizeChannel(e.Params[0]) != c.primary {
89 return
90 }
91 joinOnce.Do(func() { close(joined) })
92 })
93 client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
94 if len(e.Params) < 1 || e.Source == nil {
95 return
96 }
97 target := normalizeChannel(e.Params[0])
98 if !c.hasChannel(target) {
99 return
100 }
101 sender := e.Source.Name
102 text := strings.TrimSpace(e.Last())
103 if sender == "bridge" && strings.HasPrefix(text, "[") {
@@ -99,11 +104,11 @@
104 if end := strings.Index(text, "] "); end != -1 {
105 sender = text[1:end]
106 text = strings.TrimSpace(text[end+2:])
107 }
108 }
109 c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
110 })
111
112 c.client = client
113 go func() {
114 if err := client.Connect(); err != nil && ctx.Err() == nil {
@@ -128,11 +133,25 @@
133
134 func (c *ircConnector) Post(_ context.Context, text string) error {
135 if c.client == nil {
136 return fmt.Errorf("sessionrelay: irc client not connected")
137 }
138 for _, channel := range c.Channels() {
139 c.client.Cmd.Message(channel, text)
140 }
141 return nil
142 }
143
144 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
145 if c.client == nil {
146 return fmt.Errorf("sessionrelay: irc client not connected")
147 }
148 channel = normalizeChannel(channel)
149 if channel == "" {
150 return fmt.Errorf("sessionrelay: post channel is required")
151 }
152 c.client.Cmd.Message(channel, text)
153 return nil
154 }
155
156 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
157 c.mu.RLock()
@@ -148,10 +167,68 @@
167 }
168
169 func (c *ircConnector) Touch(context.Context) error {
170 return nil
171 }
172
173 func (c *ircConnector) JoinChannel(_ context.Context, channel string) error {
174 channel = normalizeChannel(channel)
175 if channel == "" {
176 return fmt.Errorf("sessionrelay: join channel is required")
177 }
178 c.mu.Lock()
179 if slices.Contains(c.channels, channel) {
180 c.mu.Unlock()
181 return nil
182 }
183 c.channels = append(c.channels, channel)
184 client := c.client
185 c.mu.Unlock()
186 if client != nil {
187 client.Cmd.Join(channel)
188 }
189 return nil
190 }
191
192 func (c *ircConnector) PartChannel(_ context.Context, channel string) error {
193 channel = normalizeChannel(channel)
194 if channel == "" {
195 return fmt.Errorf("sessionrelay: part channel is required")
196 }
197 if channel == c.primary {
198 return fmt.Errorf("sessionrelay: cannot part control channel %s", channel)
199 }
200 c.mu.Lock()
201 if !slices.Contains(c.channels, channel) {
202 c.mu.Unlock()
203 return nil
204 }
205 filtered := c.channels[:0]
206 for _, existing := range c.channels {
207 if existing == channel {
208 continue
209 }
210 filtered = append(filtered, existing)
211 }
212 c.channels = filtered
213 client := c.client
214 c.mu.Unlock()
215 if client != nil {
216 client.Cmd.Part(channel)
217 }
218 return nil
219 }
220
221 func (c *ircConnector) Channels() []string {
222 c.mu.RLock()
223 defer c.mu.RUnlock()
224 return append([]string(nil), c.channels...)
225 }
226
227 func (c *ircConnector) ControlChannel() string {
228 return c.primary
229 }
230
231 func (c *ircConnector) Close(ctx context.Context) error {
232 if c.client != nil {
233 c.client.Close()
234 }
@@ -187,11 +264,11 @@
264
265 func (c *ircConnector) registerOrRotate(ctx context.Context) (bool, string, error) {
266 body, _ := json.Marshal(map[string]any{
267 "nick": c.nick,
268 "type": c.agentType,
269 "channels": c.Channels(),
270 })
271 req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/v1/agents/register", bytes.NewReader(body))
272 if err != nil {
273 return false, "", err
274 }
@@ -266,10 +343,16 @@
343 return fmt.Errorf("sessionrelay: delete %s: %s", c.nick, resp.Status)
344 }
345 c.registeredByRelay = false
346 return nil
347 }
348
349 func (c *ircConnector) hasChannel(channel string) bool {
350 c.mu.RLock()
351 defer c.mu.RUnlock()
352 return slices.Contains(c.channels, channel)
353 }
354
355 func splitHostPort(addr string) (string, int, error) {
356 host, portStr, err := net.SplitHostPort(addr)
357 if err != nil {
358 return "", 0, fmt.Errorf("sessionrelay: invalid irc address %q: %w", addr, err)
359
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -23,10 +23,11 @@
2323
type Config struct {
2424
Transport Transport
2525
URL string
2626
Token string
2727
Channel string
28
+ Channels []string
2829
Nick string
2930
HTTPClient *http.Client
3031
IRC IRCConfig
3132
}
3233
@@ -36,20 +37,26 @@
3637
AgentType string
3738
DeleteOnClose bool
3839
}
3940
4041
type Message struct {
41
- At time.Time
42
- Nick string
43
- Text string
42
+ At time.Time
43
+ Channel string
44
+ Nick string
45
+ Text string
4446
}
4547
4648
type Connector interface {
4749
Connect(ctx context.Context) error
4850
Post(ctx context.Context, text string) error
51
+ PostTo(ctx context.Context, channel, text string) error
4952
MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
5053
Touch(ctx context.Context) error
54
+ JoinChannel(ctx context.Context, channel string) error
55
+ PartChannel(ctx context.Context, channel string) error
56
+ Channels() []string
57
+ ControlChannel() string
5158
Close(ctx context.Context) error
5259
}
5360
5461
func New(cfg Config) (Connector, error) {
5562
cfg = withDefaults(cfg)
@@ -76,16 +83,20 @@
7683
}
7784
if cfg.IRC.AgentType == "" {
7885
cfg.IRC.AgentType = "worker"
7986
}
8087
cfg.Channel = normalizeChannel(cfg.Channel)
88
+ cfg.Channels = normalizeChannels(cfg.Channel, cfg.Channels)
89
+ if cfg.Channel == "" && len(cfg.Channels) > 0 {
90
+ cfg.Channel = cfg.Channels[0]
91
+ }
8192
cfg.Transport = Transport(strings.ToLower(string(cfg.Transport)))
8293
return cfg
8394
}
8495
8596
func validateBaseConfig(cfg Config) error {
86
- if cfg.Channel == "" {
97
+ if cfg.Channel == "" || len(cfg.Channels) == 0 {
8798
return fmt.Errorf("sessionrelay: channel is required")
8899
}
89100
if cfg.Nick == "" {
90101
return fmt.Errorf("sessionrelay: nick is required")
91102
}
@@ -104,5 +115,28 @@
104115
}
105116
106117
func channelSlug(channel string) string {
107118
return strings.TrimPrefix(normalizeChannel(channel), "#")
108119
}
120
+
121
+func normalizeChannels(primary string, channels []string) []string {
122
+ seen := make(map[string]struct{}, len(channels)+1)
123
+ out := make([]string, 0, len(channels)+1)
124
+
125
+ add := func(channel string) {
126
+ channel = normalizeChannel(channel)
127
+ if channel == "" {
128
+ return
129
+ }
130
+ if _, ok := seen[channel]; ok {
131
+ return
132
+ }
133
+ seen[channel] = struct{}{}
134
+ out = append(out, channel)
135
+ }
136
+
137
+ add(primary)
138
+ for _, channel := range channels {
139
+ add(channel)
140
+ }
141
+ return out
142
+}
109143
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -23,10 +23,11 @@
23 type Config struct {
24 Transport Transport
25 URL string
26 Token string
27 Channel string
 
28 Nick string
29 HTTPClient *http.Client
30 IRC IRCConfig
31 }
32
@@ -36,20 +37,26 @@
36 AgentType string
37 DeleteOnClose bool
38 }
39
40 type Message struct {
41 At time.Time
42 Nick string
43 Text string
 
44 }
45
46 type Connector interface {
47 Connect(ctx context.Context) error
48 Post(ctx context.Context, text string) error
 
49 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
50 Touch(ctx context.Context) error
 
 
 
 
51 Close(ctx context.Context) error
52 }
53
54 func New(cfg Config) (Connector, error) {
55 cfg = withDefaults(cfg)
@@ -76,16 +83,20 @@
76 }
77 if cfg.IRC.AgentType == "" {
78 cfg.IRC.AgentType = "worker"
79 }
80 cfg.Channel = normalizeChannel(cfg.Channel)
 
 
 
 
81 cfg.Transport = Transport(strings.ToLower(string(cfg.Transport)))
82 return cfg
83 }
84
85 func validateBaseConfig(cfg Config) error {
86 if cfg.Channel == "" {
87 return fmt.Errorf("sessionrelay: channel is required")
88 }
89 if cfg.Nick == "" {
90 return fmt.Errorf("sessionrelay: nick is required")
91 }
@@ -104,5 +115,28 @@
104 }
105
106 func channelSlug(channel string) string {
107 return strings.TrimPrefix(normalizeChannel(channel), "#")
108 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -23,10 +23,11 @@
23 type Config struct {
24 Transport Transport
25 URL string
26 Token string
27 Channel string
28 Channels []string
29 Nick string
30 HTTPClient *http.Client
31 IRC IRCConfig
32 }
33
@@ -36,20 +37,26 @@
37 AgentType string
38 DeleteOnClose bool
39 }
40
41 type Message struct {
42 At time.Time
43 Channel string
44 Nick string
45 Text string
46 }
47
48 type Connector interface {
49 Connect(ctx context.Context) error
50 Post(ctx context.Context, text string) error
51 PostTo(ctx context.Context, channel, text string) error
52 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
53 Touch(ctx context.Context) error
54 JoinChannel(ctx context.Context, channel string) error
55 PartChannel(ctx context.Context, channel string) error
56 Channels() []string
57 ControlChannel() string
58 Close(ctx context.Context) error
59 }
60
61 func New(cfg Config) (Connector, error) {
62 cfg = withDefaults(cfg)
@@ -76,16 +83,20 @@
83 }
84 if cfg.IRC.AgentType == "" {
85 cfg.IRC.AgentType = "worker"
86 }
87 cfg.Channel = normalizeChannel(cfg.Channel)
88 cfg.Channels = normalizeChannels(cfg.Channel, cfg.Channels)
89 if cfg.Channel == "" && len(cfg.Channels) > 0 {
90 cfg.Channel = cfg.Channels[0]
91 }
92 cfg.Transport = Transport(strings.ToLower(string(cfg.Transport)))
93 return cfg
94 }
95
96 func validateBaseConfig(cfg Config) error {
97 if cfg.Channel == "" || len(cfg.Channels) == 0 {
98 return fmt.Errorf("sessionrelay: channel is required")
99 }
100 if cfg.Nick == "" {
101 return fmt.Errorf("sessionrelay: nick is required")
102 }
@@ -104,5 +115,28 @@
115 }
116
117 func channelSlug(channel string) string {
118 return strings.TrimPrefix(normalizeChannel(channel), "#")
119 }
120
121 func normalizeChannels(primary string, channels []string) []string {
122 seen := make(map[string]struct{}, len(channels)+1)
123 out := make([]string, 0, len(channels)+1)
124
125 add := func(channel string) {
126 channel = normalizeChannel(channel)
127 if channel == "" {
128 return
129 }
130 if _, ok := seen[channel]; ok {
131 return
132 }
133 seen[channel] = struct{}{}
134 out = append(out, channel)
135 }
136
137 add(primary)
138 for _, channel := range channels {
139 add(channel)
140 }
141 return out
142 }
143
--- pkg/sessionrelay/sessionrelay_test.go
+++ pkg/sessionrelay/sessionrelay_test.go
@@ -3,40 +3,64 @@
33
import (
44
"context"
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8
+ "os"
9
+ "slices"
810
"testing"
911
"time"
1012
)
1113
1214
func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) {
1315
t.Helper()
1416
1517
base := time.Date(2026, 3, 31, 22, 0, 0, 0, time.UTC)
16
- var gotAuth string
17
- var posted map[string]string
18
- var touched map[string]string
18
+ var gotAuth []string
19
+ var posted []string
20
+ var touched []string
1921
2022
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21
- gotAuth = r.Header.Get("Authorization")
23
+ gotAuth = append(gotAuth, r.Header.Get("Authorization"))
2224
switch {
2325
case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/messages":
24
- if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
25
- t.Fatalf("decode post body: %v", err)
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["text"])
31
+ w.WriteHeader(http.StatusNoContent)
32
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/messages":
33
+ var body map[string]string
34
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
35
+ t.Fatalf("decode release post body: %v", err)
2636
}
37
+ posted = append(posted, "release:"+body["nick"]+":"+body["text"])
2738
w.WriteHeader(http.StatusNoContent)
2839
case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/presence":
29
- if err := json.NewDecoder(r.Body).Decode(&touched); err != nil {
30
- t.Fatalf("decode touch body: %v", err)
40
+ var body map[string]string
41
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
42
+ t.Fatalf("decode general touch body: %v", err)
43
+ }
44
+ touched = append(touched, "general:"+body["nick"])
45
+ w.WriteHeader(http.StatusNoContent)
46
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/presence":
47
+ var body map[string]string
48
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
49
+ t.Fatalf("decode release touch body: %v", err)
3150
}
51
+ touched = append(touched, "release:"+body["nick"])
3252
w.WriteHeader(http.StatusNoContent)
3353
case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages":
3454
_ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
3555
{"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"},
3656
{"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"},
3757
}})
58
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/release/messages":
59
+ _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
60
+ {"at": base.Add(2 * time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: /join #task-42"},
61
+ }})
3862
default:
3963
http.NotFound(w, r)
4064
}
4165
}))
4266
defer srv.Close()
@@ -44,10 +68,11 @@
4468
conn, err := New(Config{
4569
Transport: TransportHTTP,
4670
URL: srv.URL,
4771
Token: "test-token",
4872
Channel: "general",
73
+ Channels: []string{"general", "release"},
4974
Nick: "codex-test",
5075
HTTPClient: srv.Client(),
5176
})
5277
if err != nil {
5378
t.Fatal(err)
@@ -56,71 +81,88 @@
5681
t.Fatal(err)
5782
}
5883
if err := conn.Post(context.Background(), "online"); err != nil {
5984
t.Fatal(err)
6085
}
61
- if posted["nick"] != "codex-test" || posted["text"] != "online" {
62
- t.Fatalf("posted body = %#v", posted)
86
+ if want := []string{"general:codex-test:online", "release:codex-test:online"}; !slices.Equal(posted, want) {
87
+ t.Fatalf("posted = %#v, want %#v", posted, want)
6388
}
64
- if gotAuth != "Bearer test-token" {
65
- t.Fatalf("authorization = %q", gotAuth)
89
+ for _, auth := range gotAuth {
90
+ if auth != "Bearer test-token" {
91
+ t.Fatalf("authorization = %q", auth)
92
+ }
6693
}
6794
6895
msgs, err := conn.MessagesSince(context.Background(), base)
6996
if err != nil {
7097
t.Fatal(err)
7198
}
72
- if len(msgs) != 1 || msgs[0].Nick != "glengoolie" {
73
- t.Fatalf("MessagesSince = %#v", msgs)
74
- }
75
-
76
- if err := conn.Touch(context.Background()); err != nil {
77
- t.Fatal(err)
78
- }
79
- if touched["nick"] != "codex-test" {
80
- t.Fatalf("touch body = %#v", touched)
81
- }
82
-}
83
-
84
-func TestHTTPConnectorTouchIgnoresMissingPresenceEndpoint(t *testing.T) {
85
- t.Helper()
86
-
87
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88
- if r.URL.Path == "/v1/channels/general/presence" {
89
- http.NotFound(w, r)
90
- return
91
- }
92
- w.WriteHeader(http.StatusNoContent)
93
- }))
94
- defer srv.Close()
95
-
96
- conn, err := New(Config{
97
- Transport: TransportHTTP,
98
- URL: srv.URL,
99
- Token: "test-token",
100
- Channel: "general",
101
- Nick: "codex-test",
102
- HTTPClient: srv.Client(),
103
- })
104
- if err != nil {
105
- t.Fatal(err)
106
- }
107
- if err := conn.Connect(context.Background()); err != nil {
108
- t.Fatal(err)
109
- }
110
- if err := conn.Touch(context.Background()); err != nil {
111
- t.Fatalf("Touch() = %v, want nil on 404", err)
99
+ if len(msgs) != 2 {
100
+ t.Fatalf("MessagesSince len = %d, want 2", len(msgs))
101
+ }
102
+ if msgs[0].Channel != "#general" || msgs[1].Channel != "#release" {
103
+ t.Fatalf("MessagesSince channels = %#v", msgs)
104
+ }
105
+
106
+ if err := conn.Touch(context.Background()); err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ if want := []string{"general:codex-test", "release:codex-test"}; !slices.Equal(touched, want) {
110
+ t.Fatalf("touches = %#v, want %#v", touched, want)
111
+ }
112
+}
113
+
114
+func TestHTTPConnectorJoinPartAndControlChannel(t *testing.T) {
115
+ t.Helper()
116
+
117
+ conn, err := New(Config{
118
+ Transport: TransportHTTP,
119
+ URL: "http://example.com",
120
+ Token: "test-token",
121
+ Channel: "general",
122
+ Channels: []string{"general", "release"},
123
+ Nick: "codex-test",
124
+ })
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+
129
+ if got := conn.ControlChannel(); got != "#general" {
130
+ t.Fatalf("ControlChannel = %q, want #general", got)
131
+ }
132
+ if err := conn.JoinChannel(context.Background(), "#task-42"); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if want := []string{"#general", "#release", "#task-42"}; !slices.Equal(conn.Channels(), want) {
136
+ t.Fatalf("Channels after join = %#v, want %#v", conn.Channels(), want)
137
+ }
138
+ if err := conn.PartChannel(context.Background(), "#general"); err == nil {
139
+ t.Fatal("PartChannel(control) = nil, want error")
140
+ }
141
+ if err := conn.PartChannel(context.Background(), "#release"); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ if want := []string{"#general", "#task-42"}; !slices.Equal(conn.Channels(), want) {
145
+ t.Fatalf("Channels after part = %#v, want %#v", conn.Channels(), want)
112146
}
113147
}
114148
115149
func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) {
116150
t.Helper()
117151
118152
var deletedPath string
153
+ var registerChannels []string
119154
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120155
switch {
121156
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register":
157
+ var body struct {
158
+ Channels []string `json:"channels"`
159
+ }
160
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
161
+ t.Fatalf("decode register body: %v", err)
162
+ }
163
+ registerChannels = body.Channels
122164
w.WriteHeader(http.StatusCreated)
123165
_ = json.NewEncoder(w).Encode(map[string]any{
124166
"credentials": map[string]string{"passphrase": "created-pass"},
125167
})
126168
case r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/codex-1234":
@@ -135,11 +177,12 @@
135177
conn := &ircConnector{
136178
http: srv.Client(),
137179
apiURL: srv.URL,
138180
token: "test-token",
139181
nick: "codex-1234",
140
- channel: "#general",
182
+ primary: "#general",
183
+ channels: []string{"#general", "#release"},
141184
agentType: "worker",
142185
deleteOnClose: true,
143186
}
144187
145188
created, pass, err := conn.registerOrRotate(context.Background())
@@ -146,10 +189,13 @@
146189
if err != nil {
147190
t.Fatal(err)
148191
}
149192
if !created || pass != "created-pass" {
150193
t.Fatalf("registerOrRotate = (%v, %q), want (true, created-pass)", created, pass)
194
+ }
195
+ if want := []string{"#general", "#release"}; !slices.Equal(registerChannels, want) {
196
+ t.Fatalf("register channels = %#v, want %#v", registerChannels, want)
151197
}
152198
conn.registeredByRelay = created
153199
if err := conn.cleanupRegistration(context.Background()); err != nil {
154200
t.Fatal(err)
155201
}
@@ -178,11 +224,12 @@
178224
conn := &ircConnector{
179225
http: srv.Client(),
180226
apiURL: srv.URL,
181227
token: "test-token",
182228
nick: "codex-1234",
183
- channel: "#general",
229
+ primary: "#general",
230
+ channels: []string{"#general"},
184231
agentType: "worker",
185232
}
186233
187234
created, pass, err := conn.registerOrRotate(context.Background())
188235
if err != nil {
@@ -193,5 +240,45 @@
193240
}
194241
if !rotateCalled || pass != "rotated-pass" {
195242
t.Fatalf("rotate fallback = (called=%v, pass=%q)", rotateCalled, pass)
196243
}
197244
}
245
+
246
+func TestWriteChannelStateFile(t *testing.T) {
247
+ t.Helper()
248
+
249
+ dir := t.TempDir()
250
+ path := dir + "/channels.env"
251
+ if err := WriteChannelStateFile(path, "general", []string{"#general", "#release"}); err != nil {
252
+ t.Fatal(err)
253
+ }
254
+ data, err := os.ReadFile(path)
255
+ if err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ want := "SCUTTLEBOT_CHANNEL=general\nSCUTTLEBOT_CHANNELS=general,release\n"
259
+ if string(data) != want {
260
+ t.Fatalf("state file = %q, want %q", string(data), want)
261
+ }
262
+}
263
+
264
+func TestParseBrokerCommand(t *testing.T) {
265
+ t.Helper()
266
+
267
+ tests := []struct {
268
+ input string
269
+ want BrokerCommand
270
+ ok bool
271
+ }{
272
+ {input: "/channels", want: BrokerCommand{Name: "channels"}, ok: true},
273
+ {input: "/join task-42", want: BrokerCommand{Name: "join", Channel: "#task-42"}, ok: true},
274
+ {input: "/part #release", want: BrokerCommand{Name: "part", Channel: "#release"}, ok: true},
275
+ {input: "please read README", ok: false},
276
+ }
277
+
278
+ for _, tt := range tests {
279
+ got, ok := ParseBrokerCommand(tt.input)
280
+ if ok != tt.ok || got != tt.want {
281
+ t.Fatalf("ParseBrokerCommand(%q) = (%#v, %v), want (%#v, %v)", tt.input, got, ok, tt.want, tt.ok)
282
+ }
283
+ }
284
+}
198285
--- pkg/sessionrelay/sessionrelay_test.go
+++ pkg/sessionrelay/sessionrelay_test.go
@@ -3,40 +3,64 @@
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
 
 
8 "testing"
9 "time"
10 )
11
12 func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) {
13 t.Helper()
14
15 base := time.Date(2026, 3, 31, 22, 0, 0, 0, time.UTC)
16 var gotAuth string
17 var posted map[string]string
18 var touched map[string]string
19
20 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 gotAuth = r.Header.Get("Authorization")
22 switch {
23 case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/messages":
24 if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
25 t.Fatalf("decode post body: %v", err)
 
 
 
 
 
 
 
 
26 }
 
27 w.WriteHeader(http.StatusNoContent)
28 case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/presence":
29 if err := json.NewDecoder(r.Body).Decode(&touched); err != nil {
30 t.Fatalf("decode touch body: %v", err)
 
 
 
 
 
 
 
 
31 }
 
32 w.WriteHeader(http.StatusNoContent)
33 case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages":
34 _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
35 {"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"},
36 {"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"},
37 }})
 
 
 
 
38 default:
39 http.NotFound(w, r)
40 }
41 }))
42 defer srv.Close()
@@ -44,10 +68,11 @@
44 conn, err := New(Config{
45 Transport: TransportHTTP,
46 URL: srv.URL,
47 Token: "test-token",
48 Channel: "general",
 
49 Nick: "codex-test",
50 HTTPClient: srv.Client(),
51 })
52 if err != nil {
53 t.Fatal(err)
@@ -56,71 +81,88 @@
56 t.Fatal(err)
57 }
58 if err := conn.Post(context.Background(), "online"); err != nil {
59 t.Fatal(err)
60 }
61 if posted["nick"] != "codex-test" || posted["text"] != "online" {
62 t.Fatalf("posted body = %#v", posted)
63 }
64 if gotAuth != "Bearer test-token" {
65 t.Fatalf("authorization = %q", gotAuth)
 
 
66 }
67
68 msgs, err := conn.MessagesSince(context.Background(), base)
69 if err != nil {
70 t.Fatal(err)
71 }
72 if len(msgs) != 1 || msgs[0].Nick != "glengoolie" {
73 t.Fatalf("MessagesSince = %#v", msgs)
74 }
75
76 if err := conn.Touch(context.Background()); err != nil {
77 t.Fatal(err)
78 }
79 if touched["nick"] != "codex-test" {
80 t.Fatalf("touch body = %#v", touched)
81 }
82 }
83
84 func TestHTTPConnectorTouchIgnoresMissingPresenceEndpoint(t *testing.T) {
85 t.Helper()
86
87 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 if r.URL.Path == "/v1/channels/general/presence" {
89 http.NotFound(w, r)
90 return
91 }
92 w.WriteHeader(http.StatusNoContent)
93 }))
94 defer srv.Close()
95
96 conn, err := New(Config{
97 Transport: TransportHTTP,
98 URL: srv.URL,
99 Token: "test-token",
100 Channel: "general",
101 Nick: "codex-test",
102 HTTPClient: srv.Client(),
103 })
104 if err != nil {
105 t.Fatal(err)
106 }
107 if err := conn.Connect(context.Background()); err != nil {
108 t.Fatal(err)
109 }
110 if err := conn.Touch(context.Background()); err != nil {
111 t.Fatalf("Touch() = %v, want nil on 404", err)
 
 
 
 
 
 
 
112 }
113 }
114
115 func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) {
116 t.Helper()
117
118 var deletedPath string
 
119 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120 switch {
121 case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register":
 
 
 
 
 
 
 
122 w.WriteHeader(http.StatusCreated)
123 _ = json.NewEncoder(w).Encode(map[string]any{
124 "credentials": map[string]string{"passphrase": "created-pass"},
125 })
126 case r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/codex-1234":
@@ -135,11 +177,12 @@
135 conn := &ircConnector{
136 http: srv.Client(),
137 apiURL: srv.URL,
138 token: "test-token",
139 nick: "codex-1234",
140 channel: "#general",
 
141 agentType: "worker",
142 deleteOnClose: true,
143 }
144
145 created, pass, err := conn.registerOrRotate(context.Background())
@@ -146,10 +189,13 @@
146 if err != nil {
147 t.Fatal(err)
148 }
149 if !created || pass != "created-pass" {
150 t.Fatalf("registerOrRotate = (%v, %q), want (true, created-pass)", created, pass)
 
 
 
151 }
152 conn.registeredByRelay = created
153 if err := conn.cleanupRegistration(context.Background()); err != nil {
154 t.Fatal(err)
155 }
@@ -178,11 +224,12 @@
178 conn := &ircConnector{
179 http: srv.Client(),
180 apiURL: srv.URL,
181 token: "test-token",
182 nick: "codex-1234",
183 channel: "#general",
 
184 agentType: "worker",
185 }
186
187 created, pass, err := conn.registerOrRotate(context.Background())
188 if err != nil {
@@ -193,5 +240,45 @@
193 }
194 if !rotateCalled || pass != "rotated-pass" {
195 t.Fatalf("rotate fallback = (called=%v, pass=%q)", rotateCalled, pass)
196 }
197 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
--- pkg/sessionrelay/sessionrelay_test.go
+++ pkg/sessionrelay/sessionrelay_test.go
@@ -3,40 +3,64 @@
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "slices"
10 "testing"
11 "time"
12 )
13
14 func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) {
15 t.Helper()
16
17 base := time.Date(2026, 3, 31, 22, 0, 0, 0, time.UTC)
18 var gotAuth []string
19 var posted []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["text"])
31 w.WriteHeader(http.StatusNoContent)
32 case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/messages":
33 var body map[string]string
34 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
35 t.Fatalf("decode release post body: %v", err)
36 }
37 posted = append(posted, "release:"+body["nick"]+":"+body["text"])
38 w.WriteHeader(http.StatusNoContent)
39 case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/presence":
40 var body map[string]string
41 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
42 t.Fatalf("decode general touch body: %v", err)
43 }
44 touched = append(touched, "general:"+body["nick"])
45 w.WriteHeader(http.StatusNoContent)
46 case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/presence":
47 var body map[string]string
48 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
49 t.Fatalf("decode release touch body: %v", err)
50 }
51 touched = append(touched, "release:"+body["nick"])
52 w.WriteHeader(http.StatusNoContent)
53 case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages":
54 _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
55 {"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"},
56 {"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"},
57 }})
58 case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/release/messages":
59 _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
60 {"at": base.Add(2 * time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: /join #task-42"},
61 }})
62 default:
63 http.NotFound(w, r)
64 }
65 }))
66 defer srv.Close()
@@ -44,10 +68,11 @@
68 conn, err := New(Config{
69 Transport: TransportHTTP,
70 URL: srv.URL,
71 Token: "test-token",
72 Channel: "general",
73 Channels: []string{"general", "release"},
74 Nick: "codex-test",
75 HTTPClient: srv.Client(),
76 })
77 if err != nil {
78 t.Fatal(err)
@@ -56,71 +81,88 @@
81 t.Fatal(err)
82 }
83 if err := conn.Post(context.Background(), "online"); err != nil {
84 t.Fatal(err)
85 }
86 if want := []string{"general:codex-test:online", "release:codex-test:online"}; !slices.Equal(posted, want) {
87 t.Fatalf("posted = %#v, want %#v", posted, want)
88 }
89 for _, auth := range gotAuth {
90 if auth != "Bearer test-token" {
91 t.Fatalf("authorization = %q", auth)
92 }
93 }
94
95 msgs, err := conn.MessagesSince(context.Background(), base)
96 if err != nil {
97 t.Fatal(err)
98 }
99 if len(msgs) != 2 {
100 t.Fatalf("MessagesSince len = %d, want 2", len(msgs))
101 }
102 if msgs[0].Channel != "#general" || msgs[1].Channel != "#release" {
103 t.Fatalf("MessagesSince channels = %#v", msgs)
104 }
105
106 if err := conn.Touch(context.Background()); err != nil {
107 t.Fatal(err)
108 }
109 if want := []string{"general:codex-test", "release:codex-test"}; !slices.Equal(touched, want) {
110 t.Fatalf("touches = %#v, want %#v", touched, want)
111 }
112 }
113
114 func TestHTTPConnectorJoinPartAndControlChannel(t *testing.T) {
115 t.Helper()
116
117 conn, err := New(Config{
118 Transport: TransportHTTP,
119 URL: "http://example.com",
120 Token: "test-token",
121 Channel: "general",
122 Channels: []string{"general", "release"},
123 Nick: "codex-test",
124 })
125 if err != nil {
126 t.Fatal(err)
127 }
128
129 if got := conn.ControlChannel(); got != "#general" {
130 t.Fatalf("ControlChannel = %q, want #general", got)
131 }
132 if err := conn.JoinChannel(context.Background(), "#task-42"); err != nil {
133 t.Fatal(err)
134 }
135 if want := []string{"#general", "#release", "#task-42"}; !slices.Equal(conn.Channels(), want) {
136 t.Fatalf("Channels after join = %#v, want %#v", conn.Channels(), want)
137 }
138 if err := conn.PartChannel(context.Background(), "#general"); err == nil {
139 t.Fatal("PartChannel(control) = nil, want error")
140 }
141 if err := conn.PartChannel(context.Background(), "#release"); err != nil {
142 t.Fatal(err)
143 }
144 if want := []string{"#general", "#task-42"}; !slices.Equal(conn.Channels(), want) {
145 t.Fatalf("Channels after part = %#v, want %#v", conn.Channels(), want)
146 }
147 }
148
149 func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) {
150 t.Helper()
151
152 var deletedPath string
153 var registerChannels []string
154 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 switch {
156 case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register":
157 var body struct {
158 Channels []string `json:"channels"`
159 }
160 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
161 t.Fatalf("decode register body: %v", err)
162 }
163 registerChannels = body.Channels
164 w.WriteHeader(http.StatusCreated)
165 _ = json.NewEncoder(w).Encode(map[string]any{
166 "credentials": map[string]string{"passphrase": "created-pass"},
167 })
168 case r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/codex-1234":
@@ -135,11 +177,12 @@
177 conn := &ircConnector{
178 http: srv.Client(),
179 apiURL: srv.URL,
180 token: "test-token",
181 nick: "codex-1234",
182 primary: "#general",
183 channels: []string{"#general", "#release"},
184 agentType: "worker",
185 deleteOnClose: true,
186 }
187
188 created, pass, err := conn.registerOrRotate(context.Background())
@@ -146,10 +189,13 @@
189 if err != nil {
190 t.Fatal(err)
191 }
192 if !created || pass != "created-pass" {
193 t.Fatalf("registerOrRotate = (%v, %q), want (true, created-pass)", created, pass)
194 }
195 if want := []string{"#general", "#release"}; !slices.Equal(registerChannels, want) {
196 t.Fatalf("register channels = %#v, want %#v", registerChannels, want)
197 }
198 conn.registeredByRelay = created
199 if err := conn.cleanupRegistration(context.Background()); err != nil {
200 t.Fatal(err)
201 }
@@ -178,11 +224,12 @@
224 conn := &ircConnector{
225 http: srv.Client(),
226 apiURL: srv.URL,
227 token: "test-token",
228 nick: "codex-1234",
229 primary: "#general",
230 channels: []string{"#general"},
231 agentType: "worker",
232 }
233
234 created, pass, err := conn.registerOrRotate(context.Background())
235 if err != nil {
@@ -193,5 +240,45 @@
240 }
241 if !rotateCalled || pass != "rotated-pass" {
242 t.Fatalf("rotate fallback = (called=%v, pass=%q)", rotateCalled, pass)
243 }
244 }
245
246 func TestWriteChannelStateFile(t *testing.T) {
247 t.Helper()
248
249 dir := t.TempDir()
250 path := dir + "/channels.env"
251 if err := WriteChannelStateFile(path, "general", []string{"#general", "#release"}); err != nil {
252 t.Fatal(err)
253 }
254 data, err := os.ReadFile(path)
255 if err != nil {
256 t.Fatal(err)
257 }
258 want := "SCUTTLEBOT_CHANNEL=general\nSCUTTLEBOT_CHANNELS=general,release\n"
259 if string(data) != want {
260 t.Fatalf("state file = %q, want %q", string(data), want)
261 }
262 }
263
264 func TestParseBrokerCommand(t *testing.T) {
265 t.Helper()
266
267 tests := []struct {
268 input string
269 want BrokerCommand
270 ok bool
271 }{
272 {input: "/channels", want: BrokerCommand{Name: "channels"}, ok: true},
273 {input: "/join task-42", want: BrokerCommand{Name: "join", Channel: "#task-42"}, ok: true},
274 {input: "/part #release", want: BrokerCommand{Name: "part", Channel: "#release"}, ok: true},
275 {input: "please read README", ok: false},
276 }
277
278 for _, tt := range tests {
279 got, ok := ParseBrokerCommand(tt.input)
280 if ok != tt.ok || got != tt.want {
281 t.Fatalf("ParseBrokerCommand(%q) = (%#v, %v), want (%#v, %v)", tt.input, got, ok, tt.want, tt.ok)
282 }
283 }
284 }
285
--- skills/gemini-relay/FLEET.md
+++ skills/gemini-relay/FLEET.md
@@ -123,23 +123,29 @@
123123
- force a fixed nick across sessions
124124
- require IRC to be up at install time
125125
126126
Useful shared env knobs:
127127
- `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
128
+- `SCUTTLEBOT_CHANNEL` is the primary control channel
129
+- `SCUTTLEBOT_CHANNELS=general,task-42` seeds extra startup work channels
128130
- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
129131
- `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
130132
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
131133
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Gemini session when it appears busy
132134
- `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
133135
- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` controls HTTP presence touches; set `0` to disable
134136
- `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS=6` caps how many IRC messages one final Gemini reply may emit
135137
- `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH=360` controls the maximum width of each mirrored reply chunk
136
-- `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
137138
138139
Installer auth knobs:
139140
- default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
140141
- `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
142
+
143
+Live channel commands:
144
+- `/channels`
145
+- `/join #task-42`
146
+- `/part #task-42`
141147
142148
## Operator workflow
143149
144150
1. Watch the configured channel in scuttlebot.
145151
2. Wait for a new `gemini-{repo}-{session}` online post.
146152
--- skills/gemini-relay/FLEET.md
+++ skills/gemini-relay/FLEET.md
@@ -123,23 +123,29 @@
123 - force a fixed nick across sessions
124 - require IRC to be up at install time
125
126 Useful shared env knobs:
127 - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
 
 
128 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
129 - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
130 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
131 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Gemini session when it appears busy
132 - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
133 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` controls HTTP presence touches; set `0` to disable
134 - `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS=6` caps how many IRC messages one final Gemini reply may emit
135 - `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH=360` controls the maximum width of each mirrored reply chunk
136 - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
137
138 Installer auth knobs:
139 - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
140 - `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
 
 
 
 
 
141
142 ## Operator workflow
143
144 1. Watch the configured channel in scuttlebot.
145 2. Wait for a new `gemini-{repo}-{session}` online post.
146
--- skills/gemini-relay/FLEET.md
+++ skills/gemini-relay/FLEET.md
@@ -123,23 +123,29 @@
123 - force a fixed nick across sessions
124 - require IRC to be up at install time
125
126 Useful shared env knobs:
127 - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
128 - `SCUTTLEBOT_CHANNEL` is the primary control channel
129 - `SCUTTLEBOT_CHANNELS=general,task-42` seeds extra startup work channels
130 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
131 - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
132 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
133 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Gemini session when it appears busy
134 - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
135 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` controls HTTP presence touches; set `0` to disable
136 - `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS=6` caps how many IRC messages one final Gemini reply may emit
137 - `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH=360` controls the maximum width of each mirrored reply chunk
 
138
139 Installer auth knobs:
140 - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
141 - `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
142
143 Live channel commands:
144 - `/channels`
145 - `/join #task-42`
146 - `/part #task-42`
147
148 ## Operator workflow
149
150 1. Watch the configured channel in scuttlebot.
151 2. Wait for a new `gemini-{repo}-{session}` online post.
152
--- skills/gemini-relay/SKILL.md
+++ skills/gemini-relay/SKILL.md
@@ -15,10 +15,11 @@
1515
connector with `http` and `irc` transports.
1616
1717
Gemini and Codex are the canonical terminal-broker reference implementations in
1818
this repo. The shared path and convention contract lives in
1919
`skills/scuttlebot-relay/ADDING_AGENTS.md`.
20
+For generic install/config work across runtimes, use `skills/scuttlebot-relay/SKILL.md`.
2021
2122
Gemini CLI itself supports a broad native hook surface, including
2223
`SessionStart`, `SessionEnd`, `BeforeAgent`, `AfterAgent`, `BeforeToolSelection`,
2324
`BeforeTool`, `AfterTool`, `BeforeModel`, `AfterModel`, `Notification`, and
2425
`PreCompress`. In this repo, the relay integration intentionally uses the broker
2526
--- skills/gemini-relay/SKILL.md
+++ skills/gemini-relay/SKILL.md
@@ -15,10 +15,11 @@
15 connector with `http` and `irc` transports.
16
17 Gemini and Codex are the canonical terminal-broker reference implementations in
18 this repo. The shared path and convention contract lives in
19 `skills/scuttlebot-relay/ADDING_AGENTS.md`.
 
20
21 Gemini CLI itself supports a broad native hook surface, including
22 `SessionStart`, `SessionEnd`, `BeforeAgent`, `AfterAgent`, `BeforeToolSelection`,
23 `BeforeTool`, `AfterTool`, `BeforeModel`, `AfterModel`, `Notification`, and
24 `PreCompress`. In this repo, the relay integration intentionally uses the broker
25
--- skills/gemini-relay/SKILL.md
+++ skills/gemini-relay/SKILL.md
@@ -15,10 +15,11 @@
15 connector with `http` and `irc` transports.
16
17 Gemini and Codex are the canonical terminal-broker reference implementations in
18 this repo. The shared path and convention contract lives in
19 `skills/scuttlebot-relay/ADDING_AGENTS.md`.
20 For generic install/config work across runtimes, use `skills/scuttlebot-relay/SKILL.md`.
21
22 Gemini CLI itself supports a broad native hook surface, including
23 `SessionStart`, `SessionEnd`, `BeforeAgent`, `AfterAgent`, `BeforeToolSelection`,
24 `BeforeTool`, `AfterTool`, `BeforeModel`, `AfterModel`, `Notification`, and
25 `PreCompress`. In this repo, the relay integration intentionally uses the broker
26
--- skills/gemini-relay/hooks/README.md
+++ skills/gemini-relay/hooks/README.md
@@ -89,10 +89,12 @@
8989
- `curl` and `jq` available on `PATH`
9090
9191
Optional:
9292
- `SCUTTLEBOT_NICK`
9393
- `SCUTTLEBOT_SESSION_ID`
94
+- `SCUTTLEBOT_CHANNELS`
95
+- `SCUTTLEBOT_CHANNEL_STATE_FILE`
9496
- `SCUTTLEBOT_TRANSPORT`
9597
- `SCUTTLEBOT_IRC_ADDR`
9698
- `SCUTTLEBOT_IRC_PASS`
9799
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
98100
- `SCUTTLEBOT_HOOKS_ENABLED`
@@ -107,19 +109,21 @@
107109
108110
```bash
109111
export SCUTTLEBOT_URL=http://localhost:8080
110112
export SCUTTLEBOT_TOKEN=$(./run.sh token)
111113
export SCUTTLEBOT_CHANNEL=general
114
+export SCUTTLEBOT_CHANNELS=general,task-42
112115
```
113116
114117
The hooks also auto-load a shared relay env file if it exists:
115118
116119
```bash
117120
cat > ~/.config/scuttlebot-relay.env <<'EOF2'
118121
SCUTTLEBOT_URL=http://localhost:8080
119122
SCUTTLEBOT_TOKEN=...
120123
SCUTTLEBOT_CHANNEL=general
124
+SCUTTLEBOT_CHANNELS=general
121125
SCUTTLEBOT_TRANSPORT=http
122126
SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
123127
SCUTTLEBOT_HOOKS_ENABLED=1
124128
SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
125129
SCUTTLEBOT_POLL_INTERVAL=2s
@@ -143,11 +147,12 @@
143147
144148
```bash
145149
bash skills/gemini-relay/scripts/install-gemini-relay.sh \
146150
--url http://localhost:8080 \
147151
--token "$(./run.sh token)" \
148
- --channel general
152
+ --channel general \
153
+ --channels general,task-42
149154
```
150155
151156
Manual path:
152157
153158
Install the scripts:
@@ -220,16 +225,19 @@
220225
Ambient channel chat must not halt a live tool loop.
221226
222227
## Operational notes
223228
224229
- `cmd/gemini-relay` can use either the HTTP bridge API or a real IRC socket.
230
+- `SCUTTLEBOT_CHANNEL` is the primary control channel; `SCUTTLEBOT_CHANNELS` is the startup channel set.
231
+- `/channels`, `/join #channel`, and `/part #channel` change the live session channel set without rewriting the shared env file.
225232
- `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.
226233
- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` keeps quiet HTTP-mode sessions in the active user list without visible chatter.
234
+- `SCUTTLEBOT_CHANNEL_STATE_FILE` is the broker-written override file that keeps hooks aligned with live channel joins and parts.
227235
- Gemini CLI expects hook success responses on `stdout` to be valid JSON; these relay hooks emit `{}` on success and structured deny JSON on blocks.
228236
- Gemini CLI built-in tool names are things like `run_shell_command`, `read_file`, and `write_file`; the activity hook summarizes those native names.
229237
- Gemini outbound mirroring is still hook-owned today: `AfterTool` covers tool activity and `AfterAgent` covers final assistant replies. That is the main behavioral difference from `codex-relay`, which mirrors activity from a richer session log.
230238
- `scuttlebot-after-agent.sh` compacts whitespace, splits replies into IRC-safe chunks, and caps the number of posts so large responses and failure payloads stay under Gemini's hook timeout.
231239
- `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS` defaults to `6`; `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH` defaults to `360`.
232240
- If scuttlebot is down or unreachable, the hooks soft-fail and return quickly.
233241
- `SCUTTLEBOT_HOOKS_ENABLED=0` disables all Gemini relay hooks explicitly.
234242
- They should remain in the repo as installable reference files.
235243
- Do not bake tokens into the scripts. Use environment variables.
236244
--- skills/gemini-relay/hooks/README.md
+++ skills/gemini-relay/hooks/README.md
@@ -89,10 +89,12 @@
89 - `curl` and `jq` available on `PATH`
90
91 Optional:
92 - `SCUTTLEBOT_NICK`
93 - `SCUTTLEBOT_SESSION_ID`
 
 
94 - `SCUTTLEBOT_TRANSPORT`
95 - `SCUTTLEBOT_IRC_ADDR`
96 - `SCUTTLEBOT_IRC_PASS`
97 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
98 - `SCUTTLEBOT_HOOKS_ENABLED`
@@ -107,19 +109,21 @@
107
108 ```bash
109 export SCUTTLEBOT_URL=http://localhost:8080
110 export SCUTTLEBOT_TOKEN=$(./run.sh token)
111 export SCUTTLEBOT_CHANNEL=general
 
112 ```
113
114 The hooks also auto-load a shared relay env file if it exists:
115
116 ```bash
117 cat > ~/.config/scuttlebot-relay.env <<'EOF2'
118 SCUTTLEBOT_URL=http://localhost:8080
119 SCUTTLEBOT_TOKEN=...
120 SCUTTLEBOT_CHANNEL=general
 
121 SCUTTLEBOT_TRANSPORT=http
122 SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
123 SCUTTLEBOT_HOOKS_ENABLED=1
124 SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
125 SCUTTLEBOT_POLL_INTERVAL=2s
@@ -143,11 +147,12 @@
143
144 ```bash
145 bash skills/gemini-relay/scripts/install-gemini-relay.sh \
146 --url http://localhost:8080 \
147 --token "$(./run.sh token)" \
148 --channel general
 
149 ```
150
151 Manual path:
152
153 Install the scripts:
@@ -220,16 +225,19 @@
220 Ambient channel chat must not halt a live tool loop.
221
222 ## Operational notes
223
224 - `cmd/gemini-relay` can use either the HTTP bridge API or a real IRC socket.
 
 
225 - `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.
226 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` keeps quiet HTTP-mode sessions in the active user list without visible chatter.
 
227 - Gemini CLI expects hook success responses on `stdout` to be valid JSON; these relay hooks emit `{}` on success and structured deny JSON on blocks.
228 - Gemini CLI built-in tool names are things like `run_shell_command`, `read_file`, and `write_file`; the activity hook summarizes those native names.
229 - Gemini outbound mirroring is still hook-owned today: `AfterTool` covers tool activity and `AfterAgent` covers final assistant replies. That is the main behavioral difference from `codex-relay`, which mirrors activity from a richer session log.
230 - `scuttlebot-after-agent.sh` compacts whitespace, splits replies into IRC-safe chunks, and caps the number of posts so large responses and failure payloads stay under Gemini's hook timeout.
231 - `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS` defaults to `6`; `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH` defaults to `360`.
232 - If scuttlebot is down or unreachable, the hooks soft-fail and return quickly.
233 - `SCUTTLEBOT_HOOKS_ENABLED=0` disables all Gemini relay hooks explicitly.
234 - They should remain in the repo as installable reference files.
235 - Do not bake tokens into the scripts. Use environment variables.
236
--- skills/gemini-relay/hooks/README.md
+++ skills/gemini-relay/hooks/README.md
@@ -89,10 +89,12 @@
89 - `curl` and `jq` available on `PATH`
90
91 Optional:
92 - `SCUTTLEBOT_NICK`
93 - `SCUTTLEBOT_SESSION_ID`
94 - `SCUTTLEBOT_CHANNELS`
95 - `SCUTTLEBOT_CHANNEL_STATE_FILE`
96 - `SCUTTLEBOT_TRANSPORT`
97 - `SCUTTLEBOT_IRC_ADDR`
98 - `SCUTTLEBOT_IRC_PASS`
99 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
100 - `SCUTTLEBOT_HOOKS_ENABLED`
@@ -107,19 +109,21 @@
109
110 ```bash
111 export SCUTTLEBOT_URL=http://localhost:8080
112 export SCUTTLEBOT_TOKEN=$(./run.sh token)
113 export SCUTTLEBOT_CHANNEL=general
114 export SCUTTLEBOT_CHANNELS=general,task-42
115 ```
116
117 The hooks also auto-load a shared relay env file if it exists:
118
119 ```bash
120 cat > ~/.config/scuttlebot-relay.env <<'EOF2'
121 SCUTTLEBOT_URL=http://localhost:8080
122 SCUTTLEBOT_TOKEN=...
123 SCUTTLEBOT_CHANNEL=general
124 SCUTTLEBOT_CHANNELS=general
125 SCUTTLEBOT_TRANSPORT=http
126 SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
127 SCUTTLEBOT_HOOKS_ENABLED=1
128 SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
129 SCUTTLEBOT_POLL_INTERVAL=2s
@@ -143,11 +147,12 @@
147
148 ```bash
149 bash skills/gemini-relay/scripts/install-gemini-relay.sh \
150 --url http://localhost:8080 \
151 --token "$(./run.sh token)" \
152 --channel general \
153 --channels general,task-42
154 ```
155
156 Manual path:
157
158 Install the scripts:
@@ -220,16 +225,19 @@
225 Ambient channel chat must not halt a live tool loop.
226
227 ## Operational notes
228
229 - `cmd/gemini-relay` can use either the HTTP bridge API or a real IRC socket.
230 - `SCUTTLEBOT_CHANNEL` is the primary control channel; `SCUTTLEBOT_CHANNELS` is the startup channel set.
231 - `/channels`, `/join #channel`, and `/part #channel` change the live session channel set without rewriting the shared env file.
232 - `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.
233 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` keeps quiet HTTP-mode sessions in the active user list without visible chatter.
234 - `SCUTTLEBOT_CHANNEL_STATE_FILE` is the broker-written override file that keeps hooks aligned with live channel joins and parts.
235 - Gemini CLI expects hook success responses on `stdout` to be valid JSON; these relay hooks emit `{}` on success and structured deny JSON on blocks.
236 - Gemini CLI built-in tool names are things like `run_shell_command`, `read_file`, and `write_file`; the activity hook summarizes those native names.
237 - Gemini outbound mirroring is still hook-owned today: `AfterTool` covers tool activity and `AfterAgent` covers final assistant replies. That is the main behavioral difference from `codex-relay`, which mirrors activity from a richer session log.
238 - `scuttlebot-after-agent.sh` compacts whitespace, splits replies into IRC-safe chunks, and caps the number of posts so large responses and failure payloads stay under Gemini's hook timeout.
239 - `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS` defaults to `6`; `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH` defaults to `360`.
240 - If scuttlebot is down or unreachable, the hooks soft-fail and return quickly.
241 - `SCUTTLEBOT_HOOKS_ENABLED=0` disables all Gemini relay hooks explicitly.
242 - They should remain in the repo as installable reference files.
243 - Do not bake tokens into the scripts. Use environment variables.
244
--- skills/gemini-relay/hooks/scuttlebot-after-agent.sh
+++ skills/gemini-relay/hooks/scuttlebot-after-agent.sh
@@ -5,17 +5,47 @@
55
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
66
set -a
77
. "$SCUTTLEBOT_CONFIG_FILE"
88
set +a
99
fi
10
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11
+ set -a
12
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13
+ set +a
14
+fi
1015
1116
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1217
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1318
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1419
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
1520
SCUTTLEBOT_AFTER_AGENT_MAX_POSTS="${SCUTTLEBOT_AFTER_AGENT_MAX_POSTS:-6}"
1621
SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH="${SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH:-360}"
22
+
23
+normalize_channel() {
24
+ local channel="$1"
25
+ channel="${channel//[$' \t\r\n']/}"
26
+ channel="${channel#\#}"
27
+ printf '%s' "$channel"
28
+}
29
+
30
+relay_channels() {
31
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
32
+ local IFS=','
33
+ local item channel seen=""
34
+ read -r -a items <<< "$raw"
35
+ for item in "${items[@]}"; do
36
+ channel=$(normalize_channel "$item")
37
+ [ -n "$channel" ] || continue
38
+ case ",$seen," in
39
+ *,"$channel",*) ;;
40
+ *)
41
+ seen="${seen:+$seen,}$channel"
42
+ printf '%s\n' "$channel"
43
+ ;;
44
+ esac
45
+ done
46
+}
1747
1848
sanitize() {
1949
local input="$1"
2050
if [ -z "$input" ]; then
2151
input=$(cat)
@@ -23,18 +53,22 @@
2353
printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
2454
}
2555
2656
post_line() {
2757
local text="$1"
58
+ local payload
2859
[ -z "$text" ] && return 0
29
- curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
30
- --connect-timeout 1 \
31
- --max-time 2 \
32
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
33
- -H "Content-Type: application/json" \
34
- -d "{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
35
- > /dev/null || true
60
+ payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
61
+ for channel in $(relay_channels); do
62
+ curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
63
+ --connect-timeout 1 \
64
+ --max-time 2 \
65
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
66
+ -H "Content-Type: application/json" \
67
+ -d "$payload" \
68
+ > /dev/null || true
69
+ done
3670
}
3771
3872
normalize_response() {
3973
printf '%s' "$1" \
4074
| tr '\r\n\t' ' ' \
4175
--- skills/gemini-relay/hooks/scuttlebot-after-agent.sh
+++ skills/gemini-relay/hooks/scuttlebot-after-agent.sh
@@ -5,17 +5,47 @@
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
 
 
 
 
 
10
11 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
12 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
13 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
14 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
15 SCUTTLEBOT_AFTER_AGENT_MAX_POSTS="${SCUTTLEBOT_AFTER_AGENT_MAX_POSTS:-6}"
16 SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH="${SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH:-360}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
18 sanitize() {
19 local input="$1"
20 if [ -z "$input" ]; then
21 input=$(cat)
@@ -23,18 +53,22 @@
23 printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
24 }
25
26 post_line() {
27 local text="$1"
 
28 [ -z "$text" ] && return 0
29 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
30 --connect-timeout 1 \
31 --max-time 2 \
32 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
33 -H "Content-Type: application/json" \
34 -d "{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
35 > /dev/null || true
 
 
 
36 }
37
38 normalize_response() {
39 printf '%s' "$1" \
40 | tr '\r\n\t' ' ' \
41
--- skills/gemini-relay/hooks/scuttlebot-after-agent.sh
+++ skills/gemini-relay/hooks/scuttlebot-after-agent.sh
@@ -5,17 +5,47 @@
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
10 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11 set -a
12 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13 set +a
14 fi
15
16 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
17 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
18 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
19 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
20 SCUTTLEBOT_AFTER_AGENT_MAX_POSTS="${SCUTTLEBOT_AFTER_AGENT_MAX_POSTS:-6}"
21 SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH="${SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH:-360}"
22
23 normalize_channel() {
24 local channel="$1"
25 channel="${channel//[$' \t\r\n']/}"
26 channel="${channel#\#}"
27 printf '%s' "$channel"
28 }
29
30 relay_channels() {
31 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
32 local IFS=','
33 local item channel seen=""
34 read -r -a items <<< "$raw"
35 for item in "${items[@]}"; do
36 channel=$(normalize_channel "$item")
37 [ -n "$channel" ] || continue
38 case ",$seen," in
39 *,"$channel",*) ;;
40 *)
41 seen="${seen:+$seen,}$channel"
42 printf '%s\n' "$channel"
43 ;;
44 esac
45 done
46 }
47
48 sanitize() {
49 local input="$1"
50 if [ -z "$input" ]; then
51 input=$(cat)
@@ -23,18 +53,22 @@
53 printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
54 }
55
56 post_line() {
57 local text="$1"
58 local payload
59 [ -z "$text" ] && return 0
60 payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
61 for channel in $(relay_channels); do
62 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
63 --connect-timeout 1 \
64 --max-time 2 \
65 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
66 -H "Content-Type: application/json" \
67 -d "$payload" \
68 > /dev/null || true
69 done
70 }
71
72 normalize_response() {
73 printf '%s' "$1" \
74 | tr '\r\n\t' ' ' \
75
--- skills/gemini-relay/hooks/scuttlebot-check.sh
+++ skills/gemini-relay/hooks/scuttlebot-check.sh
@@ -7,10 +7,15 @@
77
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
88
set -a
99
. "$SCUTTLEBOT_CONFIG_FILE"
1010
set +a
1111
fi
12
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
13
+ set -a
14
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
15
+ set +a
16
+fi
1217
1318
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1419
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1520
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1621
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
@@ -20,10 +25,49 @@
2025
if [ -z "$input" ]; then
2126
input=$(cat)
2227
fi
2328
printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
2429
}
30
+
31
+normalize_channel() {
32
+ local channel="$1"
33
+ channel="${channel//[$' \t\r\n']/}"
34
+ channel="${channel#\#}"
35
+ printf '%s' "$channel"
36
+}
37
+
38
+relay_channels() {
39
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
40
+ local IFS=','
41
+ local item channel seen=""
42
+ read -r -a items <<< "$raw"
43
+ for item in "${items[@]}"; do
44
+ channel=$(normalize_channel "$item")
45
+ [ -n "$channel" ] || continue
46
+ case ",$seen," in
47
+ *,"$channel",*) ;;
48
+ *)
49
+ seen="${seen:+$seen,}$channel"
50
+ printf '%s\n' "$channel"
51
+ ;;
52
+ esac
53
+ done
54
+}
55
+
56
+contains_mention() {
57
+ local text="$1"
58
+ [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
59
+}
60
+
61
+epoch_seconds() {
62
+ local at="$1"
63
+ local ts_clean ts
64
+ ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
65
+ ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
66
+ date -d "$ts_clean" "+%s" 2>/dev/null)
67
+ printf '%s' "$ts"
68
+}
2569
2670
base_name=$(basename "$(pwd)")
2771
base_name=$(sanitize "$base_name")
2872
session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}"
2973
if [ -z "$session_raw" ] || [ "$session_raw" = "0" ]; then
@@ -35,56 +79,49 @@
3579
3680
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; }
3781
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; }
3882
[ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; }
3983
40
-state_key=$(printf '%s' "$SCUTTLEBOT_CHANNEL|$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
84
+state_key=$(printf '%s' "$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
4185
LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
4286
43
-contains_mention() {
44
- local text="$1"
45
- [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
46
-}
47
-
4887
last_check=0
4988
if [ -f "$LAST_CHECK_FILE" ]; then
5089
last_check=$(cat "$LAST_CHECK_FILE")
5190
fi
5291
now=$(date +%s)
5392
echo "$now" > "$LAST_CHECK_FILE"
5493
55
-messages=$(curl -sf \
56
- --connect-timeout 1 \
57
- --max-time 2 \
58
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
59
- "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
60
-
61
-[ -z "$messages" ] && { echo '{}'; exit 0; }
62
-
6394
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
6495
6596
instruction=$(
66
- echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
67
- .messages[]
68
- | select(.nick as $n |
69
- ($bots | index($n) | not) and
70
- ($n | startswith("claude-") | not) and
71
- ($n | startswith("codex-") | not) and
72
- ($n | startswith("gemini-") | not) and
73
- $n != $self
74
- )
75
- | "\(.at)\t\(.nick)\t\(.text)"
76
- ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
77
- ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
78
- ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
79
- date -d "$ts_clean" "+%s" 2>/dev/null)
97
+ for channel in $(relay_channels); do
98
+ messages=$(curl -sf \
99
+ --connect-timeout 1 \
100
+ --max-time 2 \
101
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
102
+ "$SCUTTLEBOT_URL/v1/channels/$channel/messages" 2>/dev/null) || continue
103
+ [ -n "$messages" ] || continue
104
+ echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" --arg channel "$channel" '
105
+ .messages[]
106
+ | select(.nick as $n |
107
+ ($bots | index($n) | not) and
108
+ ($n | startswith("claude-") | not) and
109
+ ($n | startswith("codex-") | not) and
110
+ ($n | startswith("gemini-") | not) and
111
+ $n != $self
112
+ )
113
+ | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
114
+ ' 2>/dev/null
115
+ done | while IFS=$'\t' read -r at channel nick text; do
116
+ ts=$(epoch_seconds "$at")
80117
[ -n "$ts" ] || continue
81118
[ "$ts" -gt "$last_check" ] || continue
82119
contains_mention "$text" || continue
83
- echo "$nick: $text"
84
- done | tail -1
120
+ printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
121
+ done | sort -n | tail -1 | cut -f2-
85122
)
86123
87124
[ -z "$instruction" ] && { echo '{}'; exit 0; }
88125
89126
echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
90127
exit 0
91128
--- skills/gemini-relay/hooks/scuttlebot-check.sh
+++ skills/gemini-relay/hooks/scuttlebot-check.sh
@@ -7,10 +7,15 @@
7 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
8 set -a
9 . "$SCUTTLEBOT_CONFIG_FILE"
10 set +a
11 fi
 
 
 
 
 
12
13 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
14 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
15 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
16 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
@@ -20,10 +25,49 @@
20 if [ -z "$input" ]; then
21 input=$(cat)
22 fi
23 printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
24 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
26 base_name=$(basename "$(pwd)")
27 base_name=$(sanitize "$base_name")
28 session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}"
29 if [ -z "$session_raw" ] || [ "$session_raw" = "0" ]; then
@@ -35,56 +79,49 @@
35
36 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; }
37 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; }
38 [ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; }
39
40 state_key=$(printf '%s' "$SCUTTLEBOT_CHANNEL|$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
41 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
42
43 contains_mention() {
44 local text="$1"
45 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
46 }
47
48 last_check=0
49 if [ -f "$LAST_CHECK_FILE" ]; then
50 last_check=$(cat "$LAST_CHECK_FILE")
51 fi
52 now=$(date +%s)
53 echo "$now" > "$LAST_CHECK_FILE"
54
55 messages=$(curl -sf \
56 --connect-timeout 1 \
57 --max-time 2 \
58 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
59 "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
60
61 [ -z "$messages" ] && { echo '{}'; exit 0; }
62
63 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
64
65 instruction=$(
66 echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
67 .messages[]
68 | select(.nick as $n |
69 ($bots | index($n) | not) and
70 ($n | startswith("claude-") | not) and
71 ($n | startswith("codex-") | not) and
72 ($n | startswith("gemini-") | not) and
73 $n != $self
74 )
75 | "\(.at)\t\(.nick)\t\(.text)"
76 ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
77 ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
78 ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
79 date -d "$ts_clean" "+%s" 2>/dev/null)
 
 
 
 
 
 
80 [ -n "$ts" ] || continue
81 [ "$ts" -gt "$last_check" ] || continue
82 contains_mention "$text" || continue
83 echo "$nick: $text"
84 done | tail -1
85 )
86
87 [ -z "$instruction" ] && { echo '{}'; exit 0; }
88
89 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
90 exit 0
91
--- skills/gemini-relay/hooks/scuttlebot-check.sh
+++ skills/gemini-relay/hooks/scuttlebot-check.sh
@@ -7,10 +7,15 @@
7 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
8 set -a
9 . "$SCUTTLEBOT_CONFIG_FILE"
10 set +a
11 fi
12 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
13 set -a
14 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
15 set +a
16 fi
17
18 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
19 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
20 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
21 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
@@ -20,10 +25,49 @@
25 if [ -z "$input" ]; then
26 input=$(cat)
27 fi
28 printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
29 }
30
31 normalize_channel() {
32 local channel="$1"
33 channel="${channel//[$' \t\r\n']/}"
34 channel="${channel#\#}"
35 printf '%s' "$channel"
36 }
37
38 relay_channels() {
39 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
40 local IFS=','
41 local item channel seen=""
42 read -r -a items <<< "$raw"
43 for item in "${items[@]}"; do
44 channel=$(normalize_channel "$item")
45 [ -n "$channel" ] || continue
46 case ",$seen," in
47 *,"$channel",*) ;;
48 *)
49 seen="${seen:+$seen,}$channel"
50 printf '%s\n' "$channel"
51 ;;
52 esac
53 done
54 }
55
56 contains_mention() {
57 local text="$1"
58 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
59 }
60
61 epoch_seconds() {
62 local at="$1"
63 local ts_clean ts
64 ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
65 ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
66 date -d "$ts_clean" "+%s" 2>/dev/null)
67 printf '%s' "$ts"
68 }
69
70 base_name=$(basename "$(pwd)")
71 base_name=$(sanitize "$base_name")
72 session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}"
73 if [ -z "$session_raw" ] || [ "$session_raw" = "0" ]; then
@@ -35,56 +79,49 @@
79
80 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; }
81 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; }
82 [ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; }
83
84 state_key=$(printf '%s' "$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
85 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
86
 
 
 
 
 
87 last_check=0
88 if [ -f "$LAST_CHECK_FILE" ]; then
89 last_check=$(cat "$LAST_CHECK_FILE")
90 fi
91 now=$(date +%s)
92 echo "$now" > "$LAST_CHECK_FILE"
93
 
 
 
 
 
 
 
 
94 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
95
96 instruction=$(
97 for channel in $(relay_channels); do
98 messages=$(curl -sf \
99 --connect-timeout 1 \
100 --max-time 2 \
101 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
102 "$SCUTTLEBOT_URL/v1/channels/$channel/messages" 2>/dev/null) || continue
103 [ -n "$messages" ] || continue
104 echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" --arg channel "$channel" '
105 .messages[]
106 | select(.nick as $n |
107 ($bots | index($n) | not) and
108 ($n | startswith("claude-") | not) and
109 ($n | startswith("codex-") | not) and
110 ($n | startswith("gemini-") | not) and
111 $n != $self
112 )
113 | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
114 ' 2>/dev/null
115 done | while IFS=$'\t' read -r at channel nick text; do
116 ts=$(epoch_seconds "$at")
117 [ -n "$ts" ] || continue
118 [ "$ts" -gt "$last_check" ] || continue
119 contains_mention "$text" || continue
120 printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
121 done | sort -n | tail -1 | cut -f2-
122 )
123
124 [ -z "$instruction" ] && { echo '{}'; exit 0; }
125
126 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
127 exit 0
128
--- skills/gemini-relay/hooks/scuttlebot-post.sh
+++ skills/gemini-relay/hooks/scuttlebot-post.sh
@@ -5,16 +5,60 @@
55
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
66
set -a
77
. "$SCUTTLEBOT_CONFIG_FILE"
88
set +a
99
fi
10
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11
+ set -a
12
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13
+ set +a
14
+fi
1015
1116
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1217
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1318
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1419
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
15
-SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
20
+
21
+normalize_channel() {
22
+ local channel="$1"
23
+ channel="${channel//[$' \t\r\n']/}"
24
+ channel="${channel#\#}"
25
+ printf '%s' "$channel"
26
+}
27
+
28
+relay_channels() {
29
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
30
+ local IFS=','
31
+ local item channel seen=""
32
+ read -r -a items <<< "$raw"
33
+ for item in "${items[@]}"; do
34
+ channel=$(normalize_channel "$item")
35
+ [ -n "$channel" ] || continue
36
+ case ",$seen," in
37
+ *,"$channel",*) ;;
38
+ *)
39
+ seen="${seen:+$seen,}$channel"
40
+ printf '%s\n' "$channel"
41
+ ;;
42
+ esac
43
+ done
44
+}
45
+
46
+post_message() {
47
+ local text="$1"
48
+ local payload
49
+ payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
50
+ for channel in $(relay_channels); do
51
+ curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
52
+ --connect-timeout 1 \
53
+ --max-time 2 \
54
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
55
+ -H "Content-Type: application/json" \
56
+ -d "$payload" \
57
+ > /dev/null || true
58
+ done
59
+}
1660
1761
sanitize() {
1862
local input="$1"
1963
if [ -z "$input" ]; then
2064
input=$(cat)
@@ -39,12 +83,10 @@
3983
default_nick="gemini-${base_name}-${session_suffix}"
4084
SCUTTLEBOT_NICK="${SCUTTLEBOT_NICK:-$default_nick}"
4185
4286
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; }
4387
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; }
44
-[ "$SCUTTLEBOT_ACTIVITY_VIA_BROKER" = "1" ] && { echo '{}'; exit 0; }
45
-[ "$SCUTTLEBOT_ACTIVITY_VIA_BROKER" = "true" ] && { echo '{}'; exit 0; }
4688
[ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; }
4789
4890
case "$tool" in
4991
run_shell_command|Bash)
5092
cmd=$(echo "$input" | jq -r '.tool_input.command // empty' | head -c 120)
@@ -98,15 +140,9 @@
98140
;;
99141
esac
100142
101143
[ -z "$msg" ] && { echo '{}'; exit 0; }
102144
103
-curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
104
- --connect-timeout 1 \
105
- --max-time 2 \
106
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
107
- -H "Content-Type: application/json" \
108
- -d "{\"text\": $(echo "$msg" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
109
- > /dev/null
145
+post_message "$msg"
110146
111147
echo '{}'
112148
exit 0
113149
--- skills/gemini-relay/hooks/scuttlebot-post.sh
+++ skills/gemini-relay/hooks/scuttlebot-post.sh
@@ -5,16 +5,60 @@
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
 
 
 
 
 
10
11 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
12 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
13 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
14 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
15 SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
17 sanitize() {
18 local input="$1"
19 if [ -z "$input" ]; then
20 input=$(cat)
@@ -39,12 +83,10 @@
39 default_nick="gemini-${base_name}-${session_suffix}"
40 SCUTTLEBOT_NICK="${SCUTTLEBOT_NICK:-$default_nick}"
41
42 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; }
43 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; }
44 [ "$SCUTTLEBOT_ACTIVITY_VIA_BROKER" = "1" ] && { echo '{}'; exit 0; }
45 [ "$SCUTTLEBOT_ACTIVITY_VIA_BROKER" = "true" ] && { echo '{}'; exit 0; }
46 [ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; }
47
48 case "$tool" in
49 run_shell_command|Bash)
50 cmd=$(echo "$input" | jq -r '.tool_input.command // empty' | head -c 120)
@@ -98,15 +140,9 @@
98 ;;
99 esac
100
101 [ -z "$msg" ] && { echo '{}'; exit 0; }
102
103 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
104 --connect-timeout 1 \
105 --max-time 2 \
106 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
107 -H "Content-Type: application/json" \
108 -d "{\"text\": $(echo "$msg" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
109 > /dev/null
110
111 echo '{}'
112 exit 0
113
--- skills/gemini-relay/hooks/scuttlebot-post.sh
+++ skills/gemini-relay/hooks/scuttlebot-post.sh
@@ -5,16 +5,60 @@
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
10 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11 set -a
12 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13 set +a
14 fi
15
16 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
17 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
18 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
19 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
20
21 normalize_channel() {
22 local channel="$1"
23 channel="${channel//[$' \t\r\n']/}"
24 channel="${channel#\#}"
25 printf '%s' "$channel"
26 }
27
28 relay_channels() {
29 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
30 local IFS=','
31 local item channel seen=""
32 read -r -a items <<< "$raw"
33 for item in "${items[@]}"; do
34 channel=$(normalize_channel "$item")
35 [ -n "$channel" ] || continue
36 case ",$seen," in
37 *,"$channel",*) ;;
38 *)
39 seen="${seen:+$seen,}$channel"
40 printf '%s\n' "$channel"
41 ;;
42 esac
43 done
44 }
45
46 post_message() {
47 local text="$1"
48 local payload
49 payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
50 for channel in $(relay_channels); do
51 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
52 --connect-timeout 1 \
53 --max-time 2 \
54 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
55 -H "Content-Type: application/json" \
56 -d "$payload" \
57 > /dev/null || true
58 done
59 }
60
61 sanitize() {
62 local input="$1"
63 if [ -z "$input" ]; then
64 input=$(cat)
@@ -39,12 +83,10 @@
83 default_nick="gemini-${base_name}-${session_suffix}"
84 SCUTTLEBOT_NICK="${SCUTTLEBOT_NICK:-$default_nick}"
85
86 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; }
87 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; }
 
 
88 [ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; }
89
90 case "$tool" in
91 run_shell_command|Bash)
92 cmd=$(echo "$input" | jq -r '.tool_input.command // empty' | head -c 120)
@@ -98,15 +140,9 @@
140 ;;
141 esac
142
143 [ -z "$msg" ] && { echo '{}'; exit 0; }
144
145 post_message "$msg"
 
 
 
 
 
 
146
147 echo '{}'
148 exit 0
149
--- skills/gemini-relay/install.md
+++ skills/gemini-relay/install.md
@@ -34,10 +34,11 @@
3434
3535
## Install (Gemini CLI)
3636
Detailed primer: [`hooks/README.md`](hooks/README.md)
3737
Shared fleet guide: [`FLEET.md`](FLEET.md)
3838
Shared adapter primer: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
39
+Shared relay skill: [`../scuttlebot-relay/SKILL.md`](../scuttlebot-relay/SKILL.md)
3940
4041
Canonical pattern summary:
4142
- broker entrypoint: `cmd/gemini-relay/main.go`
4243
- tracked installer: `skills/gemini-relay/scripts/install-gemini-relay.sh`
4344
- runtime docs: `skills/gemini-relay/install.md` and `skills/gemini-relay/FLEET.md`
@@ -50,11 +51,12 @@
5051
5152
```bash
5253
bash skills/gemini-relay/scripts/install-gemini-relay.sh \
5354
--url http://localhost:8080 \
5455
--token "$(./run.sh token)" \
55
- --channel general
56
+ --channel general \
57
+ --channels general,task-42
5658
```
5759
5860
Or via Make:
5961
6062
```bash
@@ -89,10 +91,12 @@
8991
9092
## Configuration
9193
9294
Useful shared env knobs in `~/.config/scuttlebot-relay.env`:
9395
- `SCUTTLEBOT_TRANSPORT=http|irc` — selects the connector backend
96
+- `SCUTTLEBOT_CHANNEL` — primary control channel
97
+- `SCUTTLEBOT_CHANNELS=general,task-42` — optional startup channel set, including the control channel
9498
- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` — sets the real IRC address when transport is `irc`
9599
- `SCUTTLEBOT_IRC_PASS=...` — uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
96100
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` — keeps auto-registered session nicks after clean exit
97101
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Gemini session when it appears busy
98102
- `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages
@@ -102,5 +106,13 @@
102106
103107
Disable without uninstalling:
104108
```bash
105109
SCUTTLEBOT_HOOKS_ENABLED=0 gemini-relay
106110
```
111
+
112
+Live channel commands:
113
+- `/channels`
114
+- `/join #task-42`
115
+- `/part #task-42`
116
+
117
+Those commands change the joined channel set for the current session without
118
+rewriting the shared env file.
107119
--- skills/gemini-relay/install.md
+++ skills/gemini-relay/install.md
@@ -34,10 +34,11 @@
34
35 ## Install (Gemini CLI)
36 Detailed primer: [`hooks/README.md`](hooks/README.md)
37 Shared fleet guide: [`FLEET.md`](FLEET.md)
38 Shared adapter primer: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
 
39
40 Canonical pattern summary:
41 - broker entrypoint: `cmd/gemini-relay/main.go`
42 - tracked installer: `skills/gemini-relay/scripts/install-gemini-relay.sh`
43 - runtime docs: `skills/gemini-relay/install.md` and `skills/gemini-relay/FLEET.md`
@@ -50,11 +51,12 @@
50
51 ```bash
52 bash skills/gemini-relay/scripts/install-gemini-relay.sh \
53 --url http://localhost:8080 \
54 --token "$(./run.sh token)" \
55 --channel general
 
56 ```
57
58 Or via Make:
59
60 ```bash
@@ -89,10 +91,12 @@
89
90 ## Configuration
91
92 Useful shared env knobs in `~/.config/scuttlebot-relay.env`:
93 - `SCUTTLEBOT_TRANSPORT=http|irc` — selects the connector backend
 
 
94 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` — sets the real IRC address when transport is `irc`
95 - `SCUTTLEBOT_IRC_PASS=...` — uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
96 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` — keeps auto-registered session nicks after clean exit
97 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Gemini session when it appears busy
98 - `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages
@@ -102,5 +106,13 @@
102
103 Disable without uninstalling:
104 ```bash
105 SCUTTLEBOT_HOOKS_ENABLED=0 gemini-relay
106 ```
 
 
 
 
 
 
 
 
107
--- skills/gemini-relay/install.md
+++ skills/gemini-relay/install.md
@@ -34,10 +34,11 @@
34
35 ## Install (Gemini CLI)
36 Detailed primer: [`hooks/README.md`](hooks/README.md)
37 Shared fleet guide: [`FLEET.md`](FLEET.md)
38 Shared adapter primer: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
39 Shared relay skill: [`../scuttlebot-relay/SKILL.md`](../scuttlebot-relay/SKILL.md)
40
41 Canonical pattern summary:
42 - broker entrypoint: `cmd/gemini-relay/main.go`
43 - tracked installer: `skills/gemini-relay/scripts/install-gemini-relay.sh`
44 - runtime docs: `skills/gemini-relay/install.md` and `skills/gemini-relay/FLEET.md`
@@ -50,11 +51,12 @@
51
52 ```bash
53 bash skills/gemini-relay/scripts/install-gemini-relay.sh \
54 --url http://localhost:8080 \
55 --token "$(./run.sh token)" \
56 --channel general \
57 --channels general,task-42
58 ```
59
60 Or via Make:
61
62 ```bash
@@ -89,10 +91,12 @@
91
92 ## Configuration
93
94 Useful shared env knobs in `~/.config/scuttlebot-relay.env`:
95 - `SCUTTLEBOT_TRANSPORT=http|irc` — selects the connector backend
96 - `SCUTTLEBOT_CHANNEL` — primary control channel
97 - `SCUTTLEBOT_CHANNELS=general,task-42` — optional startup channel set, including the control channel
98 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` — sets the real IRC address when transport is `irc`
99 - `SCUTTLEBOT_IRC_PASS=...` — uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
100 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` — keeps auto-registered session nicks after clean exit
101 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Gemini session when it appears busy
102 - `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages
@@ -102,5 +106,13 @@
106
107 Disable without uninstalling:
108 ```bash
109 SCUTTLEBOT_HOOKS_ENABLED=0 gemini-relay
110 ```
111
112 Live channel commands:
113 - `/channels`
114 - `/join #task-42`
115 - `/part #task-42`
116
117 Those commands change the joined channel set for the current session without
118 rewriting the shared env file.
119
--- skills/gemini-relay/scripts/install-gemini-relay.sh
+++ skills/gemini-relay/scripts/install-gemini-relay.sh
@@ -10,10 +10,11 @@
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
+ --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file.
1516
--transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http.
1617
--irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
1718
--irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
1819
--auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
1920
--enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -26,10 +27,11 @@
2627
2728
Environment defaults:
2829
SCUTTLEBOT_URL
2930
SCUTTLEBOT_TOKEN
3031
SCUTTLEBOT_CHANNEL
32
+ SCUTTLEBOT_CHANNELS
3133
SCUTTLEBOT_TRANSPORT
3234
SCUTTLEBOT_IRC_ADDR
3335
SCUTTLEBOT_IRC_PASS
3436
SCUTTLEBOT_HOOKS_ENABLED
3537
SCUTTLEBOT_INTERRUPT_ON_MESSAGE
@@ -54,10 +56,11 @@
5456
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
5557
5658
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
5759
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
5860
SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
61
+SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}"
5962
SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}"
6063
SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
6164
if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
6265
SCUTTLEBOT_IRC_PASS_MODE="fixed"
6366
SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -87,10 +90,14 @@
8790
shift 2
8891
;;
8992
--channel)
9093
SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
9194
shift 2
95
+ ;;
96
+ --channels)
97
+ SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}"
98
+ shift 2
9299
;;
93100
--transport)
94101
SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
95102
shift 2
96103
;;
@@ -159,10 +166,52 @@
159166
}
160167
161168
ensure_parent_dir() {
162169
mkdir -p "$(dirname "$1")"
163170
}
171
+
172
+normalize_channels() {
173
+ local primary="$1"
174
+ local raw="$2"
175
+ local IFS=','
176
+ local items=()
177
+ local extra_items=()
178
+ local item channel seen=""
179
+
180
+ if [ -n "$primary" ]; then
181
+ items+=("$primary")
182
+ fi
183
+ if [ -n "$raw" ]; then
184
+ read -r -a extra_items <<< "$raw"
185
+ items+=("${extra_items[@]}")
186
+ fi
187
+
188
+ for item in "${items[@]}"; do
189
+ channel="${item//[$' \t\r\n']/}"
190
+ channel="${channel#\#}"
191
+ [ -n "$channel" ] || continue
192
+ case ",$seen," in
193
+ *,"$channel",*) ;;
194
+ *) seen="${seen:+$seen,}$channel" ;;
195
+ esac
196
+ done
197
+
198
+ printf '%s' "$seen"
199
+}
200
+
201
+first_channel() {
202
+ local channels
203
+ channels=$(normalize_channels "" "$1")
204
+ printf '%s' "${channels%%,*}"
205
+}
206
+
207
+if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
208
+ SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")"
209
+fi
210
+if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
211
+ SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")"
212
+fi
164213
165214
upsert_env_var() {
166215
local file="$1"
167216
local key="$2"
168217
local value="$3"
@@ -296,10 +345,13 @@
296345
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
297346
fi
298347
if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
299348
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
300349
fi
350
+if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
351
+ upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE"
352
+fi
301353
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
302354
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
303355
if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
304356
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
305357
else
306358
--- skills/gemini-relay/scripts/install-gemini-relay.sh
+++ skills/gemini-relay/scripts/install-gemini-relay.sh
@@ -10,10 +10,11 @@
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 --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
18 --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
19 --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -26,10 +27,11 @@
26
27 Environment defaults:
28 SCUTTLEBOT_URL
29 SCUTTLEBOT_TOKEN
30 SCUTTLEBOT_CHANNEL
 
31 SCUTTLEBOT_TRANSPORT
32 SCUTTLEBOT_IRC_ADDR
33 SCUTTLEBOT_IRC_PASS
34 SCUTTLEBOT_HOOKS_ENABLED
35 SCUTTLEBOT_INTERRUPT_ON_MESSAGE
@@ -54,10 +56,11 @@
54 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
55
56 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
57 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
58 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
 
59 SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}"
60 SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
61 if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
62 SCUTTLEBOT_IRC_PASS_MODE="fixed"
63 SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -87,10 +90,14 @@
87 shift 2
88 ;;
89 --channel)
90 SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
91 shift 2
 
 
 
 
92 ;;
93 --transport)
94 SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
95 shift 2
96 ;;
@@ -159,10 +166,52 @@
159 }
160
161 ensure_parent_dir() {
162 mkdir -p "$(dirname "$1")"
163 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
165 upsert_env_var() {
166 local file="$1"
167 local key="$2"
168 local value="$3"
@@ -296,10 +345,13 @@
296 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
297 fi
298 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
299 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
300 fi
 
 
 
301 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
302 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
303 if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
304 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
305 else
306
--- skills/gemini-relay/scripts/install-gemini-relay.sh
+++ skills/gemini-relay/scripts/install-gemini-relay.sh
@@ -10,10 +10,11 @@
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 --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file.
16 --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http.
17 --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
18 --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
19 --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
20 --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -26,10 +27,11 @@
27
28 Environment defaults:
29 SCUTTLEBOT_URL
30 SCUTTLEBOT_TOKEN
31 SCUTTLEBOT_CHANNEL
32 SCUTTLEBOT_CHANNELS
33 SCUTTLEBOT_TRANSPORT
34 SCUTTLEBOT_IRC_ADDR
35 SCUTTLEBOT_IRC_PASS
36 SCUTTLEBOT_HOOKS_ENABLED
37 SCUTTLEBOT_INTERRUPT_ON_MESSAGE
@@ -54,10 +56,11 @@
56 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
57
58 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
59 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
60 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
61 SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}"
62 SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}"
63 SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
64 if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
65 SCUTTLEBOT_IRC_PASS_MODE="fixed"
66 SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -87,10 +90,14 @@
90 shift 2
91 ;;
92 --channel)
93 SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
94 shift 2
95 ;;
96 --channels)
97 SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}"
98 shift 2
99 ;;
100 --transport)
101 SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
102 shift 2
103 ;;
@@ -159,10 +166,52 @@
166 }
167
168 ensure_parent_dir() {
169 mkdir -p "$(dirname "$1")"
170 }
171
172 normalize_channels() {
173 local primary="$1"
174 local raw="$2"
175 local IFS=','
176 local items=()
177 local extra_items=()
178 local item channel seen=""
179
180 if [ -n "$primary" ]; then
181 items+=("$primary")
182 fi
183 if [ -n "$raw" ]; then
184 read -r -a extra_items <<< "$raw"
185 items+=("${extra_items[@]}")
186 fi
187
188 for item in "${items[@]}"; do
189 channel="${item//[$' \t\r\n']/}"
190 channel="${channel#\#}"
191 [ -n "$channel" ] || continue
192 case ",$seen," in
193 *,"$channel",*) ;;
194 *) seen="${seen:+$seen,}$channel" ;;
195 esac
196 done
197
198 printf '%s' "$seen"
199 }
200
201 first_channel() {
202 local channels
203 channels=$(normalize_channels "" "$1")
204 printf '%s' "${channels%%,*}"
205 }
206
207 if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
208 SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")"
209 fi
210 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
211 SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")"
212 fi
213
214 upsert_env_var() {
215 local file="$1"
216 local key="$2"
217 local value="$3"
@@ -296,10 +345,13 @@
345 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
346 fi
347 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
348 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
349 fi
350 if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
351 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE"
352 fi
353 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
354 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
355 if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
356 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
357 else
358
--- skills/openai-relay/FLEET.md
+++ skills/openai-relay/FLEET.md
@@ -124,10 +124,12 @@
124124
- force a fixed nick across sessions
125125
- require IRC to be up at install time
126126
127127
Useful shared env knobs:
128128
- `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
129
+- `SCUTTLEBOT_CHANNEL` is the primary control channel
130
+- `SCUTTLEBOT_CHANNELS=general,task-42` seeds extra startup work channels
129131
- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
130132
- `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
131133
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
132134
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted
133135
- `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
@@ -135,10 +137,15 @@
135137
- `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
136138
137139
Installer auth knobs:
138140
- default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
139141
- `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
142
+
143
+Live channel commands:
144
+- `/channels`
145
+- `/join #task-42`
146
+- `/part #task-42`
140147
141148
## Operator workflow
142149
143150
1. Watch the configured channel in scuttlebot.
144151
2. Wait for a new `codex-{repo}-{session}` online post.
145152
--- skills/openai-relay/FLEET.md
+++ skills/openai-relay/FLEET.md
@@ -124,10 +124,12 @@
124 - force a fixed nick across sessions
125 - require IRC to be up at install time
126
127 Useful shared env knobs:
128 - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
 
 
129 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
130 - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
131 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
132 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted
133 - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
@@ -135,10 +137,15 @@
135 - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
136
137 Installer auth knobs:
138 - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
139 - `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
 
 
 
 
 
140
141 ## Operator workflow
142
143 1. Watch the configured channel in scuttlebot.
144 2. Wait for a new `codex-{repo}-{session}` online post.
145
--- skills/openai-relay/FLEET.md
+++ skills/openai-relay/FLEET.md
@@ -124,10 +124,12 @@
124 - force a fixed nick across sessions
125 - require IRC to be up at install time
126
127 Useful shared env knobs:
128 - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
129 - `SCUTTLEBOT_CHANNEL` is the primary control channel
130 - `SCUTTLEBOT_CHANNELS=general,task-42` seeds extra startup work channels
131 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
132 - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
133 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
134 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted
135 - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
@@ -135,10 +137,15 @@
137 - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
138
139 Installer auth knobs:
140 - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
141 - `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
142
143 Live channel commands:
144 - `/channels`
145 - `/join #task-42`
146 - `/part #task-42`
147
148 ## Operator workflow
149
150 1. Watch the configured channel in scuttlebot.
151 2. Wait for a new `codex-{repo}-{session}` online post.
152
--- skills/openai-relay/SKILL.md
+++ skills/openai-relay/SKILL.md
@@ -14,10 +14,11 @@
1414
hooks, and accept addressed instructions continuously while the session is running.
1515
1616
Codex and Gemini are the canonical terminal-broker reference implementations in
1717
this repo. The shared path and convention contract lives in
1818
`skills/scuttlebot-relay/ADDING_AGENTS.md`.
19
+For generic install/config work across runtimes, use `skills/scuttlebot-relay/SKILL.md`.
1920
2021
Source-of-truth files in the repo:
2122
- installer: `skills/openai-relay/scripts/install-codex-relay.sh`
2223
- broker: `cmd/codex-relay/main.go`
2324
- shared connector: `pkg/sessionrelay/`
2425
--- skills/openai-relay/SKILL.md
+++ skills/openai-relay/SKILL.md
@@ -14,10 +14,11 @@
14 hooks, and accept addressed instructions continuously while the session is running.
15
16 Codex and Gemini are the canonical terminal-broker reference implementations in
17 this repo. The shared path and convention contract lives in
18 `skills/scuttlebot-relay/ADDING_AGENTS.md`.
 
19
20 Source-of-truth files in the repo:
21 - installer: `skills/openai-relay/scripts/install-codex-relay.sh`
22 - broker: `cmd/codex-relay/main.go`
23 - shared connector: `pkg/sessionrelay/`
24
--- skills/openai-relay/SKILL.md
+++ skills/openai-relay/SKILL.md
@@ -14,10 +14,11 @@
14 hooks, and accept addressed instructions continuously while the session is running.
15
16 Codex and Gemini are the canonical terminal-broker reference implementations in
17 this repo. The shared path and convention contract lives in
18 `skills/scuttlebot-relay/ADDING_AGENTS.md`.
19 For generic install/config work across runtimes, use `skills/scuttlebot-relay/SKILL.md`.
20
21 Source-of-truth files in the repo:
22 - installer: `skills/openai-relay/scripts/install-codex-relay.sh`
23 - broker: `cmd/codex-relay/main.go`
24 - shared connector: `pkg/sessionrelay/`
25
--- skills/openai-relay/hooks/README.md
+++ skills/openai-relay/hooks/README.md
@@ -75,10 +75,12 @@
7575
- `curl` and `jq` available on `PATH`
7676
7777
Optional:
7878
- `SCUTTLEBOT_NICK`
7979
- `SCUTTLEBOT_SESSION_ID`
80
+- `SCUTTLEBOT_CHANNELS`
81
+- `SCUTTLEBOT_CHANNEL_STATE_FILE`
8082
- `SCUTTLEBOT_TRANSPORT`
8183
- `SCUTTLEBOT_IRC_ADDR`
8284
- `SCUTTLEBOT_IRC_PASS`
8385
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
8486
- `SCUTTLEBOT_HOOKS_ENABLED`
@@ -92,19 +94,21 @@
9294
9395
```bash
9496
export SCUTTLEBOT_URL=http://localhost:8080
9597
export SCUTTLEBOT_TOKEN=$(./run.sh token)
9698
export SCUTTLEBOT_CHANNEL=general
99
+export SCUTTLEBOT_CHANNELS=general,task-42
97100
```
98101
99102
The hooks also auto-load a shared relay env file if it exists:
100103
101104
```bash
102105
cat > ~/.config/scuttlebot-relay.env <<'EOF'
103106
SCUTTLEBOT_URL=http://localhost:8080
104107
SCUTTLEBOT_TOKEN=...
105108
SCUTTLEBOT_CHANNEL=general
109
+SCUTTLEBOT_CHANNELS=general
106110
SCUTTLEBOT_TRANSPORT=http
107111
SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
108112
SCUTTLEBOT_HOOKS_ENABLED=1
109113
SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
110114
SCUTTLEBOT_POLL_INTERVAL=2s
@@ -128,11 +132,12 @@
128132
129133
```bash
130134
bash skills/openai-relay/scripts/install-codex-relay.sh \
131135
--url http://localhost:8080 \
132136
--token "$(./run.sh token)" \
133
- --channel general
137
+ --channel general \
138
+ --channels general,task-42
134139
```
135140
136141
Manual path:
137142
138143
Install the scripts:
@@ -232,13 +237,15 @@
232237
```text
233238
/tmp/.scuttlebot-last-check-{checksum}
234239
```
235240
236241
The checksum is derived from:
237
-- channel
238242
- session nick
239243
- current working directory
244
+
245
+Live channel changes come from `SCUTTLEBOT_CHANNEL_STATE_FILE`, which the broker
246
+rewrites as `/join` and `/part` commands change the current session channel set.
240247
241248
That avoids one session consuming another session's instructions.
242249
243250
## Smoke test
244251
245252
--- skills/openai-relay/hooks/README.md
+++ skills/openai-relay/hooks/README.md
@@ -75,10 +75,12 @@
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`
@@ -92,19 +94,21 @@
92
93 ```bash
94 export SCUTTLEBOT_URL=http://localhost:8080
95 export SCUTTLEBOT_TOKEN=$(./run.sh token)
96 export SCUTTLEBOT_CHANNEL=general
 
97 ```
98
99 The hooks also auto-load a shared relay env file if it exists:
100
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
@@ -128,11 +132,12 @@
128
129 ```bash
130 bash skills/openai-relay/scripts/install-codex-relay.sh \
131 --url http://localhost:8080 \
132 --token "$(./run.sh token)" \
133 --channel general
 
134 ```
135
136 Manual path:
137
138 Install the scripts:
@@ -232,13 +237,15 @@
232 ```text
233 /tmp/.scuttlebot-last-check-{checksum}
234 ```
235
236 The checksum is derived from:
237 - channel
238 - session nick
239 - current working directory
 
 
 
240
241 That avoids one session consuming another session's instructions.
242
243 ## Smoke test
244
245
--- skills/openai-relay/hooks/README.md
+++ skills/openai-relay/hooks/README.md
@@ -75,10 +75,12 @@
75 - `curl` and `jq` available on `PATH`
76
77 Optional:
78 - `SCUTTLEBOT_NICK`
79 - `SCUTTLEBOT_SESSION_ID`
80 - `SCUTTLEBOT_CHANNELS`
81 - `SCUTTLEBOT_CHANNEL_STATE_FILE`
82 - `SCUTTLEBOT_TRANSPORT`
83 - `SCUTTLEBOT_IRC_ADDR`
84 - `SCUTTLEBOT_IRC_PASS`
85 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
86 - `SCUTTLEBOT_HOOKS_ENABLED`
@@ -92,19 +94,21 @@
94
95 ```bash
96 export SCUTTLEBOT_URL=http://localhost:8080
97 export SCUTTLEBOT_TOKEN=$(./run.sh token)
98 export SCUTTLEBOT_CHANNEL=general
99 export SCUTTLEBOT_CHANNELS=general,task-42
100 ```
101
102 The hooks also auto-load a shared relay env file if it exists:
103
104 ```bash
105 cat > ~/.config/scuttlebot-relay.env <<'EOF'
106 SCUTTLEBOT_URL=http://localhost:8080
107 SCUTTLEBOT_TOKEN=...
108 SCUTTLEBOT_CHANNEL=general
109 SCUTTLEBOT_CHANNELS=general
110 SCUTTLEBOT_TRANSPORT=http
111 SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
112 SCUTTLEBOT_HOOKS_ENABLED=1
113 SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
114 SCUTTLEBOT_POLL_INTERVAL=2s
@@ -128,11 +132,12 @@
132
133 ```bash
134 bash skills/openai-relay/scripts/install-codex-relay.sh \
135 --url http://localhost:8080 \
136 --token "$(./run.sh token)" \
137 --channel general \
138 --channels general,task-42
139 ```
140
141 Manual path:
142
143 Install the scripts:
@@ -232,13 +237,15 @@
237 ```text
238 /tmp/.scuttlebot-last-check-{checksum}
239 ```
240
241 The checksum is derived from:
 
242 - session nick
243 - current working directory
244
245 Live channel changes come from `SCUTTLEBOT_CHANNEL_STATE_FILE`, which the broker
246 rewrites as `/join` and `/part` commands change the current session channel set.
247
248 That avoids one session consuming another session's instructions.
249
250 ## Smoke test
251
252
--- skills/openai-relay/hooks/scuttlebot-check.sh
+++ skills/openai-relay/hooks/scuttlebot-check.sh
@@ -7,19 +7,63 @@
77
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
88
set -a
99
. "$SCUTTLEBOT_CONFIG_FILE"
1010
set +a
1111
fi
12
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
13
+ set -a
14
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
15
+ set +a
16
+fi
1217
1318
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1419
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1520
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1621
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
1722
1823
sanitize() {
1924
printf '%s' "$1" | tr -cs '[:alnum:]_-' '-'
2025
}
26
+
27
+normalize_channel() {
28
+ local channel="$1"
29
+ channel="${channel//[$' \t\r\n']/}"
30
+ channel="${channel#\#}"
31
+ printf '%s' "$channel"
32
+}
33
+
34
+relay_channels() {
35
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
36
+ local IFS=','
37
+ local item channel seen=""
38
+ read -r -a items <<< "$raw"
39
+ for item in "${items[@]}"; do
40
+ channel=$(normalize_channel "$item")
41
+ [ -n "$channel" ] || continue
42
+ case ",$seen," in
43
+ *,"$channel",*) ;;
44
+ *)
45
+ seen="${seen:+$seen,}$channel"
46
+ printf '%s\n' "$channel"
47
+ ;;
48
+ esac
49
+ done
50
+}
51
+
52
+contains_mention() {
53
+ local text="$1"
54
+ [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
55
+}
56
+
57
+epoch_seconds() {
58
+ local at="$1"
59
+ local ts_clean ts
60
+ ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
61
+ ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
62
+ date -d "$ts_clean" "+%s" 2>/dev/null)
63
+ printf '%s' "$ts"
64
+}
2165
2266
base_name=$(basename "$(pwd)")
2367
base_name=$(sanitize "$base_name")
2468
session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}"
2569
session_suffix=$(sanitize "$session_suffix")
@@ -28,56 +72,49 @@
2872
2973
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0
3074
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0
3175
[ -z "$SCUTTLEBOT_TOKEN" ] && exit 0
3276
33
-state_key=$(printf '%s' "$SCUTTLEBOT_CHANNEL|$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
77
+state_key=$(printf '%s' "$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
3478
LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
3579
36
-contains_mention() {
37
- local text="$1"
38
- [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
39
-}
40
-
4180
last_check=0
4281
if [ -f "$LAST_CHECK_FILE" ]; then
4382
last_check=$(cat "$LAST_CHECK_FILE")
4483
fi
4584
now=$(date +%s)
4685
echo "$now" > "$LAST_CHECK_FILE"
4786
48
-messages=$(curl -sf \
49
- --connect-timeout 1 \
50
- --max-time 2 \
51
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
52
- "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
53
-
54
-[ -z "$messages" ] && exit 0
55
-
5687
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
5788
5889
instruction=$(
59
- echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
60
- .messages[]
61
- | select(.nick as $n |
62
- ($bots | index($n) | not) and
63
- ($n | startswith("claude-") | not) and
64
- ($n | startswith("codex-") | not) and
65
- ($n | startswith("gemini-") | not) and
66
- $n != $self
67
- )
68
- | "\(.at)\t\(.nick)\t\(.text)"
69
- ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
70
- ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
71
- ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
72
- date -d "$ts_clean" "+%s" 2>/dev/null)
90
+ for channel in $(relay_channels); do
91
+ messages=$(curl -sf \
92
+ --connect-timeout 1 \
93
+ --max-time 2 \
94
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
95
+ "$SCUTTLEBOT_URL/v1/channels/$channel/messages" 2>/dev/null) || continue
96
+ [ -n "$messages" ] || continue
97
+ echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" --arg channel "$channel" '
98
+ .messages[]
99
+ | select(.nick as $n |
100
+ ($bots | index($n) | not) and
101
+ ($n | startswith("claude-") | not) and
102
+ ($n | startswith("codex-") | not) and
103
+ ($n | startswith("gemini-") | not) and
104
+ $n != $self
105
+ )
106
+ | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
107
+ ' 2>/dev/null
108
+ done | while IFS=$'\t' read -r at channel nick text; do
109
+ ts=$(epoch_seconds "$at")
73110
[ -n "$ts" ] || continue
74111
[ "$ts" -gt "$last_check" ] || continue
75112
contains_mention "$text" || continue
76
- echo "$nick: $text"
77
- done | tail -1
113
+ printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
114
+ done | sort -n | tail -1 | cut -f2-
78115
)
79116
80117
[ -z "$instruction" ] && exit 0
81118
82119
echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
83120
exit 0
84121
--- skills/openai-relay/hooks/scuttlebot-check.sh
+++ skills/openai-relay/hooks/scuttlebot-check.sh
@@ -7,19 +7,63 @@
7 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
8 set -a
9 . "$SCUTTLEBOT_CONFIG_FILE"
10 set +a
11 fi
 
 
 
 
 
12
13 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
14 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
15 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
16 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
17
18 sanitize() {
19 printf '%s' "$1" | tr -cs '[:alnum:]_-' '-'
20 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
22 base_name=$(basename "$(pwd)")
23 base_name=$(sanitize "$base_name")
24 session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}"
25 session_suffix=$(sanitize "$session_suffix")
@@ -28,56 +72,49 @@
28
29 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0
30 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0
31 [ -z "$SCUTTLEBOT_TOKEN" ] && exit 0
32
33 state_key=$(printf '%s' "$SCUTTLEBOT_CHANNEL|$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
34 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
35
36 contains_mention() {
37 local text="$1"
38 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
39 }
40
41 last_check=0
42 if [ -f "$LAST_CHECK_FILE" ]; then
43 last_check=$(cat "$LAST_CHECK_FILE")
44 fi
45 now=$(date +%s)
46 echo "$now" > "$LAST_CHECK_FILE"
47
48 messages=$(curl -sf \
49 --connect-timeout 1 \
50 --max-time 2 \
51 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
52 "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
53
54 [ -z "$messages" ] && exit 0
55
56 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
57
58 instruction=$(
59 echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
60 .messages[]
61 | select(.nick as $n |
62 ($bots | index($n) | not) and
63 ($n | startswith("claude-") | not) and
64 ($n | startswith("codex-") | not) and
65 ($n | startswith("gemini-") | not) and
66 $n != $self
67 )
68 | "\(.at)\t\(.nick)\t\(.text)"
69 ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
70 ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
71 ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
72 date -d "$ts_clean" "+%s" 2>/dev/null)
 
 
 
 
 
 
73 [ -n "$ts" ] || continue
74 [ "$ts" -gt "$last_check" ] || continue
75 contains_mention "$text" || continue
76 echo "$nick: $text"
77 done | tail -1
78 )
79
80 [ -z "$instruction" ] && exit 0
81
82 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
83 exit 0
84
--- skills/openai-relay/hooks/scuttlebot-check.sh
+++ skills/openai-relay/hooks/scuttlebot-check.sh
@@ -7,19 +7,63 @@
7 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
8 set -a
9 . "$SCUTTLEBOT_CONFIG_FILE"
10 set +a
11 fi
12 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
13 set -a
14 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
15 set +a
16 fi
17
18 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
19 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
20 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
21 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
22
23 sanitize() {
24 printf '%s' "$1" | tr -cs '[:alnum:]_-' '-'
25 }
26
27 normalize_channel() {
28 local channel="$1"
29 channel="${channel//[$' \t\r\n']/}"
30 channel="${channel#\#}"
31 printf '%s' "$channel"
32 }
33
34 relay_channels() {
35 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
36 local IFS=','
37 local item channel seen=""
38 read -r -a items <<< "$raw"
39 for item in "${items[@]}"; do
40 channel=$(normalize_channel "$item")
41 [ -n "$channel" ] || continue
42 case ",$seen," in
43 *,"$channel",*) ;;
44 *)
45 seen="${seen:+$seen,}$channel"
46 printf '%s\n' "$channel"
47 ;;
48 esac
49 done
50 }
51
52 contains_mention() {
53 local text="$1"
54 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
55 }
56
57 epoch_seconds() {
58 local at="$1"
59 local ts_clean ts
60 ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
61 ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
62 date -d "$ts_clean" "+%s" 2>/dev/null)
63 printf '%s' "$ts"
64 }
65
66 base_name=$(basename "$(pwd)")
67 base_name=$(sanitize "$base_name")
68 session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}"
69 session_suffix=$(sanitize "$session_suffix")
@@ -28,56 +72,49 @@
72
73 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0
74 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0
75 [ -z "$SCUTTLEBOT_TOKEN" ] && exit 0
76
77 state_key=$(printf '%s' "$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
78 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
79
 
 
 
 
 
80 last_check=0
81 if [ -f "$LAST_CHECK_FILE" ]; then
82 last_check=$(cat "$LAST_CHECK_FILE")
83 fi
84 now=$(date +%s)
85 echo "$now" > "$LAST_CHECK_FILE"
86
 
 
 
 
 
 
 
 
87 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
88
89 instruction=$(
90 for channel in $(relay_channels); do
91 messages=$(curl -sf \
92 --connect-timeout 1 \
93 --max-time 2 \
94 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
95 "$SCUTTLEBOT_URL/v1/channels/$channel/messages" 2>/dev/null) || continue
96 [ -n "$messages" ] || continue
97 echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" --arg channel "$channel" '
98 .messages[]
99 | select(.nick as $n |
100 ($bots | index($n) | not) and
101 ($n | startswith("claude-") | not) and
102 ($n | startswith("codex-") | not) and
103 ($n | startswith("gemini-") | not) and
104 $n != $self
105 )
106 | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
107 ' 2>/dev/null
108 done | while IFS=$'\t' read -r at channel nick text; do
109 ts=$(epoch_seconds "$at")
110 [ -n "$ts" ] || continue
111 [ "$ts" -gt "$last_check" ] || continue
112 contains_mention "$text" || continue
113 printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
114 done | sort -n | tail -1 | cut -f2-
115 )
116
117 [ -z "$instruction" ] && exit 0
118
119 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
120 exit 0
121
--- skills/openai-relay/hooks/scuttlebot-post.sh
+++ skills/openai-relay/hooks/scuttlebot-post.sh
@@ -5,16 +5,61 @@
55
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
66
set -a
77
. "$SCUTTLEBOT_CONFIG_FILE"
88
set +a
99
fi
10
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11
+ set -a
12
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13
+ set +a
14
+fi
1015
1116
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1217
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1318
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1419
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
1520
SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
21
+
22
+normalize_channel() {
23
+ local channel="$1"
24
+ channel="${channel//[$' \t\r\n']/}"
25
+ channel="${channel#\#}"
26
+ printf '%s' "$channel"
27
+}
28
+
29
+relay_channels() {
30
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
31
+ local IFS=','
32
+ local item channel seen=""
33
+ read -r -a items <<< "$raw"
34
+ for item in "${items[@]}"; do
35
+ channel=$(normalize_channel "$item")
36
+ [ -n "$channel" ] || continue
37
+ case ",$seen," in
38
+ *,"$channel",*) ;;
39
+ *)
40
+ seen="${seen:+$seen,}$channel"
41
+ printf '%s\n' "$channel"
42
+ ;;
43
+ esac
44
+ done
45
+}
46
+
47
+post_message() {
48
+ local text="$1"
49
+ local payload
50
+ payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
51
+ for channel in $(relay_channels); do
52
+ curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
53
+ --connect-timeout 1 \
54
+ --max-time 2 \
55
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
56
+ -H "Content-Type: application/json" \
57
+ -d "$payload" \
58
+ > /dev/null || true
59
+ done
60
+}
1661
1762
input=$(cat)
1863
1964
tool=$(echo "$input" | jq -r '.tool_name // empty')
2065
cwd=$(echo "$input" | jq -r '.cwd // empty')
@@ -72,14 +117,7 @@
72117
;;
73118
esac
74119
75120
[ -z "$msg" ] && exit 0
76121
77
-curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
78
- --connect-timeout 1 \
79
- --max-time 2 \
80
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
81
- -H "Content-Type: application/json" \
82
- -d "{\"text\": $(echo "$msg" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
83
- > /dev/null
84
-
122
+post_message "$msg"
85123
exit 0
86124
--- skills/openai-relay/hooks/scuttlebot-post.sh
+++ skills/openai-relay/hooks/scuttlebot-post.sh
@@ -5,16 +5,61 @@
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
 
 
 
 
 
10
11 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
12 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
13 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
14 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
15 SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
17 input=$(cat)
18
19 tool=$(echo "$input" | jq -r '.tool_name // empty')
20 cwd=$(echo "$input" | jq -r '.cwd // empty')
@@ -72,14 +117,7 @@
72 ;;
73 esac
74
75 [ -z "$msg" ] && exit 0
76
77 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
78 --connect-timeout 1 \
79 --max-time 2 \
80 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
81 -H "Content-Type: application/json" \
82 -d "{\"text\": $(echo "$msg" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
83 > /dev/null
84
85 exit 0
86
--- skills/openai-relay/hooks/scuttlebot-post.sh
+++ skills/openai-relay/hooks/scuttlebot-post.sh
@@ -5,16 +5,61 @@
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
10 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11 set -a
12 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13 set +a
14 fi
15
16 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
17 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
18 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
19 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
20 SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
21
22 normalize_channel() {
23 local channel="$1"
24 channel="${channel//[$' \t\r\n']/}"
25 channel="${channel#\#}"
26 printf '%s' "$channel"
27 }
28
29 relay_channels() {
30 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
31 local IFS=','
32 local item channel seen=""
33 read -r -a items <<< "$raw"
34 for item in "${items[@]}"; do
35 channel=$(normalize_channel "$item")
36 [ -n "$channel" ] || continue
37 case ",$seen," in
38 *,"$channel",*) ;;
39 *)
40 seen="${seen:+$seen,}$channel"
41 printf '%s\n' "$channel"
42 ;;
43 esac
44 done
45 }
46
47 post_message() {
48 local text="$1"
49 local payload
50 payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
51 for channel in $(relay_channels); do
52 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
53 --connect-timeout 1 \
54 --max-time 2 \
55 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
56 -H "Content-Type: application/json" \
57 -d "$payload" \
58 > /dev/null || true
59 done
60 }
61
62 input=$(cat)
63
64 tool=$(echo "$input" | jq -r '.tool_name // empty')
65 cwd=$(echo "$input" | jq -r '.cwd // empty')
@@ -72,14 +117,7 @@
117 ;;
118 esac
119
120 [ -z "$msg" ] && exit 0
121
122 post_message "$msg"
 
 
 
 
 
 
 
123 exit 0
124
--- skills/openai-relay/install.md
+++ skills/openai-relay/install.md
@@ -35,10 +35,11 @@
3535
```
3636
3737
## Preferred For Local Codex CLI: codex-relay broker
3838
Detailed primer: [`hooks/README.md`](hooks/README.md)
3939
Shared adapter primer: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
40
+Shared relay skill: [`../scuttlebot-relay/SKILL.md`](../scuttlebot-relay/SKILL.md)
4041
Fleet rollout guide: [`FLEET.md`](FLEET.md)
4142
4243
Canonical pattern summary:
4344
- broker entrypoint: `cmd/codex-relay/main.go`
4445
- tracked installer: `skills/openai-relay/scripts/install-codex-relay.sh`
@@ -52,11 +53,12 @@
5253
5354
```bash
5455
bash skills/openai-relay/scripts/install-codex-relay.sh \
5556
--url http://localhost:8080 \
5657
--token "$(./run.sh token)" \
57
- --channel general
58
+ --channel general \
59
+ --channels general,task-42
5860
```
5961
6062
This installer:
6163
- copies the tracked hook scripts into `~/.codex/hooks/`
6264
- builds and installs `codex-relay` into `~/.local/bin/`
@@ -93,10 +95,12 @@
9395
Common knobs:
9496
- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667`
9597
- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s`
9698
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1`
9799
- `SCUTTLEBOT_IRC_PASS` only when you intentionally want a fixed NickServ identity instead of auto-registration
100
+- `SCUTTLEBOT_CHANNEL` primary control channel
101
+- `SCUTTLEBOT_CHANNELS` optional startup channel set; include the control channel
98102
99103
Installer auth modes:
100104
- default: omit `SCUTTLEBOT_IRC_PASS` and let the broker auto-register the session nick
101105
- `--irc-pass <passphrase>`: pin a fixed NickServ password in the shared env file
102106
- `--auto-register`: remove any stale `SCUTTLEBOT_IRC_PASS` entry from the shared env file
@@ -210,10 +214,19 @@
210214
- `SCUTTLEBOT_TRANSPORT=irc` switches from the HTTP bridge path to a real IRC socket
211215
- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` points the real IRC transport at Ergo
212216
- `SCUTTLEBOT_IRC_PASS=<passphrase>` skips auto-registration and uses a fixed NickServ password; leave it unset for the default broker convention
213217
- `SCUTTLEBOT_PRESENCE_HEARTBEAT=0` disables HTTP presence heartbeats
214218
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks in the registry after clean exit
219
+- `SCUTTLEBOT_CHANNELS=general,task-42` starts the broker in more than one channel
220
+
221
+Live channel commands:
222
+- `/channels`
223
+- `/join #task-42`
224
+- `/part #task-42`
225
+
226
+Those commands change the joined channel set for the current session without
227
+rewriting the shared env file.
215228
216229
If you want `codex` itself to always use the wrapper, prefer a shell alias:
217230
218231
```bash
219232
alias codex="$HOME/.local/bin/codex-relay"
220233
--- skills/openai-relay/install.md
+++ skills/openai-relay/install.md
@@ -35,10 +35,11 @@
35 ```
36
37 ## Preferred For Local Codex CLI: codex-relay broker
38 Detailed primer: [`hooks/README.md`](hooks/README.md)
39 Shared adapter primer: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
 
40 Fleet rollout guide: [`FLEET.md`](FLEET.md)
41
42 Canonical pattern summary:
43 - broker entrypoint: `cmd/codex-relay/main.go`
44 - tracked installer: `skills/openai-relay/scripts/install-codex-relay.sh`
@@ -52,11 +53,12 @@
52
53 ```bash
54 bash skills/openai-relay/scripts/install-codex-relay.sh \
55 --url http://localhost:8080 \
56 --token "$(./run.sh token)" \
57 --channel general
 
58 ```
59
60 This installer:
61 - copies the tracked hook scripts into `~/.codex/hooks/`
62 - builds and installs `codex-relay` into `~/.local/bin/`
@@ -93,10 +95,12 @@
93 Common knobs:
94 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667`
95 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s`
96 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1`
97 - `SCUTTLEBOT_IRC_PASS` only when you intentionally want a fixed NickServ identity instead of auto-registration
 
 
98
99 Installer auth modes:
100 - default: omit `SCUTTLEBOT_IRC_PASS` and let the broker auto-register the session nick
101 - `--irc-pass <passphrase>`: pin a fixed NickServ password in the shared env file
102 - `--auto-register`: remove any stale `SCUTTLEBOT_IRC_PASS` entry from the shared env file
@@ -210,10 +214,19 @@
210 - `SCUTTLEBOT_TRANSPORT=irc` switches from the HTTP bridge path to a real IRC socket
211 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` points the real IRC transport at Ergo
212 - `SCUTTLEBOT_IRC_PASS=<passphrase>` skips auto-registration and uses a fixed NickServ password; leave it unset for the default broker convention
213 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=0` disables HTTP presence heartbeats
214 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks in the registry after clean exit
 
 
 
 
 
 
 
 
 
215
216 If you want `codex` itself to always use the wrapper, prefer a shell alias:
217
218 ```bash
219 alias codex="$HOME/.local/bin/codex-relay"
220
--- skills/openai-relay/install.md
+++ skills/openai-relay/install.md
@@ -35,10 +35,11 @@
35 ```
36
37 ## Preferred For Local Codex CLI: codex-relay broker
38 Detailed primer: [`hooks/README.md`](hooks/README.md)
39 Shared adapter primer: [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md)
40 Shared relay skill: [`../scuttlebot-relay/SKILL.md`](../scuttlebot-relay/SKILL.md)
41 Fleet rollout guide: [`FLEET.md`](FLEET.md)
42
43 Canonical pattern summary:
44 - broker entrypoint: `cmd/codex-relay/main.go`
45 - tracked installer: `skills/openai-relay/scripts/install-codex-relay.sh`
@@ -52,11 +53,12 @@
53
54 ```bash
55 bash skills/openai-relay/scripts/install-codex-relay.sh \
56 --url http://localhost:8080 \
57 --token "$(./run.sh token)" \
58 --channel general \
59 --channels general,task-42
60 ```
61
62 This installer:
63 - copies the tracked hook scripts into `~/.codex/hooks/`
64 - builds and installs `codex-relay` into `~/.local/bin/`
@@ -93,10 +95,12 @@
95 Common knobs:
96 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667`
97 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s`
98 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1`
99 - `SCUTTLEBOT_IRC_PASS` only when you intentionally want a fixed NickServ identity instead of auto-registration
100 - `SCUTTLEBOT_CHANNEL` primary control channel
101 - `SCUTTLEBOT_CHANNELS` optional startup channel set; include the control channel
102
103 Installer auth modes:
104 - default: omit `SCUTTLEBOT_IRC_PASS` and let the broker auto-register the session nick
105 - `--irc-pass <passphrase>`: pin a fixed NickServ password in the shared env file
106 - `--auto-register`: remove any stale `SCUTTLEBOT_IRC_PASS` entry from the shared env file
@@ -210,10 +214,19 @@
214 - `SCUTTLEBOT_TRANSPORT=irc` switches from the HTTP bridge path to a real IRC socket
215 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` points the real IRC transport at Ergo
216 - `SCUTTLEBOT_IRC_PASS=<passphrase>` skips auto-registration and uses a fixed NickServ password; leave it unset for the default broker convention
217 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=0` disables HTTP presence heartbeats
218 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks in the registry after clean exit
219 - `SCUTTLEBOT_CHANNELS=general,task-42` starts the broker in more than one channel
220
221 Live channel commands:
222 - `/channels`
223 - `/join #task-42`
224 - `/part #task-42`
225
226 Those commands change the joined channel set for the current session without
227 rewriting the shared env file.
228
229 If you want `codex` itself to always use the wrapper, prefer a shell alias:
230
231 ```bash
232 alias codex="$HOME/.local/bin/codex-relay"
233
--- skills/openai-relay/scripts/install-codex-relay.sh
+++ skills/openai-relay/scripts/install-codex-relay.sh
@@ -10,10 +10,11 @@
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
+ --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file.
1516
--transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http.
1617
--irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
1718
--irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
1819
--auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
1920
--enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -27,10 +28,11 @@
2728
2829
Environment defaults:
2930
SCUTTLEBOT_URL
3031
SCUTTLEBOT_TOKEN
3132
SCUTTLEBOT_CHANNEL
33
+ SCUTTLEBOT_CHANNELS
3234
SCUTTLEBOT_TRANSPORT
3335
SCUTTLEBOT_IRC_ADDR
3436
SCUTTLEBOT_IRC_PASS
3537
SCUTTLEBOT_HOOKS_ENABLED
3638
SCUTTLEBOT_INTERRUPT_ON_MESSAGE
@@ -54,10 +56,11 @@
5456
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
5557
5658
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
5759
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
5860
SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
61
+SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}"
5962
SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}"
6063
SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
6164
if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
6265
SCUTTLEBOT_IRC_PASS_MODE="fixed"
6366
SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -88,10 +91,14 @@
8891
shift 2
8992
;;
9093
--channel)
9194
SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
9295
shift 2
96
+ ;;
97
+ --channels)
98
+ SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}"
99
+ shift 2
93100
;;
94101
--transport)
95102
SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
96103
shift 2
97104
;;
@@ -164,10 +171,52 @@
164171
}
165172
166173
ensure_parent_dir() {
167174
mkdir -p "$(dirname "$1")"
168175
}
176
+
177
+normalize_channels() {
178
+ local primary="$1"
179
+ local raw="$2"
180
+ local IFS=','
181
+ local items=()
182
+ local extra_items=()
183
+ local item channel seen=""
184
+
185
+ if [ -n "$primary" ]; then
186
+ items+=("$primary")
187
+ fi
188
+ if [ -n "$raw" ]; then
189
+ read -r -a extra_items <<< "$raw"
190
+ items+=("${extra_items[@]}")
191
+ fi
192
+
193
+ for item in "${items[@]}"; do
194
+ channel="${item//[$' \t\r\n']/}"
195
+ channel="${channel#\#}"
196
+ [ -n "$channel" ] || continue
197
+ case ",$seen," in
198
+ *,"$channel",*) ;;
199
+ *) seen="${seen:+$seen,}$channel" ;;
200
+ esac
201
+ done
202
+
203
+ printf '%s' "$seen"
204
+}
205
+
206
+first_channel() {
207
+ local channels
208
+ channels=$(normalize_channels "" "$1")
209
+ printf '%s' "${channels%%,*}"
210
+}
211
+
212
+if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
213
+ SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")"
214
+fi
215
+if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
216
+ SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")"
217
+fi
169218
170219
upsert_env_var() {
171220
local file="$1"
172221
local key="$2"
173222
local value="$3"
@@ -349,10 +398,13 @@
349398
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
350399
fi
351400
if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
352401
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
353402
fi
403
+if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
404
+ upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE"
405
+fi
354406
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
355407
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
356408
if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
357409
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
358410
else
359411
--- skills/openai-relay/scripts/install-codex-relay.sh
+++ skills/openai-relay/scripts/install-codex-relay.sh
@@ -10,10 +10,11 @@
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 --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
18 --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
19 --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -27,10 +28,11 @@
27
28 Environment defaults:
29 SCUTTLEBOT_URL
30 SCUTTLEBOT_TOKEN
31 SCUTTLEBOT_CHANNEL
 
32 SCUTTLEBOT_TRANSPORT
33 SCUTTLEBOT_IRC_ADDR
34 SCUTTLEBOT_IRC_PASS
35 SCUTTLEBOT_HOOKS_ENABLED
36 SCUTTLEBOT_INTERRUPT_ON_MESSAGE
@@ -54,10 +56,11 @@
54 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
55
56 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
57 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
58 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
 
59 SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}"
60 SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
61 if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
62 SCUTTLEBOT_IRC_PASS_MODE="fixed"
63 SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -88,10 +91,14 @@
88 shift 2
89 ;;
90 --channel)
91 SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
92 shift 2
 
 
 
 
93 ;;
94 --transport)
95 SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
96 shift 2
97 ;;
@@ -164,10 +171,52 @@
164 }
165
166 ensure_parent_dir() {
167 mkdir -p "$(dirname "$1")"
168 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
170 upsert_env_var() {
171 local file="$1"
172 local key="$2"
173 local value="$3"
@@ -349,10 +398,13 @@
349 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
350 fi
351 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
352 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
353 fi
 
 
 
354 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
355 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
356 if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
357 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
358 else
359
--- skills/openai-relay/scripts/install-codex-relay.sh
+++ skills/openai-relay/scripts/install-codex-relay.sh
@@ -10,10 +10,11 @@
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 --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file.
16 --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http.
17 --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
18 --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
19 --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
20 --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -27,10 +28,11 @@
28
29 Environment defaults:
30 SCUTTLEBOT_URL
31 SCUTTLEBOT_TOKEN
32 SCUTTLEBOT_CHANNEL
33 SCUTTLEBOT_CHANNELS
34 SCUTTLEBOT_TRANSPORT
35 SCUTTLEBOT_IRC_ADDR
36 SCUTTLEBOT_IRC_PASS
37 SCUTTLEBOT_HOOKS_ENABLED
38 SCUTTLEBOT_INTERRUPT_ON_MESSAGE
@@ -54,10 +56,11 @@
56 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
57
58 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
59 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
60 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
61 SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}"
62 SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}"
63 SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
64 if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
65 SCUTTLEBOT_IRC_PASS_MODE="fixed"
66 SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -88,10 +91,14 @@
91 shift 2
92 ;;
93 --channel)
94 SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
95 shift 2
96 ;;
97 --channels)
98 SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}"
99 shift 2
100 ;;
101 --transport)
102 SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
103 shift 2
104 ;;
@@ -164,10 +171,52 @@
171 }
172
173 ensure_parent_dir() {
174 mkdir -p "$(dirname "$1")"
175 }
176
177 normalize_channels() {
178 local primary="$1"
179 local raw="$2"
180 local IFS=','
181 local items=()
182 local extra_items=()
183 local item channel seen=""
184
185 if [ -n "$primary" ]; then
186 items+=("$primary")
187 fi
188 if [ -n "$raw" ]; then
189 read -r -a extra_items <<< "$raw"
190 items+=("${extra_items[@]}")
191 fi
192
193 for item in "${items[@]}"; do
194 channel="${item//[$' \t\r\n']/}"
195 channel="${channel#\#}"
196 [ -n "$channel" ] || continue
197 case ",$seen," in
198 *,"$channel",*) ;;
199 *) seen="${seen:+$seen,}$channel" ;;
200 esac
201 done
202
203 printf '%s' "$seen"
204 }
205
206 first_channel() {
207 local channels
208 channels=$(normalize_channels "" "$1")
209 printf '%s' "${channels%%,*}"
210 }
211
212 if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
213 SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")"
214 fi
215 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
216 SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")"
217 fi
218
219 upsert_env_var() {
220 local file="$1"
221 local key="$2"
222 local value="$3"
@@ -349,10 +398,13 @@
398 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
399 fi
400 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
401 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
402 fi
403 if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
404 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE"
405 fi
406 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
407 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
408 if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
409 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
410 else
411
--- skills/scuttlebot-relay/ADDING_AGENTS.md
+++ skills/scuttlebot-relay/ADDING_AGENTS.md
@@ -83,10 +83,11 @@
8383
8484
Every terminal broker should follow these conventions:
8585
- one stable nick per live session: `{runtime}-{basename}-{session}`
8686
- one shared env contract using `SCUTTLEBOT_*`
8787
- installer default is auto-registration: leave `SCUTTLEBOT_IRC_PASS` unset and remove stale fixed-pass values unless the operator explicitly requests a fixed identity
88
+- one primary control channel plus optional joined work channels
8889
- one broker process owning `online` / `offline`
8990
- one broker process owning continuous addressed operator input injection
9091
- one broker process owning outbound activity and assistant-message mirroring when the runtime exposes a reliable event/session stream
9192
- hooks used for pre-action fallback and for runtime-specific gaps such as post-tool summaries or final reply hooks
9293
- support both `SCUTTLEBOT_TRANSPORT=http` and `SCUTTLEBOT_TRANSPORT=irc` behind the same broker contract
@@ -96,10 +97,11 @@
9697
9798
All adapters should use the same environment variables:
9899
- `SCUTTLEBOT_URL`
99100
- `SCUTTLEBOT_TOKEN`
100101
- `SCUTTLEBOT_CHANNEL`
102
+- `SCUTTLEBOT_CHANNELS`
101103
- `SCUTTLEBOT_TRANSPORT`
102104
103105
Optional:
104106
- `SCUTTLEBOT_NICK`
105107
- `SCUTTLEBOT_SESSION_ID`
@@ -108,15 +110,21 @@
108110
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
109111
- `SCUTTLEBOT_HOOKS_ENABLED`
110112
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE`
111113
- `SCUTTLEBOT_POLL_INTERVAL`
112114
- `SCUTTLEBOT_PRESENCE_HEARTBEAT`
115
+- `SCUTTLEBOT_CHANNEL_STATE_FILE`
113116
114117
Do not hardcode tokens into repo scripts.
115118
For terminal-session brokers, treat `SCUTTLEBOT_IRC_PASS` as an explicit
116119
fixed-identity override, not a default.
117120
121
+Channel semantics:
122
+- `SCUTTLEBOT_CHANNEL` is the primary control channel
123
+- `SCUTTLEBOT_CHANNELS` is the startup channel set and should include the control channel
124
+- runtime `/join`, `/part`, and `/channels` commands may change the live channel set for one session without rewriting the shared env file
125
+
118126
## Nicking rules
119127
120128
Use a stable, human-addressable session nick.
121129
122130
Requirements:
@@ -146,15 +154,16 @@
146154
## State scoping
147155
148156
Do not use one global timestamp file.
149157
150158
Track last-seen state by a key derived from:
151
-- channel
152159
- nick
153160
- working directory
154161
155
-That prevents parallel sessions from consuming each other's instructions.
162
+That prevents parallel sessions from consuming each other's instructions while
163
+still allowing one session to join or part channels without losing its check
164
+state.
156165
157166
## HTTP API contract
158167
159168
All adapters use the same scuttlebot HTTP API:
160169
161170
--- skills/scuttlebot-relay/ADDING_AGENTS.md
+++ skills/scuttlebot-relay/ADDING_AGENTS.md
@@ -83,10 +83,11 @@
83
84 Every terminal broker should follow these conventions:
85 - one stable nick per live session: `{runtime}-{basename}-{session}`
86 - one shared env contract using `SCUTTLEBOT_*`
87 - installer default is auto-registration: leave `SCUTTLEBOT_IRC_PASS` unset and remove stale fixed-pass values unless the operator explicitly requests a fixed identity
 
88 - one broker process owning `online` / `offline`
89 - one broker process owning continuous addressed operator input injection
90 - one broker process owning outbound activity and assistant-message mirroring when the runtime exposes a reliable event/session stream
91 - hooks used for pre-action fallback and for runtime-specific gaps such as post-tool summaries or final reply hooks
92 - support both `SCUTTLEBOT_TRANSPORT=http` and `SCUTTLEBOT_TRANSPORT=irc` behind the same broker contract
@@ -96,10 +97,11 @@
96
97 All adapters should use the same environment variables:
98 - `SCUTTLEBOT_URL`
99 - `SCUTTLEBOT_TOKEN`
100 - `SCUTTLEBOT_CHANNEL`
 
101 - `SCUTTLEBOT_TRANSPORT`
102
103 Optional:
104 - `SCUTTLEBOT_NICK`
105 - `SCUTTLEBOT_SESSION_ID`
@@ -108,15 +110,21 @@
108 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
109 - `SCUTTLEBOT_HOOKS_ENABLED`
110 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE`
111 - `SCUTTLEBOT_POLL_INTERVAL`
112 - `SCUTTLEBOT_PRESENCE_HEARTBEAT`
 
113
114 Do not hardcode tokens into repo scripts.
115 For terminal-session brokers, treat `SCUTTLEBOT_IRC_PASS` as an explicit
116 fixed-identity override, not a default.
117
 
 
 
 
 
118 ## Nicking rules
119
120 Use a stable, human-addressable session nick.
121
122 Requirements:
@@ -146,15 +154,16 @@
146 ## State scoping
147
148 Do not use one global timestamp file.
149
150 Track last-seen state by a key derived from:
151 - channel
152 - nick
153 - working directory
154
155 That prevents parallel sessions from consuming each other's instructions.
 
 
156
157 ## HTTP API contract
158
159 All adapters use the same scuttlebot HTTP API:
160
161
--- skills/scuttlebot-relay/ADDING_AGENTS.md
+++ skills/scuttlebot-relay/ADDING_AGENTS.md
@@ -83,10 +83,11 @@
83
84 Every terminal broker should follow these conventions:
85 - one stable nick per live session: `{runtime}-{basename}-{session}`
86 - one shared env contract using `SCUTTLEBOT_*`
87 - installer default is auto-registration: leave `SCUTTLEBOT_IRC_PASS` unset and remove stale fixed-pass values unless the operator explicitly requests a fixed identity
88 - one primary control channel plus optional joined work channels
89 - one broker process owning `online` / `offline`
90 - one broker process owning continuous addressed operator input injection
91 - one broker process owning outbound activity and assistant-message mirroring when the runtime exposes a reliable event/session stream
92 - hooks used for pre-action fallback and for runtime-specific gaps such as post-tool summaries or final reply hooks
93 - support both `SCUTTLEBOT_TRANSPORT=http` and `SCUTTLEBOT_TRANSPORT=irc` behind the same broker contract
@@ -96,10 +97,11 @@
97
98 All adapters should use the same environment variables:
99 - `SCUTTLEBOT_URL`
100 - `SCUTTLEBOT_TOKEN`
101 - `SCUTTLEBOT_CHANNEL`
102 - `SCUTTLEBOT_CHANNELS`
103 - `SCUTTLEBOT_TRANSPORT`
104
105 Optional:
106 - `SCUTTLEBOT_NICK`
107 - `SCUTTLEBOT_SESSION_ID`
@@ -108,15 +110,21 @@
110 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
111 - `SCUTTLEBOT_HOOKS_ENABLED`
112 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE`
113 - `SCUTTLEBOT_POLL_INTERVAL`
114 - `SCUTTLEBOT_PRESENCE_HEARTBEAT`
115 - `SCUTTLEBOT_CHANNEL_STATE_FILE`
116
117 Do not hardcode tokens into repo scripts.
118 For terminal-session brokers, treat `SCUTTLEBOT_IRC_PASS` as an explicit
119 fixed-identity override, not a default.
120
121 Channel semantics:
122 - `SCUTTLEBOT_CHANNEL` is the primary control channel
123 - `SCUTTLEBOT_CHANNELS` is the startup channel set and should include the control channel
124 - runtime `/join`, `/part`, and `/channels` commands may change the live channel set for one session without rewriting the shared env file
125
126 ## Nicking rules
127
128 Use a stable, human-addressable session nick.
129
130 Requirements:
@@ -146,15 +154,16 @@
154 ## State scoping
155
156 Do not use one global timestamp file.
157
158 Track last-seen state by a key derived from:
 
159 - nick
160 - working directory
161
162 That prevents parallel sessions from consuming each other's instructions while
163 still allowing one session to join or part channels without losing its check
164 state.
165
166 ## HTTP API contract
167
168 All adapters use the same scuttlebot HTTP API:
169
170
--- skills/scuttlebot-relay/FLEET.md
+++ skills/scuttlebot-relay/FLEET.md
@@ -101,10 +101,12 @@
101101
- force a fixed nick across sessions
102102
- require IRC to be up at install time
103103
104104
Useful shared env knobs:
105105
- `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
106
+- `SCUTTLEBOT_CHANNEL` is the primary control channel
107
+- `SCUTTLEBOT_CHANNELS=general,task-42` seeds extra startup work channels
106108
- `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
107109
- `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
108110
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
109111
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Claude session when it appears busy
110112
- `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
@@ -112,10 +114,15 @@
112114
- `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
113115
114116
Installer auth knobs:
115117
- default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
116118
- `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
119
+
120
+Live channel commands:
121
+- `/channels`
122
+- `/join #task-42`
123
+- `/part #task-42`
117124
118125
## Operator workflow
119126
120127
1. Watch the configured channel in scuttlebot.
121128
2. Wait for a new `claude-{repo}-{session}` online post.
122129
123130
ADDED skills/scuttlebot-relay/SKILL.md
124131
ADDED skills/scuttlebot-relay/agents/openai.yaml
--- skills/scuttlebot-relay/FLEET.md
+++ skills/scuttlebot-relay/FLEET.md
@@ -101,10 +101,12 @@
101 - force a fixed nick across sessions
102 - require IRC to be up at install time
103
104 Useful shared env knobs:
105 - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
 
 
106 - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc`
107 - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention
108 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
109 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Claude session when it appears busy
110 - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
@@ -112,10 +114,15 @@
112 - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
113
114 Installer auth knobs:
115 - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
116 - `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
 
 
 
 
 
117
118 ## Operator workflow
119
120 1. Watch the configured channel in scuttlebot.
121 2. Wait for a new `claude-{repo}-{session}` online post.
122
123 DDED skills/scuttlebot-relay/SKILL.md
124 DDED skills/scuttlebot-relay/agents/openai.yaml
--- skills/scuttlebot-relay/FLEET.md
+++ skills/scuttlebot-relay/FLEET.md
@@ -101,10 +101,12 @@
101 - force a fixed nick across sessions
102 - require IRC to be up at install time
103
104 Useful shared env knobs:
105 - `SCUTTLEBOT_TRANSPORT=http|irc` selects the connector backend
106 - `SCUTTLEBOT_CHANNEL` is the primary control channel
107 - `SCUTTLEBOT_CHANNELS=general,task-42` seeds extra startup work channels
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; leave it unset for the default broker convention
110 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit
111 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Claude session when it appears busy
112 - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages
@@ -112,10 +114,15 @@
114 - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts
115
116 Installer auth knobs:
117 - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks
118 - `--irc-pass <passphrase>`: persist a fixed NickServ password in the shared env file
119
120 Live channel commands:
121 - `/channels`
122 - `/join #task-42`
123 - `/part #task-42`
124
125 ## Operator workflow
126
127 1. Watch the configured channel in scuttlebot.
128 2. Wait for a new `claude-{repo}-{session}` online post.
129
130 DDED skills/scuttlebot-relay/SKILL.md
131 DDED skills/scuttlebot-relay/agents/openai.yaml
--- a/skills/scuttlebot-relay/SKILL.md
+++ b/skills/scuttlebot-relay/SKILL.md
@@ -0,0 +1,75 @@
1
+---
2
+name: scuttleboannel control:
3
+
4
+- `/channels`
5
+- `/join #channel`
6
+- `/part #channel`
7
+
8
+Use the control channel for operator coordination. Join extra work channels only
9
+when the session needs to mirror activity there too.
10
+
11
+## Connection health and reconnection
12
+
13
+All three relay binaries (`claude-relay`, `codex-relay`, `gemini-relay`) handle
14
+`SIGUSR1` as a reconnect signal. When the relay receives `SIGUSR1` it tears down
15
+its current IRC/HTTP session and re-establishes the connection from scratch
16
+without restarting the process.
17
+
18
+The `relay-watchdog` sidecar automates this:
19
+
20
+- Reads `~/.config/scuttlebot-relay.env` (same env file the relays use).
21
+- Polls `$SCUTTLEBOT_URL/v1/status` every 10 seconds.
22
+- Detects server restarts (changed boot ID) and extended outages.
23
+- Sends `SIGUSR1` to the relay process when a reconnect is needed.
24
+
25
+Run the watchdog alongside any relay:
26
+
27
+```bash
28
+relay-watchdog &
29
+claude-relay "$@"
30
+```
31
+
32
+Or use the convenience wrapper:
33
+
34
+```bash
35
+skills/scuttlebot-relay/scripts/relay-start.sh claude-relay [args...]
36
+```
37
+
38
+Container / fleet pattern: have the entrypoint run both processes, or use
39
+supervisord. The watchdog exits cleanly when its parent relay exits.
40
+
41
+## Per-repo channel config
42
+
43
+Drop a `.scuttlebot.yaml` in a repo root (gitignored) to override channel
44
+settings per project:
45
+
46
+```yaml
47
+# .scuttlebot.yaml
48
+channel: my-project # auto-joins this as the control channel
49
+channels: # additional channels joined at startup
50
+ - my-project
51
+ - design-review
52
+```
53
+
54
+`channel` sets the primary control channel for the session (equivalent to
55
+`SCUTTLEBOT_CHANNEL`). The optional `channels` list adds extra work channels
56
+(equivalent to `SCUTTLEBOT_CHANNELS`). Values in the file override the
57
+environment for that repo only.
58
+
59
+## Transport conventions
60
+
61
+Use one broker contract for both transport modes:
62
+
63
+- `SCUTTLEBOT_TRANSPORT=irc`
64
+ - real IRC presence
65
+ - real channel join/part semantics
66
+ - appears in the user list and agent roster through auto-registration
67
+- `SCUTTLEBOT_TRANSPORT=http`
68
+ - bridge/API transport
69
+ - uses silent presence touches instead of visible chatter
70
+ - useful when a direct IRC socket is not available
71
+
72
+Default auth convention:
73
+
74
+- broker sessions: auto-register ephemeral session nicks
75
+- persistent `*-agent
--- a/skills/scuttlebot-relay/SKILL.md
+++ b/skills/scuttlebot-relay/SKILL.md
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/skills/scuttlebot-relay/SKILL.md
+++ b/skills/scuttlebot-relay/SKILL.md
@@ -0,0 +1,75 @@
1 ---
2 name: scuttleboannel control:
3
4 - `/channels`
5 - `/join #channel`
6 - `/part #channel`
7
8 Use the control channel for operator coordination. Join extra work channels only
9 when the session needs to mirror activity there too.
10
11 ## Connection health and reconnection
12
13 All three relay binaries (`claude-relay`, `codex-relay`, `gemini-relay`) handle
14 `SIGUSR1` as a reconnect signal. When the relay receives `SIGUSR1` it tears down
15 its current IRC/HTTP session and re-establishes the connection from scratch
16 without restarting the process.
17
18 The `relay-watchdog` sidecar automates this:
19
20 - Reads `~/.config/scuttlebot-relay.env` (same env file the relays use).
21 - Polls `$SCUTTLEBOT_URL/v1/status` every 10 seconds.
22 - Detects server restarts (changed boot ID) and extended outages.
23 - Sends `SIGUSR1` to the relay process when a reconnect is needed.
24
25 Run the watchdog alongside any relay:
26
27 ```bash
28 relay-watchdog &
29 claude-relay "$@"
30 ```
31
32 Or use the convenience wrapper:
33
34 ```bash
35 skills/scuttlebot-relay/scripts/relay-start.sh claude-relay [args...]
36 ```
37
38 Container / fleet pattern: have the entrypoint run both processes, or use
39 supervisord. The watchdog exits cleanly when its parent relay exits.
40
41 ## Per-repo channel config
42
43 Drop a `.scuttlebot.yaml` in a repo root (gitignored) to override channel
44 settings per project:
45
46 ```yaml
47 # .scuttlebot.yaml
48 channel: my-project # auto-joins this as the control channel
49 channels: # additional channels joined at startup
50 - my-project
51 - design-review
52 ```
53
54 `channel` sets the primary control channel for the session (equivalent to
55 `SCUTTLEBOT_CHANNEL`). The optional `channels` list adds extra work channels
56 (equivalent to `SCUTTLEBOT_CHANNELS`). Values in the file override the
57 environment for that repo only.
58
59 ## Transport conventions
60
61 Use one broker contract for both transport modes:
62
63 - `SCUTTLEBOT_TRANSPORT=irc`
64 - real IRC presence
65 - real channel join/part semantics
66 - appears in the user list and agent roster through auto-registration
67 - `SCUTTLEBOT_TRANSPORT=http`
68 - bridge/API transport
69 - uses silent presence touches instead of visible chatter
70 - useful when a direct IRC socket is not available
71
72 Default auth convention:
73
74 - broker sessions: auto-register ephemeral session nicks
75 - persistent `*-agent
--- a/skills/scuttlebot-relay/agents/openai.yaml
+++ b/skills/scuttlebot-relay/agents/openai.yaml
@@ -0,0 +1,4 @@
1
+interface:
2
+ display_name: "Scuttlebot Relay"
3
+ short_description: "Install and configure relay brokers for Claude, Codex, and Gemini"
4
+ default_prompt: "Use $scuttlebot-relay to install, configure, or extend the shared scuttlebot relay brokers. Prefer the tracked installer scripts, the shared SCUTTLEBOT_* env contract, IRC auto-registration by default, and the canonical broker conventions in skills/scuttlebot-relay/ADDING_AGENTS.md."
--- a/skills/scuttlebot-relay/agents/openai.yaml
+++ b/skills/scuttlebot-relay/agents/openai.yaml
@@ -0,0 +1,4 @@
 
 
 
 
--- a/skills/scuttlebot-relay/agents/openai.yaml
+++ b/skills/scuttlebot-relay/agents/openai.yaml
@@ -0,0 +1,4 @@
1 interface:
2 display_name: "Scuttlebot Relay"
3 short_description: "Install and configure relay brokers for Claude, Codex, and Gemini"
4 default_prompt: "Use $scuttlebot-relay to install, configure, or extend the shared scuttlebot relay brokers. Prefer the tracked installer scripts, the shared SCUTTLEBOT_* env contract, IRC auto-registration by default, and the canonical broker conventions in skills/scuttlebot-relay/ADDING_AGENTS.md."
--- skills/scuttlebot-relay/hooks/README.md
+++ skills/scuttlebot-relay/hooks/README.md
@@ -70,10 +70,12 @@
7070
- `SCUTTLEBOT_TOKEN`
7171
- `SCUTTLEBOT_CHANNEL`
7272
7373
Optional:
7474
- `SCUTTLEBOT_NICK`
75
+- `SCUTTLEBOT_CHANNELS`
76
+- `SCUTTLEBOT_CHANNEL_STATE_FILE`
7577
- `SCUTTLEBOT_TRANSPORT`
7678
- `SCUTTLEBOT_IRC_ADDR`
7779
- `SCUTTLEBOT_IRC_PASS`
7880
- `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
7981
- `SCUTTLEBOT_HOOKS_ENABLED`
@@ -87,19 +89,21 @@
8789
8890
```bash
8991
export SCUTTLEBOT_URL=http://localhost:8080
9092
export SCUTTLEBOT_TOKEN=$(./run.sh token)
9193
export SCUTTLEBOT_CHANNEL=general
94
+export SCUTTLEBOT_CHANNELS=general,task-42
9295
```
9396
9497
The hooks also auto-load a shared relay env file if it exists:
9598
9699
```bash
97100
cat > ~/.config/scuttlebot-relay.env <<'EOF'
98101
SCUTTLEBOT_URL=http://localhost:8080
99102
SCUTTLEBOT_TOKEN=...
100103
SCUTTLEBOT_CHANNEL=general
104
+SCUTTLEBOT_CHANNELS=general
101105
SCUTTLEBOT_TRANSPORT=irc
102106
SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
103107
SCUTTLEBOT_HOOKS_ENABLED=1
104108
SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
105109
SCUTTLEBOT_POLL_INTERVAL=2s
@@ -123,11 +127,12 @@
123127
124128
```bash
125129
bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \
126130
--url http://localhost:8080 \
127131
--token "$(./run.sh token)" \
128
- --channel general
132
+ --channel general \
133
+ --channels general,task-42
129134
```
130135
131136
Manual path:
132137
133138
```bash
@@ -190,15 +195,18 @@
190195
glengoolie: someone should inspect the schema
191196
claude-otherrepo-e5f6a7b8: read config.go
192197
```
193198
194199
The last-check timestamp is stored in a session-scoped file under `/tmp`, keyed by:
195
-- channel
196200
- nick
197201
- working directory
198202
199
-That prevents one Claude session from consuming another session's instructions.
203
+That prevents one Claude session from consuming another session's instructions
204
+while still allowing the broker to join or part work channels.
205
+
206
+`SCUTTLEBOT_CHANNEL_STATE_FILE` is the broker-written override file that keeps
207
+the hooks aligned with live `/join` and `/part` changes.
200208
201209
## Smoke test
202210
203211
Use the matching commands from `skills/scuttlebot-relay/install.md`, replacing the
204212
nick in the operator message with your Claude session nick.
205213
--- skills/scuttlebot-relay/hooks/README.md
+++ skills/scuttlebot-relay/hooks/README.md
@@ -70,10 +70,12 @@
70 - `SCUTTLEBOT_TOKEN`
71 - `SCUTTLEBOT_CHANNEL`
72
73 Optional:
74 - `SCUTTLEBOT_NICK`
 
 
75 - `SCUTTLEBOT_TRANSPORT`
76 - `SCUTTLEBOT_IRC_ADDR`
77 - `SCUTTLEBOT_IRC_PASS`
78 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
79 - `SCUTTLEBOT_HOOKS_ENABLED`
@@ -87,19 +89,21 @@
87
88 ```bash
89 export SCUTTLEBOT_URL=http://localhost:8080
90 export SCUTTLEBOT_TOKEN=$(./run.sh token)
91 export SCUTTLEBOT_CHANNEL=general
 
92 ```
93
94 The hooks also auto-load a shared relay env file if it exists:
95
96 ```bash
97 cat > ~/.config/scuttlebot-relay.env <<'EOF'
98 SCUTTLEBOT_URL=http://localhost:8080
99 SCUTTLEBOT_TOKEN=...
100 SCUTTLEBOT_CHANNEL=general
 
101 SCUTTLEBOT_TRANSPORT=irc
102 SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
103 SCUTTLEBOT_HOOKS_ENABLED=1
104 SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
105 SCUTTLEBOT_POLL_INTERVAL=2s
@@ -123,11 +127,12 @@
123
124 ```bash
125 bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \
126 --url http://localhost:8080 \
127 --token "$(./run.sh token)" \
128 --channel general
 
129 ```
130
131 Manual path:
132
133 ```bash
@@ -190,15 +195,18 @@
190 glengoolie: someone should inspect the schema
191 claude-otherrepo-e5f6a7b8: read config.go
192 ```
193
194 The last-check timestamp is stored in a session-scoped file under `/tmp`, keyed by:
195 - channel
196 - nick
197 - working directory
198
199 That prevents one Claude session from consuming another session's instructions.
 
 
 
 
200
201 ## Smoke test
202
203 Use the matching commands from `skills/scuttlebot-relay/install.md`, replacing the
204 nick in the operator message with your Claude session nick.
205
--- skills/scuttlebot-relay/hooks/README.md
+++ skills/scuttlebot-relay/hooks/README.md
@@ -70,10 +70,12 @@
70 - `SCUTTLEBOT_TOKEN`
71 - `SCUTTLEBOT_CHANNEL`
72
73 Optional:
74 - `SCUTTLEBOT_NICK`
75 - `SCUTTLEBOT_CHANNELS`
76 - `SCUTTLEBOT_CHANNEL_STATE_FILE`
77 - `SCUTTLEBOT_TRANSPORT`
78 - `SCUTTLEBOT_IRC_ADDR`
79 - `SCUTTLEBOT_IRC_PASS`
80 - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE`
81 - `SCUTTLEBOT_HOOKS_ENABLED`
@@ -87,19 +89,21 @@
89
90 ```bash
91 export SCUTTLEBOT_URL=http://localhost:8080
92 export SCUTTLEBOT_TOKEN=$(./run.sh token)
93 export SCUTTLEBOT_CHANNEL=general
94 export SCUTTLEBOT_CHANNELS=general,task-42
95 ```
96
97 The hooks also auto-load a shared relay env file if it exists:
98
99 ```bash
100 cat > ~/.config/scuttlebot-relay.env <<'EOF'
101 SCUTTLEBOT_URL=http://localhost:8080
102 SCUTTLEBOT_TOKEN=...
103 SCUTTLEBOT_CHANNEL=general
104 SCUTTLEBOT_CHANNELS=general
105 SCUTTLEBOT_TRANSPORT=irc
106 SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667
107 SCUTTLEBOT_HOOKS_ENABLED=1
108 SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1
109 SCUTTLEBOT_POLL_INTERVAL=2s
@@ -123,11 +127,12 @@
127
128 ```bash
129 bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \
130 --url http://localhost:8080 \
131 --token "$(./run.sh token)" \
132 --channel general \
133 --channels general,task-42
134 ```
135
136 Manual path:
137
138 ```bash
@@ -190,15 +195,18 @@
195 glengoolie: someone should inspect the schema
196 claude-otherrepo-e5f6a7b8: read config.go
197 ```
198
199 The last-check timestamp is stored in a session-scoped file under `/tmp`, keyed by:
 
200 - nick
201 - working directory
202
203 That prevents one Claude session from consuming another session's instructions
204 while still allowing the broker to join or part work channels.
205
206 `SCUTTLEBOT_CHANNEL_STATE_FILE` is the broker-written override file that keeps
207 the hooks aligned with live `/join` and `/part` changes.
208
209 ## Smoke test
210
211 Use the matching commands from `skills/scuttlebot-relay/install.md`, replacing the
212 nick in the operator message with your Claude session nick.
213
--- skills/scuttlebot-relay/hooks/scuttlebot-check.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-check.sh
@@ -7,10 +7,15 @@
77
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
88
set -a
99
. "$SCUTTLEBOT_CONFIG_FILE"
1010
set +a
1111
fi
12
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
13
+ set -a
14
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
15
+ set +a
16
+fi
1217
1318
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1419
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1520
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1621
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
@@ -19,10 +24,49 @@
1924
session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null | head -c 8)
2025
2126
sanitize() {
2227
printf '%s' "$1" | tr -cs '[:alnum:]_-' '-'
2328
}
29
+
30
+normalize_channel() {
31
+ local channel="$1"
32
+ channel="${channel//[$' \t\r\n']/}"
33
+ channel="${channel#\#}"
34
+ printf '%s' "$channel"
35
+}
36
+
37
+relay_channels() {
38
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
39
+ local IFS=','
40
+ local item channel seen=""
41
+ read -r -a items <<< "$raw"
42
+ for item in "${items[@]}"; do
43
+ channel=$(normalize_channel "$item")
44
+ [ -n "$channel" ] || continue
45
+ case ",$seen," in
46
+ *,"$channel",*) ;;
47
+ *)
48
+ seen="${seen:+$seen,}$channel"
49
+ printf '%s\n' "$channel"
50
+ ;;
51
+ esac
52
+ done
53
+}
54
+
55
+contains_mention() {
56
+ local text="$1"
57
+ [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
58
+}
59
+
60
+epoch_seconds() {
61
+ local at="$1"
62
+ local ts_clean ts
63
+ ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
64
+ ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
65
+ date -d "$ts_clean" "+%s" 2>/dev/null)
66
+ printf '%s' "$ts"
67
+}
2468
2569
cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null)
2670
if [ -z "$cwd" ]; then cwd=$(pwd); fi
2771
base_name=$(sanitize "$(basename "$cwd")")
2872
session_suffix="${session_id:-$PPID}"
@@ -31,56 +75,49 @@
3175
3276
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0
3377
[ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0
3478
[ -z "$SCUTTLEBOT_TOKEN" ] && exit 0
3579
36
-state_key=$(printf '%s' "$SCUTTLEBOT_CHANNEL|$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
80
+state_key=$(printf '%s' "$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
3781
LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
3882
39
-contains_mention() {
40
- local text="$1"
41
- [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
42
-}
43
-
4483
last_check=0
4584
if [ -f "$LAST_CHECK_FILE" ]; then
4685
last_check=$(cat "$LAST_CHECK_FILE")
4786
fi
4887
now=$(date +%s)
4988
echo "$now" > "$LAST_CHECK_FILE"
5089
51
-messages=$(curl -sf \
52
- --connect-timeout 1 \
53
- --max-time 2 \
54
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
55
- "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
56
-
57
-[ -z "$messages" ] && exit 0
58
-
5990
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
6091
6192
instruction=$(
62
- echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
63
- .messages[]
64
- | select(.nick as $n |
65
- ($bots | index($n) | not) and
66
- ($n | startswith("claude-") | not) and
67
- ($n | startswith("codex-") | not) and
68
- ($n | startswith("gemini-") | not) and
69
- $n != $self
70
- )
71
- | "\(.at)\t\(.nick)\t\(.text)"
72
- ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
73
- ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
74
- ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
75
- date -d "$ts_clean" "+%s" 2>/dev/null)
93
+ for channel in $(relay_channels); do
94
+ messages=$(curl -sf \
95
+ --connect-timeout 1 \
96
+ --max-time 2 \
97
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
98
+ "$SCUTTLEBOT_URL/v1/channels/$channel/messages" 2>/dev/null) || continue
99
+ [ -n "$messages" ] || continue
100
+ echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" --arg channel "$channel" '
101
+ .messages[]
102
+ | select(.nick as $n |
103
+ ($bots | index($n) | not) and
104
+ ($n | startswith("claude-") | not) and
105
+ ($n | startswith("codex-") | not) and
106
+ ($n | startswith("gemini-") | not) and
107
+ $n != $self
108
+ )
109
+ | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
110
+ ' 2>/dev/null
111
+ done | while IFS=$'\t' read -r at channel nick text; do
112
+ ts=$(epoch_seconds "$at")
76113
[ -n "$ts" ] || continue
77114
[ "$ts" -gt "$last_check" ] || continue
78115
contains_mention "$text" || continue
79
- echo "$nick: $text"
80
- done | tail -1
116
+ printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
117
+ done | sort -n | tail -1 | cut -f2-
81118
)
82119
83120
[ -z "$instruction" ] && exit 0
84121
85122
echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
86123
exit 0
87124
--- skills/scuttlebot-relay/hooks/scuttlebot-check.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-check.sh
@@ -7,10 +7,15 @@
7 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
8 set -a
9 . "$SCUTTLEBOT_CONFIG_FILE"
10 set +a
11 fi
 
 
 
 
 
12
13 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
14 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
15 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
16 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
@@ -19,10 +24,49 @@
19 session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null | head -c 8)
20
21 sanitize() {
22 printf '%s' "$1" | tr -cs '[:alnum:]_-' '-'
23 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
25 cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null)
26 if [ -z "$cwd" ]; then cwd=$(pwd); fi
27 base_name=$(sanitize "$(basename "$cwd")")
28 session_suffix="${session_id:-$PPID}"
@@ -31,56 +75,49 @@
31
32 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0
33 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0
34 [ -z "$SCUTTLEBOT_TOKEN" ] && exit 0
35
36 state_key=$(printf '%s' "$SCUTTLEBOT_CHANNEL|$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
37 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
38
39 contains_mention() {
40 local text="$1"
41 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
42 }
43
44 last_check=0
45 if [ -f "$LAST_CHECK_FILE" ]; then
46 last_check=$(cat "$LAST_CHECK_FILE")
47 fi
48 now=$(date +%s)
49 echo "$now" > "$LAST_CHECK_FILE"
50
51 messages=$(curl -sf \
52 --connect-timeout 1 \
53 --max-time 2 \
54 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
55 "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
56
57 [ -z "$messages" ] && exit 0
58
59 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
60
61 instruction=$(
62 echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
63 .messages[]
64 | select(.nick as $n |
65 ($bots | index($n) | not) and
66 ($n | startswith("claude-") | not) and
67 ($n | startswith("codex-") | not) and
68 ($n | startswith("gemini-") | not) and
69 $n != $self
70 )
71 | "\(.at)\t\(.nick)\t\(.text)"
72 ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
73 ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
74 ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
75 date -d "$ts_clean" "+%s" 2>/dev/null)
 
 
 
 
 
 
76 [ -n "$ts" ] || continue
77 [ "$ts" -gt "$last_check" ] || continue
78 contains_mention "$text" || continue
79 echo "$nick: $text"
80 done | tail -1
81 )
82
83 [ -z "$instruction" ] && exit 0
84
85 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
86 exit 0
87
--- skills/scuttlebot-relay/hooks/scuttlebot-check.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-check.sh
@@ -7,10 +7,15 @@
7 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
8 set -a
9 . "$SCUTTLEBOT_CONFIG_FILE"
10 set +a
11 fi
12 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
13 set -a
14 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
15 set +a
16 fi
17
18 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
19 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
20 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
21 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
@@ -19,10 +24,49 @@
24 session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null | head -c 8)
25
26 sanitize() {
27 printf '%s' "$1" | tr -cs '[:alnum:]_-' '-'
28 }
29
30 normalize_channel() {
31 local channel="$1"
32 channel="${channel//[$' \t\r\n']/}"
33 channel="${channel#\#}"
34 printf '%s' "$channel"
35 }
36
37 relay_channels() {
38 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
39 local IFS=','
40 local item channel seen=""
41 read -r -a items <<< "$raw"
42 for item in "${items[@]}"; do
43 channel=$(normalize_channel "$item")
44 [ -n "$channel" ] || continue
45 case ",$seen," in
46 *,"$channel",*) ;;
47 *)
48 seen="${seen:+$seen,}$channel"
49 printf '%s\n' "$channel"
50 ;;
51 esac
52 done
53 }
54
55 contains_mention() {
56 local text="$1"
57 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
58 }
59
60 epoch_seconds() {
61 local at="$1"
62 local ts_clean ts
63 ts_clean=$(echo "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
64 ts=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" "+%s" 2>/dev/null || \
65 date -d "$ts_clean" "+%s" 2>/dev/null)
66 printf '%s' "$ts"
67 }
68
69 cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null)
70 if [ -z "$cwd" ]; then cwd=$(pwd); fi
71 base_name=$(sanitize "$(basename "$cwd")")
72 session_suffix="${session_id:-$PPID}"
@@ -31,56 +75,49 @@
75
76 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0
77 [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0
78 [ -z "$SCUTTLEBOT_TOKEN" ] && exit 0
79
80 state_key=$(printf '%s' "$SCUTTLEBOT_NICK|$(pwd)" | cksum | awk '{print $1}')
81 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
82
 
 
 
 
 
83 last_check=0
84 if [ -f "$LAST_CHECK_FILE" ]; then
85 last_check=$(cat "$LAST_CHECK_FILE")
86 fi
87 now=$(date +%s)
88 echo "$now" > "$LAST_CHECK_FILE"
89
 
 
 
 
 
 
 
 
90 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
91
92 instruction=$(
93 for channel in $(relay_channels); do
94 messages=$(curl -sf \
95 --connect-timeout 1 \
96 --max-time 2 \
97 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
98 "$SCUTTLEBOT_URL/v1/channels/$channel/messages" 2>/dev/null) || continue
99 [ -n "$messages" ] || continue
100 echo "$messages" | jq -r --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" --arg channel "$channel" '
101 .messages[]
102 | select(.nick as $n |
103 ($bots | index($n) | not) and
104 ($n | startswith("claude-") | not) and
105 ($n | startswith("codex-") | not) and
106 ($n | startswith("gemini-") | not) and
107 $n != $self
108 )
109 | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
110 ' 2>/dev/null
111 done | while IFS=$'\t' read -r at channel nick text; do
112 ts=$(epoch_seconds "$at")
113 [ -n "$ts" ] || continue
114 [ "$ts" -gt "$last_check" ] || continue
115 contains_mention "$text" || continue
116 printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
117 done | sort -n | tail -1 | cut -f2-
118 )
119
120 [ -z "$instruction" ] && exit 0
121
122 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
123 exit 0
124
--- skills/scuttlebot-relay/hooks/scuttlebot-post.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-post.sh
@@ -1,21 +1,65 @@
11
#!/bin/bash
2
-# PostToolUse hook — posts what Claude Code just did to scuttlebot IRC.
3
-# Reads Claude Code's JSON event from stdin.
2
+# PostToolUse hook — posts what Claude just did to scuttlebot IRC.
43
54
SCUTTLEBOT_CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
65
if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
76
set -a
87
. "$SCUTTLEBOT_CONFIG_FILE"
98
set +a
9
+fi
10
+if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11
+ set -a
12
+ . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13
+ set +a
1014
fi
1115
1216
SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
1317
SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
1418
SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
1519
SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
1620
SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
21
+
22
+normalize_channel() {
23
+ local channel="$1"
24
+ channel="${channel//[$' \t\r\n']/}"
25
+ channel="${channel#\#}"
26
+ printf '%s' "$channel"
27
+}
28
+
29
+relay_channels() {
30
+ local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
31
+ local IFS=','
32
+ local item channel seen=""
33
+ read -r -a items <<< "$raw"
34
+ for item in "${items[@]}"; do
35
+ channel=$(normalize_channel "$item")
36
+ [ -n "$channel" ] || continue
37
+ case ",$seen," in
38
+ *,"$channel",*) ;;
39
+ *)
40
+ seen="${seen:+$seen,}$channel"
41
+ printf '%s\n' "$channel"
42
+ ;;
43
+ esac
44
+ done
45
+}
46
+
47
+post_message() {
48
+ local text="$1"
49
+ local payload
50
+ payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
51
+ for channel in $(relay_channels); do
52
+ curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
53
+ --connect-timeout 1 \
54
+ --max-time 2 \
55
+ -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
56
+ -H "Content-Type: application/json" \
57
+ -d "$payload" \
58
+ > /dev/null || true
59
+ done
60
+}
1761
1862
input=$(cat)
1963
2064
tool=$(echo "$input" | jq -r '.tool_name // empty')
2165
cwd=$(echo "$input" | jq -r '.cwd // empty')
@@ -73,14 +117,7 @@
73117
;;
74118
esac
75119
76120
[ -z "$msg" ] && exit 0
77121
78
-curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
79
- --connect-timeout 1 \
80
- --max-time 2 \
81
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
82
- -H "Content-Type: application/json" \
83
- -d "{\"text\": $(echo "$msg" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
84
- > /dev/null
85
-
122
+post_message "$msg"
86123
exit 0
87124
--- skills/scuttlebot-relay/hooks/scuttlebot-post.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-post.sh
@@ -1,21 +1,65 @@
1 #!/bin/bash
2 # PostToolUse hook — posts what Claude Code just did to scuttlebot IRC.
3 # Reads Claude Code's JSON event from stdin.
4
5 SCUTTLEBOT_CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
6 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
7 set -a
8 . "$SCUTTLEBOT_CONFIG_FILE"
9 set +a
 
 
 
 
 
10 fi
11
12 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
13 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
14 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
15 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
16 SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
18 input=$(cat)
19
20 tool=$(echo "$input" | jq -r '.tool_name // empty')
21 cwd=$(echo "$input" | jq -r '.cwd // empty')
@@ -73,14 +117,7 @@
73 ;;
74 esac
75
76 [ -z "$msg" ] && exit 0
77
78 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
79 --connect-timeout 1 \
80 --max-time 2 \
81 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
82 -H "Content-Type: application/json" \
83 -d "{\"text\": $(echo "$msg" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
84 > /dev/null
85
86 exit 0
87
--- skills/scuttlebot-relay/hooks/scuttlebot-post.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-post.sh
@@ -1,21 +1,65 @@
1 #!/bin/bash
2 # PostToolUse hook — posts what Claude just did to scuttlebot IRC.
 
3
4 SCUTTLEBOT_CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
5 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
6 set -a
7 . "$SCUTTLEBOT_CONFIG_FILE"
8 set +a
9 fi
10 if [ -n "${SCUTTLEBOT_CHANNEL_STATE_FILE:-}" ] && [ -f "$SCUTTLEBOT_CHANNEL_STATE_FILE" ]; then
11 set -a
12 . "$SCUTTLEBOT_CHANNEL_STATE_FILE"
13 set +a
14 fi
15
16 SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}"
17 SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}"
18 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}"
19 SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}"
20 SCUTTLEBOT_ACTIVITY_VIA_BROKER="${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}"
21
22 normalize_channel() {
23 local channel="$1"
24 channel="${channel//[$' \t\r\n']/}"
25 channel="${channel#\#}"
26 printf '%s' "$channel"
27 }
28
29 relay_channels() {
30 local raw="${SCUTTLEBOT_CHANNELS:-$SCUTTLEBOT_CHANNEL}"
31 local IFS=','
32 local item channel seen=""
33 read -r -a items <<< "$raw"
34 for item in "${items[@]}"; do
35 channel=$(normalize_channel "$item")
36 [ -n "$channel" ] || continue
37 case ",$seen," in
38 *,"$channel",*) ;;
39 *)
40 seen="${seen:+$seen,}$channel"
41 printf '%s\n' "$channel"
42 ;;
43 esac
44 done
45 }
46
47 post_message() {
48 local text="$1"
49 local payload
50 payload="{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}"
51 for channel in $(relay_channels); do
52 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$channel/messages" \
53 --connect-timeout 1 \
54 --max-time 2 \
55 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
56 -H "Content-Type: application/json" \
57 -d "$payload" \
58 > /dev/null || true
59 done
60 }
61
62 input=$(cat)
63
64 tool=$(echo "$input" | jq -r '.tool_name // empty')
65 cwd=$(echo "$input" | jq -r '.cwd // empty')
@@ -73,14 +117,7 @@
117 ;;
118 esac
119
120 [ -z "$msg" ] && exit 0
121
122 post_message "$msg"
 
 
 
 
 
 
 
123 exit 0
124
--- skills/scuttlebot-relay/install.md
+++ skills/scuttlebot-relay/install.md
@@ -1,10 +1,12 @@
11
# scuttlebot-relay skill
22
33
Installs Claude Code hooks that post your activity to an IRC channel in real time
44
and surface human instructions from IRC back into your context before each action.
55
6
+Shared relay skill entry: [`SKILL.md`](SKILL.md)
7
+
68
## What it does
79
810
The relay provides an interactive broker that:
911
- starts your Claude session on a real PTY
1012
- posts an "online" message immediately
@@ -22,11 +24,12 @@
2224
2325
```bash
2426
bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \
2527
--url http://localhost:8080 \
2628
--token "$(./run.sh token)" \
27
- --channel general
29
+ --channel general \
30
+ --channels general,task-42
2831
```
2932
3033
Or via Make:
3134
3235
```bash
@@ -55,13 +58,23 @@
5558
5659
## Configuration
5760
5861
Useful shared env knobs in `~/.config/scuttlebot-relay.env`:
5962
- `SCUTTLEBOT_TRANSPORT=http|irc` — selects the connector backend
63
+- `SCUTTLEBOT_CHANNEL` — primary control channel
64
+- `SCUTTLEBOT_CHANNELS=general,task-42` — optional startup channel set, including the control channel
6065
- `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Claude session when it appears busy
6166
- `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages
6267
- `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` — controls HTTP presence touches; set `0` to disable
6368
6469
Disable without uninstalling:
6570
```bash
6671
SCUTTLEBOT_HOOKS_ENABLED=0 claude-relay
6772
```
73
+
74
+Live channel commands:
75
+- `/channels`
76
+- `/join #task-42`
77
+- `/part #task-42`
78
+
79
+Those commands change the joined channel set for the current session without
80
+rewriting the shared env file.
6881
--- skills/scuttlebot-relay/install.md
+++ skills/scuttlebot-relay/install.md
@@ -1,10 +1,12 @@
1 # scuttlebot-relay skill
2
3 Installs Claude Code hooks that post your activity to an IRC channel in real time
4 and surface human instructions from IRC back into your context before each action.
5
 
 
6 ## What it does
7
8 The relay provides an interactive broker that:
9 - starts your Claude session on a real PTY
10 - posts an "online" message immediately
@@ -22,11 +24,12 @@
22
23 ```bash
24 bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \
25 --url http://localhost:8080 \
26 --token "$(./run.sh token)" \
27 --channel general
 
28 ```
29
30 Or via Make:
31
32 ```bash
@@ -55,13 +58,23 @@
55
56 ## Configuration
57
58 Useful shared env knobs in `~/.config/scuttlebot-relay.env`:
59 - `SCUTTLEBOT_TRANSPORT=http|irc` — selects the connector backend
 
 
60 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Claude session when it appears busy
61 - `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages
62 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` — controls HTTP presence touches; set `0` to disable
63
64 Disable without uninstalling:
65 ```bash
66 SCUTTLEBOT_HOOKS_ENABLED=0 claude-relay
67 ```
 
 
 
 
 
 
 
 
68
--- skills/scuttlebot-relay/install.md
+++ skills/scuttlebot-relay/install.md
@@ -1,10 +1,12 @@
1 # scuttlebot-relay skill
2
3 Installs Claude Code hooks that post your activity to an IRC channel in real time
4 and surface human instructions from IRC back into your context before each action.
5
6 Shared relay skill entry: [`SKILL.md`](SKILL.md)
7
8 ## What it does
9
10 The relay provides an interactive broker that:
11 - starts your Claude session on a real PTY
12 - posts an "online" message immediately
@@ -22,11 +24,12 @@
24
25 ```bash
26 bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \
27 --url http://localhost:8080 \
28 --token "$(./run.sh token)" \
29 --channel general \
30 --channels general,task-42
31 ```
32
33 Or via Make:
34
35 ```bash
@@ -55,13 +58,23 @@
58
59 ## Configuration
60
61 Useful shared env knobs in `~/.config/scuttlebot-relay.env`:
62 - `SCUTTLEBOT_TRANSPORT=http|irc` — selects the connector backend
63 - `SCUTTLEBOT_CHANNEL` — primary control channel
64 - `SCUTTLEBOT_CHANNELS=general,task-42` — optional startup channel set, including the control channel
65 - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Claude session when it appears busy
66 - `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages
67 - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` — controls HTTP presence touches; set `0` to disable
68
69 Disable without uninstalling:
70 ```bash
71 SCUTTLEBOT_HOOKS_ENABLED=0 claude-relay
72 ```
73
74 Live channel commands:
75 - `/channels`
76 - `/join #task-42`
77 - `/part #task-42`
78
79 Those commands change the joined channel set for the current session without
80 rewriting the shared env file.
81
--- skills/scuttlebot-relay/scripts/install-claude-relay.sh
+++ skills/scuttlebot-relay/scripts/install-claude-relay.sh
@@ -10,10 +10,11 @@
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
+ --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file.
1516
--transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: irc.
1617
--irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
1718
--irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
1819
--auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
1920
--enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -26,10 +27,11 @@
2627
2728
Environment defaults:
2829
SCUTTLEBOT_URL
2930
SCUTTLEBOT_TOKEN
3031
SCUTTLEBOT_CHANNEL
32
+ SCUTTLEBOT_CHANNELS
3133
SCUTTLEBOT_TRANSPORT
3234
SCUTTLEBOT_IRC_ADDR
3335
SCUTTLEBOT_IRC_PASS
3436
SCUTTLEBOT_IRC_DELETE_ON_CLOSE
3537
SCUTTLEBOT_HOOKS_ENABLED
@@ -55,10 +57,11 @@
5557
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
5658
5759
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
5860
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
5961
SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
62
+SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}"
6063
SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-irc}"
6164
SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
6265
if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
6366
SCUTTLEBOT_IRC_PASS_MODE="fixed"
6467
SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -88,10 +91,14 @@
8891
shift 2
8992
;;
9093
--channel)
9194
SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
9295
shift 2
96
+ ;;
97
+ --channels)
98
+ SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}"
99
+ shift 2
93100
;;
94101
--transport)
95102
SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
96103
shift 2
97104
;;
@@ -160,10 +167,52 @@
160167
}
161168
162169
ensure_parent_dir() {
163170
mkdir -p "$(dirname "$1")"
164171
}
172
+
173
+normalize_channels() {
174
+ local primary="$1"
175
+ local raw="$2"
176
+ local IFS=','
177
+ local items=()
178
+ local extra_items=()
179
+ local item channel seen=""
180
+
181
+ if [ -n "$primary" ]; then
182
+ items+=("$primary")
183
+ fi
184
+ if [ -n "$raw" ]; then
185
+ read -r -a extra_items <<< "$raw"
186
+ items+=("${extra_items[@]}")
187
+ fi
188
+
189
+ for item in "${items[@]}"; do
190
+ channel="${item//[$' \t\r\n']/}"
191
+ channel="${channel#\#}"
192
+ [ -n "$channel" ] || continue
193
+ case ",$seen," in
194
+ *,"$channel",*) ;;
195
+ *) seen="${seen:+$seen,}$channel" ;;
196
+ esac
197
+ done
198
+
199
+ printf '%s' "$seen"
200
+}
201
+
202
+first_channel() {
203
+ local channels
204
+ channels=$(normalize_channels "" "$1")
205
+ printf '%s' "${channels%%,*}"
206
+}
207
+
208
+if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
209
+ SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")"
210
+fi
211
+if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
212
+ SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")"
213
+fi
165214
166215
upsert_env_var() {
167216
local file="$1"
168217
local key="$2"
169218
local value="$3"
@@ -277,10 +326,13 @@
277326
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
278327
fi
279328
if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
280329
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
281330
fi
331
+if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
332
+ upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE"
333
+fi
282334
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
283335
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
284336
if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
285337
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
286338
else
287339
--- skills/scuttlebot-relay/scripts/install-claude-relay.sh
+++ skills/scuttlebot-relay/scripts/install-claude-relay.sh
@@ -10,10 +10,11 @@
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: irc.
16 --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
17 --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
18 --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
19 --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -26,10 +27,11 @@
26
27 Environment defaults:
28 SCUTTLEBOT_URL
29 SCUTTLEBOT_TOKEN
30 SCUTTLEBOT_CHANNEL
 
31 SCUTTLEBOT_TRANSPORT
32 SCUTTLEBOT_IRC_ADDR
33 SCUTTLEBOT_IRC_PASS
34 SCUTTLEBOT_IRC_DELETE_ON_CLOSE
35 SCUTTLEBOT_HOOKS_ENABLED
@@ -55,10 +57,11 @@
55 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
56
57 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
58 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
59 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
 
60 SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-irc}"
61 SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
62 if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
63 SCUTTLEBOT_IRC_PASS_MODE="fixed"
64 SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -88,10 +91,14 @@
88 shift 2
89 ;;
90 --channel)
91 SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
92 shift 2
 
 
 
 
93 ;;
94 --transport)
95 SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
96 shift 2
97 ;;
@@ -160,10 +167,52 @@
160 }
161
162 ensure_parent_dir() {
163 mkdir -p "$(dirname "$1")"
164 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
166 upsert_env_var() {
167 local file="$1"
168 local key="$2"
169 local value="$3"
@@ -277,10 +326,13 @@
277 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
278 fi
279 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
280 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
281 fi
 
 
 
282 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
283 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
284 if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
285 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
286 else
287
--- skills/scuttlebot-relay/scripts/install-claude-relay.sh
+++ skills/scuttlebot-relay/scripts/install-claude-relay.sh
@@ -10,10 +10,11 @@
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 --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file.
16 --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: irc.
17 --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667.
18 --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode.
19 --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default.
20 --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default.
@@ -26,10 +27,11 @@
27
28 Environment defaults:
29 SCUTTLEBOT_URL
30 SCUTTLEBOT_TOKEN
31 SCUTTLEBOT_CHANNEL
32 SCUTTLEBOT_CHANNELS
33 SCUTTLEBOT_TRANSPORT
34 SCUTTLEBOT_IRC_ADDR
35 SCUTTLEBOT_IRC_PASS
36 SCUTTLEBOT_IRC_DELETE_ON_CLOSE
37 SCUTTLEBOT_HOOKS_ENABLED
@@ -55,10 +57,11 @@
57 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
58
59 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
60 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
61 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
62 SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}"
63 SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-irc}"
64 SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}"
65 if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then
66 SCUTTLEBOT_IRC_PASS_MODE="fixed"
67 SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS"
@@ -88,10 +91,14 @@
91 shift 2
92 ;;
93 --channel)
94 SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}"
95 shift 2
96 ;;
97 --channels)
98 SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}"
99 shift 2
100 ;;
101 --transport)
102 SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}"
103 shift 2
104 ;;
@@ -160,10 +167,52 @@
167 }
168
169 ensure_parent_dir() {
170 mkdir -p "$(dirname "$1")"
171 }
172
173 normalize_channels() {
174 local primary="$1"
175 local raw="$2"
176 local IFS=','
177 local items=()
178 local extra_items=()
179 local item channel seen=""
180
181 if [ -n "$primary" ]; then
182 items+=("$primary")
183 fi
184 if [ -n "$raw" ]; then
185 read -r -a extra_items <<< "$raw"
186 items+=("${extra_items[@]}")
187 fi
188
189 for item in "${items[@]}"; do
190 channel="${item//[$' \t\r\n']/}"
191 channel="${channel#\#}"
192 [ -n "$channel" ] || continue
193 case ",$seen," in
194 *,"$channel",*) ;;
195 *) seen="${seen:+$seen,}$channel" ;;
196 esac
197 done
198
199 printf '%s' "$seen"
200 }
201
202 first_channel() {
203 local channels
204 channels=$(normalize_channels "" "$1")
205 printf '%s' "${channels%%,*}"
206 }
207
208 if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
209 SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")"
210 fi
211 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
212 SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")"
213 fi
214
215 upsert_env_var() {
216 local file="$1"
217 local key="$2"
218 local value="$3"
@@ -277,10 +326,13 @@
326 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE"
327 fi
328 if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then
329 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}"
330 fi
331 if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then
332 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE"
333 fi
334 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE"
335 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE"
336 if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then
337 upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE"
338 else
339
--- tests/smoke/test-installers.sh
+++ tests/smoke/test-installers.sh
@@ -34,11 +34,12 @@
3434
# 1. Codex
3535
printf 'Testing Codex installer...\n'
3636
bash "$REPO_ROOT/skills/openai-relay/scripts/install-codex-relay.sh" \
3737
--url http://localhost:8080 \
3838
--token "test-token" \
39
- --channel general
39
+ --channel general \
40
+ --channels general,task-42
4041
4142
# Verify files
4243
[ -f "$HOME/.codex/hooks/scuttlebot-post.sh" ]
4344
[ -f "$HOME/.codex/hooks/scuttlebot-check.sh" ]
4445
[ -f "$HOME/.codex/hooks.json" ]
@@ -45,25 +46,28 @@
4546
[ -f "$HOME/.codex/config.toml" ]
4647
[ -f "$HOME/.local/bin/codex-relay" ]
4748
[ -f "$HOME/.config/scuttlebot-relay.env" ]
4849
! grep -q '^SCUTTLEBOT_IRC_PASS=' "$SCUTTLEBOT_CONFIG_FILE"
4950
grep -q '^SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1$' "$SCUTTLEBOT_CONFIG_FILE"
51
+grep -q '^SCUTTLEBOT_CHANNELS=general,task-42$' "$SCUTTLEBOT_CONFIG_FILE"
5052
5153
# 2. Gemini
5254
printf 'Testing Gemini installer...\n'
5355
bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \
5456
--url http://localhost:8080 \
5557
--token "test-token" \
5658
--channel general \
59
+ --channels general,release \
5760
--irc-pass "gemini-fixed"
5861
5962
# Verify files
6063
[ -f "$HOME/.gemini/hooks/scuttlebot-post.sh" ]
6164
[ -f "$HOME/.gemini/hooks/scuttlebot-check.sh" ]
6265
[ -f "$HOME/.gemini/settings.json" ]
6366
[ -f "$HOME/.local/bin/gemini-relay" ]
6467
grep -q '^SCUTTLEBOT_IRC_PASS=gemini-fixed$' "$SCUTTLEBOT_CONFIG_FILE"
68
+grep -q '^SCUTTLEBOT_CHANNELS=general,release$' "$SCUTTLEBOT_CONFIG_FILE"
6569
6670
printf 'Testing Gemini auto-register scrub...\n'
6771
bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \
6872
--channel general \
6973
--auto-register
@@ -73,10 +77,11 @@
7377
printf 'Testing Claude installer...\n'
7478
bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \
7579
--url http://localhost:8080 \
7680
--token "test-token" \
7781
--channel general \
82
+ --channels general,ops \
7883
--transport irc \
7984
--irc-addr 127.0.0.1:6667 \
8085
--irc-pass "claude-fixed"
8186
8287
# Verify files
@@ -84,10 +89,11 @@
8489
[ -f "$HOME/.claude/hooks/scuttlebot-check.sh" ]
8590
[ -f "$HOME/.claude/settings.json" ]
8691
[ -f "$HOME/.local/bin/claude-relay" ]
8792
grep -q '^SCUTTLEBOT_IRC_PASS=claude-fixed$' "$SCUTTLEBOT_CONFIG_FILE"
8893
grep -q '^SCUTTLEBOT_TRANSPORT=irc$' "$SCUTTLEBOT_CONFIG_FILE"
94
+grep -q '^SCUTTLEBOT_CHANNELS=general,ops$' "$SCUTTLEBOT_CONFIG_FILE"
8995
9096
printf 'Testing Claude auto-register scrub...\n'
9197
bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \
9298
--channel general \
9399
--auto-register
94100
--- tests/smoke/test-installers.sh
+++ tests/smoke/test-installers.sh
@@ -34,11 +34,12 @@
34 # 1. Codex
35 printf 'Testing Codex installer...\n'
36 bash "$REPO_ROOT/skills/openai-relay/scripts/install-codex-relay.sh" \
37 --url http://localhost:8080 \
38 --token "test-token" \
39 --channel general
 
40
41 # Verify files
42 [ -f "$HOME/.codex/hooks/scuttlebot-post.sh" ]
43 [ -f "$HOME/.codex/hooks/scuttlebot-check.sh" ]
44 [ -f "$HOME/.codex/hooks.json" ]
@@ -45,25 +46,28 @@
45 [ -f "$HOME/.codex/config.toml" ]
46 [ -f "$HOME/.local/bin/codex-relay" ]
47 [ -f "$HOME/.config/scuttlebot-relay.env" ]
48 ! grep -q '^SCUTTLEBOT_IRC_PASS=' "$SCUTTLEBOT_CONFIG_FILE"
49 grep -q '^SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1$' "$SCUTTLEBOT_CONFIG_FILE"
 
50
51 # 2. Gemini
52 printf 'Testing Gemini installer...\n'
53 bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \
54 --url http://localhost:8080 \
55 --token "test-token" \
56 --channel general \
 
57 --irc-pass "gemini-fixed"
58
59 # Verify files
60 [ -f "$HOME/.gemini/hooks/scuttlebot-post.sh" ]
61 [ -f "$HOME/.gemini/hooks/scuttlebot-check.sh" ]
62 [ -f "$HOME/.gemini/settings.json" ]
63 [ -f "$HOME/.local/bin/gemini-relay" ]
64 grep -q '^SCUTTLEBOT_IRC_PASS=gemini-fixed$' "$SCUTTLEBOT_CONFIG_FILE"
 
65
66 printf 'Testing Gemini auto-register scrub...\n'
67 bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \
68 --channel general \
69 --auto-register
@@ -73,10 +77,11 @@
73 printf 'Testing Claude installer...\n'
74 bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \
75 --url http://localhost:8080 \
76 --token "test-token" \
77 --channel general \
 
78 --transport irc \
79 --irc-addr 127.0.0.1:6667 \
80 --irc-pass "claude-fixed"
81
82 # Verify files
@@ -84,10 +89,11 @@
84 [ -f "$HOME/.claude/hooks/scuttlebot-check.sh" ]
85 [ -f "$HOME/.claude/settings.json" ]
86 [ -f "$HOME/.local/bin/claude-relay" ]
87 grep -q '^SCUTTLEBOT_IRC_PASS=claude-fixed$' "$SCUTTLEBOT_CONFIG_FILE"
88 grep -q '^SCUTTLEBOT_TRANSPORT=irc$' "$SCUTTLEBOT_CONFIG_FILE"
 
89
90 printf 'Testing Claude auto-register scrub...\n'
91 bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \
92 --channel general \
93 --auto-register
94
--- tests/smoke/test-installers.sh
+++ tests/smoke/test-installers.sh
@@ -34,11 +34,12 @@
34 # 1. Codex
35 printf 'Testing Codex installer...\n'
36 bash "$REPO_ROOT/skills/openai-relay/scripts/install-codex-relay.sh" \
37 --url http://localhost:8080 \
38 --token "test-token" \
39 --channel general \
40 --channels general,task-42
41
42 # Verify files
43 [ -f "$HOME/.codex/hooks/scuttlebot-post.sh" ]
44 [ -f "$HOME/.codex/hooks/scuttlebot-check.sh" ]
45 [ -f "$HOME/.codex/hooks.json" ]
@@ -45,25 +46,28 @@
46 [ -f "$HOME/.codex/config.toml" ]
47 [ -f "$HOME/.local/bin/codex-relay" ]
48 [ -f "$HOME/.config/scuttlebot-relay.env" ]
49 ! grep -q '^SCUTTLEBOT_IRC_PASS=' "$SCUTTLEBOT_CONFIG_FILE"
50 grep -q '^SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1$' "$SCUTTLEBOT_CONFIG_FILE"
51 grep -q '^SCUTTLEBOT_CHANNELS=general,task-42$' "$SCUTTLEBOT_CONFIG_FILE"
52
53 # 2. Gemini
54 printf 'Testing Gemini installer...\n'
55 bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \
56 --url http://localhost:8080 \
57 --token "test-token" \
58 --channel general \
59 --channels general,release \
60 --irc-pass "gemini-fixed"
61
62 # Verify files
63 [ -f "$HOME/.gemini/hooks/scuttlebot-post.sh" ]
64 [ -f "$HOME/.gemini/hooks/scuttlebot-check.sh" ]
65 [ -f "$HOME/.gemini/settings.json" ]
66 [ -f "$HOME/.local/bin/gemini-relay" ]
67 grep -q '^SCUTTLEBOT_IRC_PASS=gemini-fixed$' "$SCUTTLEBOT_CONFIG_FILE"
68 grep -q '^SCUTTLEBOT_CHANNELS=general,release$' "$SCUTTLEBOT_CONFIG_FILE"
69
70 printf 'Testing Gemini auto-register scrub...\n'
71 bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \
72 --channel general \
73 --auto-register
@@ -73,10 +77,11 @@
77 printf 'Testing Claude installer...\n'
78 bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \
79 --url http://localhost:8080 \
80 --token "test-token" \
81 --channel general \
82 --channels general,ops \
83 --transport irc \
84 --irc-addr 127.0.0.1:6667 \
85 --irc-pass "claude-fixed"
86
87 # Verify files
@@ -84,10 +89,11 @@
89 [ -f "$HOME/.claude/hooks/scuttlebot-check.sh" ]
90 [ -f "$HOME/.claude/settings.json" ]
91 [ -f "$HOME/.local/bin/claude-relay" ]
92 grep -q '^SCUTTLEBOT_IRC_PASS=claude-fixed$' "$SCUTTLEBOT_CONFIG_FILE"
93 grep -q '^SCUTTLEBOT_TRANSPORT=irc$' "$SCUTTLEBOT_CONFIG_FILE"
94 grep -q '^SCUTTLEBOT_CHANNELS=general,ops$' "$SCUTTLEBOT_CONFIG_FILE"
95
96 printf 'Testing Claude auto-register scrub...\n'
97 bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \
98 --channel general \
99 --auto-register
100

Keyboard Shortcuts

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