ScuttleBot
Add multi-channel relay control and shared install skill
Commit
1d3caa2033b59f26bcdfd39ab1bd53c9090ca274d0cb3e8fbd094aefb36e08d1
Parent
0adbd1e1813ebb0…
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/codex-relay/main.go
~
cmd/codex-relay/main_test.go
~
cmd/gemini-relay/main.go
~
cmd/gemini-relay/main_test.go
+
pkg/sessionrelay/channelstate.go
~
pkg/sessionrelay/http.go
~
pkg/sessionrelay/irc.go
~
pkg/sessionrelay/sessionrelay.go
~
pkg/sessionrelay/sessionrelay_test.go
~
skills/gemini-relay/FLEET.md
~
skills/gemini-relay/SKILL.md
~
skills/gemini-relay/hooks/README.md
~
skills/gemini-relay/hooks/scuttlebot-after-agent.sh
~
skills/gemini-relay/hooks/scuttlebot-check.sh
~
skills/gemini-relay/hooks/scuttlebot-post.sh
~
skills/gemini-relay/install.md
~
skills/gemini-relay/scripts/install-gemini-relay.sh
~
skills/openai-relay/FLEET.md
~
skills/openai-relay/SKILL.md
~
skills/openai-relay/hooks/README.md
~
skills/openai-relay/hooks/scuttlebot-check.sh
~
skills/openai-relay/hooks/scuttlebot-post.sh
~
skills/openai-relay/install.md
~
skills/openai-relay/scripts/install-codex-relay.sh
~
skills/scuttlebot-relay/ADDING_AGENTS.md
~
skills/scuttlebot-relay/FLEET.md
+
skills/scuttlebot-relay/SKILL.md
+
skills/scuttlebot-relay/agents/openai.yaml
~
skills/scuttlebot-relay/hooks/README.md
~
skills/scuttlebot-relay/hooks/scuttlebot-check.sh
~
skills/scuttlebot-relay/hooks/scuttlebot-post.sh
~
skills/scuttlebot-relay/install.md
~
skills/scuttlebot-relay/scripts/install-claude-relay.sh
~
tests/smoke/test-installers.sh
+103
-5
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -71,10 +71,12 @@ | ||
| 71 | 71 | IRCAddr string |
| 72 | 72 | IRCPass string |
| 73 | 73 | IRCAgentType string |
| 74 | 74 | IRCDeleteOnClose bool |
| 75 | 75 | Channel string |
| 76 | + Channels []string | |
| 77 | + ChannelStateFile string | |
| 76 | 78 | SessionID string |
| 77 | 79 | Nick string |
| 78 | 80 | HooksEnabled bool |
| 79 | 81 | InterruptOnMessage bool |
| 80 | 82 | PollInterval time.Duration |
| @@ -124,19 +126,22 @@ | ||
| 124 | 126 | fmt.Fprintf(os.Stderr, "claude-relay: nick %s\n", cfg.Nick) |
| 125 | 127 | relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args) |
| 126 | 128 | |
| 127 | 129 | ctx, cancel := context.WithCancel(context.Background()) |
| 128 | 130 | defer cancel() |
| 131 | + _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) | |
| 132 | + defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }() | |
| 129 | 133 | |
| 130 | 134 | var relay sessionrelay.Connector |
| 131 | 135 | relayActive := false |
| 132 | 136 | if relayRequested { |
| 133 | 137 | conn, err := sessionrelay.New(sessionrelay.Config{ |
| 134 | 138 | Transport: cfg.Transport, |
| 135 | 139 | URL: cfg.URL, |
| 136 | 140 | Token: cfg.Token, |
| 137 | 141 | Channel: cfg.Channel, |
| 142 | + Channels: cfg.Channels, | |
| 138 | 143 | Nick: cfg.Nick, |
| 139 | 144 | IRC: sessionrelay.IRCConfig{ |
| 140 | 145 | Addr: cfg.IRCAddr, |
| 141 | 146 | Pass: cfg.IRCPass, |
| 142 | 147 | AgentType: cfg.IRCAgentType, |
| @@ -151,10 +156,13 @@ | ||
| 151 | 156 | fmt.Fprintf(os.Stderr, "claude-relay: relay disabled: %v\n", err) |
| 152 | 157 | _ = conn.Close(context.Background()) |
| 153 | 158 | } else { |
| 154 | 159 | relay = conn |
| 155 | 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 | + } | |
| 156 | 164 | _ = relay.Post(context.Background(), fmt.Sprintf( |
| 157 | 165 | "online in %s; mention %s to interrupt before the next action", |
| 158 | 166 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 159 | 167 | )) |
| 160 | 168 | } |
| @@ -174,10 +182,12 @@ | ||
| 174 | 182 | cmd.Env = append(os.Environ(), |
| 175 | 183 | "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile, |
| 176 | 184 | "SCUTTLEBOT_URL="+cfg.URL, |
| 177 | 185 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 178 | 186 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| 187 | + "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","), | |
| 188 | + "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile, | |
| 179 | 189 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 180 | 190 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 181 | 191 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 182 | 192 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 183 | 193 | ) |
| @@ -429,11 +439,11 @@ | ||
| 429 | 439 | } |
| 430 | 440 | case "tool_use": |
| 431 | 441 | if msg := summarizeToolUse(block.Name, block.Input); msg != "" { |
| 432 | 442 | out = append(out, msg) |
| 433 | 443 | } |
| 434 | - // thinking blocks are intentionally skipped — too verbose for IRC | |
| 444 | + // thinking blocks are intentionally skipped — too verbose for IRC | |
| 435 | 445 | } |
| 436 | 446 | } |
| 437 | 447 | return out |
| 438 | 448 | } |
| 439 | 449 | |
| @@ -581,11 +591,25 @@ | ||
| 581 | 591 | batch, newest := filterMessages(messages, lastSeen, cfg.Nick) |
| 582 | 592 | if len(batch) == 0 { |
| 583 | 593 | continue |
| 584 | 594 | } |
| 585 | 595 | 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 { | |
| 587 | 611 | return |
| 588 | 612 | } |
| 589 | 613 | } |
| 590 | 614 | } |
| 591 | 615 | } |
| @@ -605,18 +629,25 @@ | ||
| 605 | 629 | _ = relay.Touch(ctx) |
| 606 | 630 | } |
| 607 | 631 | } |
| 608 | 632 | } |
| 609 | 633 | |
| 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 { | |
| 611 | 635 | lines := make([]string, 0, len(batch)) |
| 612 | 636 | for _, msg := range batch { |
| 613 | 637 | text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick) |
| 614 | 638 | if text == "" { |
| 615 | 639 | text = strings.TrimSpace(msg.Text) |
| 616 | 640 | } |
| 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)) | |
| 618 | 649 | } |
| 619 | 650 | |
| 620 | 651 | var block strings.Builder |
| 621 | 652 | block.WriteString("[IRC operator messages]\n") |
| 622 | 653 | for _, line := range lines { |
| @@ -638,10 +669,62 @@ | ||
| 638 | 669 | return err |
| 639 | 670 | } |
| 640 | 671 | _, err := writer.Write([]byte{'\r'}) |
| 641 | 672 | return err |
| 642 | 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 | +} | |
| 643 | 726 | |
| 644 | 727 | func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) { |
| 645 | 728 | buf := make([]byte, 4096) |
| 646 | 729 | for { |
| 647 | 730 | n, err := src.Read(buf) |
| @@ -722,17 +805,22 @@ | ||
| 722 | 805 | Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""), |
| 723 | 806 | IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr), |
| 724 | 807 | IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""), |
| 725 | 808 | IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"), |
| 726 | 809 | IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true), |
| 727 | - Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"), | |
| 728 | 810 | HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true), |
| 729 | 811 | InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true), |
| 730 | 812 | PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval), |
| 731 | 813 | HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat), |
| 732 | 814 | Args: append([]string(nil), args...), |
| 733 | 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 | + } | |
| 734 | 822 | |
| 735 | 823 | target, err := targetCWD(args) |
| 736 | 824 | if err != nil { |
| 737 | 825 | return config{}, err |
| 738 | 826 | } |
| @@ -747,19 +835,29 @@ | ||
| 747 | 835 | nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "") |
| 748 | 836 | if nick == "" { |
| 749 | 837 | nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID) |
| 750 | 838 | } |
| 751 | 839 | cfg.Nick = sanitize(nick) |
| 840 | + cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick)) | |
| 752 | 841 | |
| 753 | 842 | if cfg.Channel == "" { |
| 754 | 843 | cfg.Channel = defaultChannel |
| 844 | + cfg.Channels = []string{defaultChannel} | |
| 755 | 845 | } |
| 756 | 846 | if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" { |
| 757 | 847 | cfg.HooksEnabled = false |
| 758 | 848 | } |
| 759 | 849 | return cfg, nil |
| 760 | 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 | +} | |
| 761 | 859 | |
| 762 | 860 | func configFilePath() string { |
| 763 | 861 | if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" { |
| 764 | 862 | return value |
| 765 | 863 | } |
| 766 | 864 |
| --- 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 |
+102
-4
| --- cmd/codex-relay/main.go | ||
| +++ cmd/codex-relay/main.go | ||
| @@ -71,10 +71,12 @@ | ||
| 71 | 71 | IRCAddr string |
| 72 | 72 | IRCPass string |
| 73 | 73 | IRCAgentType string |
| 74 | 74 | IRCDeleteOnClose bool |
| 75 | 75 | Channel string |
| 76 | + Channels []string | |
| 77 | + ChannelStateFile string | |
| 76 | 78 | SessionID string |
| 77 | 79 | Nick string |
| 78 | 80 | HooksEnabled bool |
| 79 | 81 | InterruptOnMessage bool |
| 80 | 82 | PollInterval time.Duration |
| @@ -145,19 +147,22 @@ | ||
| 145 | 147 | fmt.Fprintf(os.Stderr, "codex-relay: nick %s\n", cfg.Nick) |
| 146 | 148 | relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args) |
| 147 | 149 | |
| 148 | 150 | ctx, cancel := context.WithCancel(context.Background()) |
| 149 | 151 | defer cancel() |
| 152 | + _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) | |
| 153 | + defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }() | |
| 150 | 154 | |
| 151 | 155 | var relay sessionrelay.Connector |
| 152 | 156 | relayActive := false |
| 153 | 157 | if relayRequested { |
| 154 | 158 | conn, err := sessionrelay.New(sessionrelay.Config{ |
| 155 | 159 | Transport: cfg.Transport, |
| 156 | 160 | URL: cfg.URL, |
| 157 | 161 | Token: cfg.Token, |
| 158 | 162 | Channel: cfg.Channel, |
| 163 | + Channels: cfg.Channels, | |
| 159 | 164 | Nick: cfg.Nick, |
| 160 | 165 | IRC: sessionrelay.IRCConfig{ |
| 161 | 166 | Addr: cfg.IRCAddr, |
| 162 | 167 | Pass: cfg.IRCPass, |
| 163 | 168 | AgentType: cfg.IRCAgentType, |
| @@ -172,10 +177,13 @@ | ||
| 172 | 177 | fmt.Fprintf(os.Stderr, "codex-relay: relay disabled: %v\n", err) |
| 173 | 178 | _ = conn.Close(context.Background()) |
| 174 | 179 | } else { |
| 175 | 180 | relay = conn |
| 176 | 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 | + } | |
| 177 | 185 | _ = relay.Post(context.Background(), fmt.Sprintf( |
| 178 | 186 | "online in %s; mention %s to interrupt before the next action", |
| 179 | 187 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 180 | 188 | )) |
| 181 | 189 | } |
| @@ -195,10 +203,12 @@ | ||
| 195 | 203 | cmd.Env = append(os.Environ(), |
| 196 | 204 | "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile, |
| 197 | 205 | "SCUTTLEBOT_URL="+cfg.URL, |
| 198 | 206 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 199 | 207 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| 208 | + "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","), | |
| 209 | + "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile, | |
| 200 | 210 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 201 | 211 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 202 | 212 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 203 | 213 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 204 | 214 | ) |
| @@ -288,11 +298,25 @@ | ||
| 288 | 298 | batch, newest := filterMessages(messages, lastSeen, cfg.Nick) |
| 289 | 299 | if len(batch) == 0 { |
| 290 | 300 | continue |
| 291 | 301 | } |
| 292 | 302 | 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 { | |
| 294 | 318 | return |
| 295 | 319 | } |
| 296 | 320 | } |
| 297 | 321 | } |
| 298 | 322 | } |
| @@ -312,18 +336,25 @@ | ||
| 312 | 336 | _ = relay.Touch(ctx) |
| 313 | 337 | } |
| 314 | 338 | } |
| 315 | 339 | } |
| 316 | 340 | |
| 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 { | |
| 318 | 342 | lines := make([]string, 0, len(batch)) |
| 319 | 343 | for _, msg := range batch { |
| 320 | 344 | text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick) |
| 321 | 345 | if text == "" { |
| 322 | 346 | text = strings.TrimSpace(msg.Text) |
| 323 | 347 | } |
| 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)) | |
| 325 | 356 | } |
| 326 | 357 | |
| 327 | 358 | var block strings.Builder |
| 328 | 359 | block.WriteString("[IRC operator messages]\n") |
| 329 | 360 | for _, line := range lines { |
| @@ -345,10 +376,62 @@ | ||
| 345 | 376 | return err |
| 346 | 377 | } |
| 347 | 378 | _, err := writer.Write([]byte{'\r'}) |
| 348 | 379 | return err |
| 349 | 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 | +} | |
| 350 | 433 | |
| 351 | 434 | func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) { |
| 352 | 435 | buf := make([]byte, 4096) |
| 353 | 436 | for { |
| 354 | 437 | n, err := src.Read(buf) |
| @@ -426,17 +509,22 @@ | ||
| 426 | 509 | Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""), |
| 427 | 510 | IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr), |
| 428 | 511 | IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""), |
| 429 | 512 | IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"), |
| 430 | 513 | IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true), |
| 431 | - Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"), | |
| 432 | 514 | HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true), |
| 433 | 515 | InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true), |
| 434 | 516 | PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval), |
| 435 | 517 | HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat), |
| 436 | 518 | Args: append([]string(nil), args...), |
| 437 | 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 | + } | |
| 438 | 526 | |
| 439 | 527 | target, err := targetCWD(args) |
| 440 | 528 | if err != nil { |
| 441 | 529 | return config{}, err |
| 442 | 530 | } |
| @@ -454,19 +542,29 @@ | ||
| 454 | 542 | nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "") |
| 455 | 543 | if nick == "" { |
| 456 | 544 | nick = fmt.Sprintf("codex-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID) |
| 457 | 545 | } |
| 458 | 546 | cfg.Nick = sanitize(nick) |
| 547 | + cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick)) | |
| 459 | 548 | |
| 460 | 549 | if cfg.Channel == "" { |
| 461 | 550 | cfg.Channel = defaultChannel |
| 551 | + cfg.Channels = []string{defaultChannel} | |
| 462 | 552 | } |
| 463 | 553 | if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" { |
| 464 | 554 | cfg.HooksEnabled = false |
| 465 | 555 | } |
| 466 | 556 | return cfg, nil |
| 467 | 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 | +} | |
| 468 | 566 | |
| 469 | 567 | func configFilePath() string { |
| 470 | 568 | if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" { |
| 471 | 569 | return value |
| 472 | 570 | } |
| 473 | 571 |
| --- 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 |
+4
-4
| --- cmd/codex-relay/main_test.go | ||
| +++ cmd/codex-relay/main_test.go | ||
| @@ -83,15 +83,15 @@ | ||
| 83 | 83 | batch := []message{{ |
| 84 | 84 | Nick: "glengoolie", |
| 85 | 85 | Text: "codex-scuttlebot-1234: check README.md", |
| 86 | 86 | }} |
| 87 | 87 | |
| 88 | - if err := injectMessages(&writer, cfg, state, batch); err != nil { | |
| 88 | + if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil { | |
| 89 | 89 | t.Fatal(err) |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | - want := "[IRC operator messages]\nglengoolie: check README.md\n\r" | |
| 92 | + want := "[IRC operator messages]\n[general] glengoolie: check README.md\n\r" | |
| 93 | 93 | if writer.String() != want { |
| 94 | 94 | t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want) |
| 95 | 95 | } |
| 96 | 96 | } |
| 97 | 97 | |
| @@ -108,15 +108,15 @@ | ||
| 108 | 108 | batch := []message{{ |
| 109 | 109 | Nick: "glengoolie", |
| 110 | 110 | Text: "codex-scuttlebot-1234: stop and re-read bridge.go", |
| 111 | 111 | }} |
| 112 | 112 | |
| 113 | - if err := injectMessages(&writer, cfg, state, batch); err != nil { | |
| 113 | + if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil { | |
| 114 | 114 | t.Fatal(err) |
| 115 | 115 | } |
| 116 | 116 | |
| 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" | |
| 118 | 118 | if writer.String() != want { |
| 119 | 119 | t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want) |
| 120 | 120 | } |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | 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, 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 |
+102
-5
| --- cmd/gemini-relay/main.go | ||
| +++ cmd/gemini-relay/main.go | ||
| @@ -61,10 +61,12 @@ | ||
| 61 | 61 | IRCAddr string |
| 62 | 62 | IRCPass string |
| 63 | 63 | IRCAgentType string |
| 64 | 64 | IRCDeleteOnClose bool |
| 65 | 65 | Channel string |
| 66 | + Channels []string | |
| 67 | + ChannelStateFile string | |
| 66 | 68 | SessionID string |
| 67 | 69 | Nick string |
| 68 | 70 | HooksEnabled bool |
| 69 | 71 | InterruptOnMessage bool |
| 70 | 72 | PollInterval time.Duration |
| @@ -97,19 +99,22 @@ | ||
| 97 | 99 | fmt.Fprintf(os.Stderr, "gemini-relay: nick %s\n", cfg.Nick) |
| 98 | 100 | relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args) |
| 99 | 101 | |
| 100 | 102 | ctx, cancel := context.WithCancel(context.Background()) |
| 101 | 103 | defer cancel() |
| 104 | + _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) | |
| 105 | + defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }() | |
| 102 | 106 | |
| 103 | 107 | var relay sessionrelay.Connector |
| 104 | 108 | relayActive := false |
| 105 | 109 | if relayRequested { |
| 106 | 110 | conn, err := sessionrelay.New(sessionrelay.Config{ |
| 107 | 111 | Transport: cfg.Transport, |
| 108 | 112 | URL: cfg.URL, |
| 109 | 113 | Token: cfg.Token, |
| 110 | 114 | Channel: cfg.Channel, |
| 115 | + Channels: cfg.Channels, | |
| 111 | 116 | Nick: cfg.Nick, |
| 112 | 117 | IRC: sessionrelay.IRCConfig{ |
| 113 | 118 | Addr: cfg.IRCAddr, |
| 114 | 119 | Pass: cfg.IRCPass, |
| 115 | 120 | AgentType: cfg.IRCAgentType, |
| @@ -124,10 +129,13 @@ | ||
| 124 | 129 | fmt.Fprintf(os.Stderr, "gemini-relay: relay disabled: %v\n", err) |
| 125 | 130 | _ = conn.Close(context.Background()) |
| 126 | 131 | } else { |
| 127 | 132 | relay = conn |
| 128 | 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 | + } | |
| 129 | 137 | _ = relay.Post(context.Background(), fmt.Sprintf( |
| 130 | 138 | "online in %s; mention %s to interrupt before the next action", |
| 131 | 139 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 132 | 140 | )) |
| 133 | 141 | } |
| @@ -146,14 +154,15 @@ | ||
| 146 | 154 | cmd.Env = append(os.Environ(), |
| 147 | 155 | "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile, |
| 148 | 156 | "SCUTTLEBOT_URL="+cfg.URL, |
| 149 | 157 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 150 | 158 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| 159 | + "SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","), | |
| 160 | + "SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile, | |
| 151 | 161 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 152 | 162 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 153 | 163 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 154 | - "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), | |
| 155 | 164 | ) |
| 156 | 165 | if relayActive { |
| 157 | 166 | go presenceLoop(ctx, relay, cfg.HeartbeatInterval) |
| 158 | 167 | } |
| 159 | 168 | |
| @@ -238,11 +247,25 @@ | ||
| 238 | 247 | batch, newest := filterMessages(messages, lastSeen, cfg.Nick) |
| 239 | 248 | if len(batch) == 0 { |
| 240 | 249 | continue |
| 241 | 250 | } |
| 242 | 251 | 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 { | |
| 244 | 267 | return |
| 245 | 268 | } |
| 246 | 269 | } |
| 247 | 270 | } |
| 248 | 271 | } |
| @@ -262,18 +285,25 @@ | ||
| 262 | 285 | _ = relay.Touch(ctx) |
| 263 | 286 | } |
| 264 | 287 | } |
| 265 | 288 | } |
| 266 | 289 | |
| 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 { | |
| 268 | 291 | lines := make([]string, 0, len(batch)) |
| 269 | 292 | for _, msg := range batch { |
| 270 | 293 | text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick) |
| 271 | 294 | if text == "" { |
| 272 | 295 | text = strings.TrimSpace(msg.Text) |
| 273 | 296 | } |
| 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)) | |
| 275 | 305 | } |
| 276 | 306 | |
| 277 | 307 | var block strings.Builder |
| 278 | 308 | block.WriteString("[IRC operator messages]\n") |
| 279 | 309 | for _, line := range lines { |
| @@ -299,10 +329,62 @@ | ||
| 299 | 329 | } |
| 300 | 330 | time.Sleep(defaultInjectDelay) |
| 301 | 331 | _, err := writer.Write([]byte{'\r'}) |
| 302 | 332 | return err |
| 303 | 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 | +} | |
| 304 | 386 | |
| 305 | 387 | func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) { |
| 306 | 388 | buf := make([]byte, 4096) |
| 307 | 389 | for { |
| 308 | 390 | n, err := src.Read(buf) |
| @@ -383,17 +465,22 @@ | ||
| 383 | 465 | Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""), |
| 384 | 466 | IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr), |
| 385 | 467 | IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""), |
| 386 | 468 | IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"), |
| 387 | 469 | IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true), |
| 388 | - Channel: strings.TrimPrefix(getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel), "#"), | |
| 389 | 470 | HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true), |
| 390 | 471 | InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true), |
| 391 | 472 | PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval), |
| 392 | 473 | HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat), |
| 393 | 474 | Args: append([]string(nil), args...), |
| 394 | 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 | + } | |
| 395 | 482 | |
| 396 | 483 | target, err := targetCWD(args) |
| 397 | 484 | if err != nil { |
| 398 | 485 | return config{}, err |
| 399 | 486 | } |
| @@ -411,19 +498,29 @@ | ||
| 411 | 498 | nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "") |
| 412 | 499 | if nick == "" { |
| 413 | 500 | nick = fmt.Sprintf("gemini-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID) |
| 414 | 501 | } |
| 415 | 502 | cfg.Nick = sanitize(nick) |
| 503 | + cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick)) | |
| 416 | 504 | |
| 417 | 505 | if cfg.Channel == "" { |
| 418 | 506 | cfg.Channel = defaultChannel |
| 507 | + cfg.Channels = []string{defaultChannel} | |
| 419 | 508 | } |
| 420 | 509 | if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" { |
| 421 | 510 | cfg.HooksEnabled = false |
| 422 | 511 | } |
| 423 | 512 | return cfg, nil |
| 424 | 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 | +} | |
| 425 | 522 | |
| 426 | 523 | func configFilePath() string { |
| 427 | 524 | if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" { |
| 428 | 525 | return value |
| 429 | 526 | } |
| 430 | 527 |
| --- 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 |
+4
-4
| --- cmd/gemini-relay/main_test.go | ||
| +++ cmd/gemini-relay/main_test.go | ||
| @@ -90,15 +90,15 @@ | ||
| 90 | 90 | batch := []message{{ |
| 91 | 91 | Nick: "glengoolie", |
| 92 | 92 | Text: "gemini-scuttlebot-1234: check README.md", |
| 93 | 93 | }} |
| 94 | 94 | |
| 95 | - if err := injectMessages(&writer, cfg, state, batch); err != nil { | |
| 95 | + if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil { | |
| 96 | 96 | t.Fatal(err) |
| 97 | 97 | } |
| 98 | 98 | |
| 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" | |
| 100 | 100 | if writer.String() != want { |
| 101 | 101 | t.Fatalf("injectMessages idle = %q, want %q", writer.String(), want) |
| 102 | 102 | } |
| 103 | 103 | } |
| 104 | 104 | |
| @@ -115,14 +115,14 @@ | ||
| 115 | 115 | batch := []message{{ |
| 116 | 116 | Nick: "glengoolie", |
| 117 | 117 | Text: "gemini-scuttlebot-1234: stop and re-read bridge.go", |
| 118 | 118 | }} |
| 119 | 119 | |
| 120 | - if err := injectMessages(&writer, cfg, state, batch); err != nil { | |
| 120 | + if err := injectMessages(&writer, cfg, state, "#general", batch); err != nil { | |
| 121 | 121 | t.Fatal(err) |
| 122 | 122 | } |
| 123 | 123 | |
| 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" | |
| 125 | 125 | if writer.String() != want { |
| 126 | 126 | t.Fatalf("injectMessages busy = %q, want %q", writer.String(), want) |
| 127 | 127 | } |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | 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 | } |
+119
-46
| --- pkg/sessionrelay/http.go | ||
| +++ pkg/sessionrelay/http.go | ||
| @@ -5,19 +5,25 @@ | ||
| 5 | 5 | "context" |
| 6 | 6 | "encoding/json" |
| 7 | 7 | "errors" |
| 8 | 8 | "fmt" |
| 9 | 9 | "net/http" |
| 10 | + "slices" | |
| 11 | + "sort" | |
| 12 | + "sync" | |
| 10 | 13 | "time" |
| 11 | 14 | ) |
| 12 | 15 | |
| 13 | 16 | type httpConnector struct { |
| 14 | 17 | http *http.Client |
| 15 | 18 | baseURL string |
| 16 | 19 | token string |
| 17 | - channel string | |
| 20 | + primary string | |
| 18 | 21 | nick string |
| 22 | + | |
| 23 | + mu sync.RWMutex | |
| 24 | + channels []string | |
| 19 | 25 | } |
| 20 | 26 | |
| 21 | 27 | type httpMessage struct { |
| 22 | 28 | At string `json:"at"` |
| 23 | 29 | Nick string `json:"nick"` |
| @@ -24,15 +30,16 @@ | ||
| 24 | 30 | Text string `json:"text"` |
| 25 | 31 | } |
| 26 | 32 | |
| 27 | 33 | func newHTTPConnector(cfg Config) Connector { |
| 28 | 34 | 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...), | |
| 34 | 41 | } |
| 35 | 42 | } |
| 36 | 43 | |
| 37 | 44 | func (c *httpConnector) Connect(context.Context) error { |
| 38 | 45 | if c.baseURL == "" { |
| @@ -43,63 +50,129 @@ | ||
| 43 | 50 | } |
| 44 | 51 | return nil |
| 45 | 52 | } |
| 46 | 53 | |
| 47 | 54 | 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{ | |
| 49 | 69 | "nick": c.nick, |
| 50 | 70 | "text": text, |
| 51 | 71 | }) |
| 52 | 72 | } |
| 53 | 73 | |
| 54 | 74 | 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) }) | |
| 88 | 113 | return out, nil |
| 89 | 114 | } |
| 90 | 115 | |
| 91 | 116 | 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") | |
| 95 | 135 | } |
| 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) { | |
| 98 | 139 | return nil |
| 99 | 140 | } |
| 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 | |
| 101 | 174 | } |
| 102 | 175 | |
| 103 | 176 | func (c *httpConnector) Close(context.Context) error { |
| 104 | 177 | return nil |
| 105 | 178 | } |
| 106 | 179 |
| --- 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 |
+91
-8
| --- pkg/sessionrelay/irc.go | ||
| +++ pkg/sessionrelay/irc.go | ||
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | "context" |
| 6 | 6 | "encoding/json" |
| 7 | 7 | "fmt" |
| 8 | 8 | "net" |
| 9 | 9 | "net/http" |
| 10 | + "slices" | |
| 10 | 11 | "strconv" |
| 11 | 12 | "strings" |
| 12 | 13 | "sync" |
| 13 | 14 | "time" |
| 14 | 15 | |
| @@ -17,18 +18,19 @@ | ||
| 17 | 18 | |
| 18 | 19 | type ircConnector struct { |
| 19 | 20 | http *http.Client |
| 20 | 21 | apiURL string |
| 21 | 22 | token string |
| 22 | - channel string | |
| 23 | + primary string | |
| 23 | 24 | nick string |
| 24 | 25 | addr string |
| 25 | 26 | agentType string |
| 26 | 27 | pass string |
| 27 | 28 | deleteOnClose bool |
| 28 | 29 | |
| 29 | 30 | mu sync.RWMutex |
| 31 | + channels []string | |
| 30 | 32 | messages []Message |
| 31 | 33 | client *girc.Client |
| 32 | 34 | errCh chan error |
| 33 | 35 | |
| 34 | 36 | registeredByRelay bool |
| @@ -40,16 +42,17 @@ | ||
| 40 | 42 | } |
| 41 | 43 | return &ircConnector{ |
| 42 | 44 | http: cfg.HTTPClient, |
| 43 | 45 | apiURL: stringsTrimRightSlash(cfg.URL), |
| 44 | 46 | token: cfg.Token, |
| 45 | - channel: normalizeChannel(cfg.Channel), | |
| 47 | + primary: normalizeChannel(cfg.Channel), | |
| 46 | 48 | nick: cfg.Nick, |
| 47 | 49 | addr: cfg.IRC.Addr, |
| 48 | 50 | agentType: cfg.IRC.AgentType, |
| 49 | 51 | pass: cfg.IRC.Pass, |
| 50 | 52 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 53 | + channels: append([]string(nil), cfg.Channels...), | |
| 51 | 54 | messages: make([]Message, 0, defaultBufferSize), |
| 52 | 55 | errCh: make(chan error, 1), |
| 53 | 56 | }, nil |
| 54 | 57 | } |
| 55 | 58 | |
| @@ -72,27 +75,29 @@ | ||
| 72 | 75 | User: c.nick, |
| 73 | 76 | Name: c.nick + " (session relay)", |
| 74 | 77 | SASL: &girc.SASLPlain{User: c.nick, Pass: c.pass}, |
| 75 | 78 | }) |
| 76 | 79 | 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 | + } | |
| 78 | 83 | }) |
| 79 | 84 | client.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) { |
| 80 | 85 | if len(e.Params) < 1 || e.Source == nil || e.Source.Name != c.nick { |
| 81 | 86 | return |
| 82 | 87 | } |
| 83 | - if normalizeChannel(e.Params[0]) != c.channel { | |
| 88 | + if normalizeChannel(e.Params[0]) != c.primary { | |
| 84 | 89 | return |
| 85 | 90 | } |
| 86 | 91 | joinOnce.Do(func() { close(joined) }) |
| 87 | 92 | }) |
| 88 | 93 | client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 89 | 94 | if len(e.Params) < 1 || e.Source == nil { |
| 90 | 95 | return |
| 91 | 96 | } |
| 92 | 97 | target := normalizeChannel(e.Params[0]) |
| 93 | - if target != c.channel { | |
| 98 | + if !c.hasChannel(target) { | |
| 94 | 99 | return |
| 95 | 100 | } |
| 96 | 101 | sender := e.Source.Name |
| 97 | 102 | text := strings.TrimSpace(e.Last()) |
| 98 | 103 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| @@ -99,11 +104,11 @@ | ||
| 99 | 104 | if end := strings.Index(text, "] "); end != -1 { |
| 100 | 105 | sender = text[1:end] |
| 101 | 106 | text = strings.TrimSpace(text[end+2:]) |
| 102 | 107 | } |
| 103 | 108 | } |
| 104 | - c.appendMessage(Message{At: time.Now(), Nick: sender, Text: text}) | |
| 109 | + c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text}) | |
| 105 | 110 | }) |
| 106 | 111 | |
| 107 | 112 | c.client = client |
| 108 | 113 | go func() { |
| 109 | 114 | if err := client.Connect(); err != nil && ctx.Err() == nil { |
| @@ -128,11 +133,25 @@ | ||
| 128 | 133 | |
| 129 | 134 | func (c *ircConnector) Post(_ context.Context, text string) error { |
| 130 | 135 | if c.client == nil { |
| 131 | 136 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 132 | 137 | } |
| 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) | |
| 134 | 153 | return nil |
| 135 | 154 | } |
| 136 | 155 | |
| 137 | 156 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 138 | 157 | c.mu.RLock() |
| @@ -148,10 +167,68 @@ | ||
| 148 | 167 | } |
| 149 | 168 | |
| 150 | 169 | func (c *ircConnector) Touch(context.Context) error { |
| 151 | 170 | return nil |
| 152 | 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 | +} | |
| 153 | 230 | |
| 154 | 231 | func (c *ircConnector) Close(ctx context.Context) error { |
| 155 | 232 | if c.client != nil { |
| 156 | 233 | c.client.Close() |
| 157 | 234 | } |
| @@ -187,11 +264,11 @@ | ||
| 187 | 264 | |
| 188 | 265 | func (c *ircConnector) registerOrRotate(ctx context.Context) (bool, string, error) { |
| 189 | 266 | body, _ := json.Marshal(map[string]any{ |
| 190 | 267 | "nick": c.nick, |
| 191 | 268 | "type": c.agentType, |
| 192 | - "channels": []string{c.channel}, | |
| 269 | + "channels": c.Channels(), | |
| 193 | 270 | }) |
| 194 | 271 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/v1/agents/register", bytes.NewReader(body)) |
| 195 | 272 | if err != nil { |
| 196 | 273 | return false, "", err |
| 197 | 274 | } |
| @@ -266,10 +343,16 @@ | ||
| 266 | 343 | return fmt.Errorf("sessionrelay: delete %s: %s", c.nick, resp.Status) |
| 267 | 344 | } |
| 268 | 345 | c.registeredByRelay = false |
| 269 | 346 | return nil |
| 270 | 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 | +} | |
| 271 | 354 | |
| 272 | 355 | func splitHostPort(addr string) (string, int, error) { |
| 273 | 356 | host, portStr, err := net.SplitHostPort(addr) |
| 274 | 357 | if err != nil { |
| 275 | 358 | return "", 0, fmt.Errorf("sessionrelay: invalid irc address %q: %w", addr, err) |
| 276 | 359 |
| --- 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 |
+38
-4
| --- pkg/sessionrelay/sessionrelay.go | ||
| +++ pkg/sessionrelay/sessionrelay.go | ||
| @@ -23,10 +23,11 @@ | ||
| 23 | 23 | type Config struct { |
| 24 | 24 | Transport Transport |
| 25 | 25 | URL string |
| 26 | 26 | Token string |
| 27 | 27 | Channel string |
| 28 | + Channels []string | |
| 28 | 29 | Nick string |
| 29 | 30 | HTTPClient *http.Client |
| 30 | 31 | IRC IRCConfig |
| 31 | 32 | } |
| 32 | 33 | |
| @@ -36,20 +37,26 @@ | ||
| 36 | 37 | AgentType string |
| 37 | 38 | DeleteOnClose bool |
| 38 | 39 | } |
| 39 | 40 | |
| 40 | 41 | 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 | |
| 44 | 46 | } |
| 45 | 47 | |
| 46 | 48 | type Connector interface { |
| 47 | 49 | Connect(ctx context.Context) error |
| 48 | 50 | Post(ctx context.Context, text string) error |
| 51 | + PostTo(ctx context.Context, channel, text string) error | |
| 49 | 52 | MessagesSince(ctx context.Context, since time.Time) ([]Message, error) |
| 50 | 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 | |
| 51 | 58 | Close(ctx context.Context) error |
| 52 | 59 | } |
| 53 | 60 | |
| 54 | 61 | func New(cfg Config) (Connector, error) { |
| 55 | 62 | cfg = withDefaults(cfg) |
| @@ -76,16 +83,20 @@ | ||
| 76 | 83 | } |
| 77 | 84 | if cfg.IRC.AgentType == "" { |
| 78 | 85 | cfg.IRC.AgentType = "worker" |
| 79 | 86 | } |
| 80 | 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 | + } | |
| 81 | 92 | cfg.Transport = Transport(strings.ToLower(string(cfg.Transport))) |
| 82 | 93 | return cfg |
| 83 | 94 | } |
| 84 | 95 | |
| 85 | 96 | func validateBaseConfig(cfg Config) error { |
| 86 | - if cfg.Channel == "" { | |
| 97 | + if cfg.Channel == "" || len(cfg.Channels) == 0 { | |
| 87 | 98 | return fmt.Errorf("sessionrelay: channel is required") |
| 88 | 99 | } |
| 89 | 100 | if cfg.Nick == "" { |
| 90 | 101 | return fmt.Errorf("sessionrelay: nick is required") |
| 91 | 102 | } |
| @@ -104,5 +115,28 @@ | ||
| 104 | 115 | } |
| 105 | 116 | |
| 106 | 117 | func channelSlug(channel string) string { |
| 107 | 118 | return strings.TrimPrefix(normalizeChannel(channel), "#") |
| 108 | 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 | +} | |
| 109 | 143 |
| --- 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 |
+141
-54
| --- pkg/sessionrelay/sessionrelay_test.go | ||
| +++ pkg/sessionrelay/sessionrelay_test.go | ||
| @@ -3,40 +3,64 @@ | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "encoding/json" |
| 6 | 6 | "net/http" |
| 7 | 7 | "net/http/httptest" |
| 8 | + "os" | |
| 9 | + "slices" | |
| 8 | 10 | "testing" |
| 9 | 11 | "time" |
| 10 | 12 | ) |
| 11 | 13 | |
| 12 | 14 | func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) { |
| 13 | 15 | t.Helper() |
| 14 | 16 | |
| 15 | 17 | 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 | |
| 19 | 21 | |
| 20 | 22 | 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")) | |
| 22 | 24 | switch { |
| 23 | 25 | 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) | |
| 26 | 36 | } |
| 37 | + posted = append(posted, "release:"+body["nick"]+":"+body["text"]) | |
| 27 | 38 | w.WriteHeader(http.StatusNoContent) |
| 28 | 39 | 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) | |
| 31 | 50 | } |
| 51 | + touched = append(touched, "release:"+body["nick"]) | |
| 32 | 52 | w.WriteHeader(http.StatusNoContent) |
| 33 | 53 | case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages": |
| 34 | 54 | _ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{ |
| 35 | 55 | {"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"}, |
| 36 | 56 | {"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"}, |
| 37 | 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 | + }}) | |
| 38 | 62 | default: |
| 39 | 63 | http.NotFound(w, r) |
| 40 | 64 | } |
| 41 | 65 | })) |
| 42 | 66 | defer srv.Close() |
| @@ -44,10 +68,11 @@ | ||
| 44 | 68 | conn, err := New(Config{ |
| 45 | 69 | Transport: TransportHTTP, |
| 46 | 70 | URL: srv.URL, |
| 47 | 71 | Token: "test-token", |
| 48 | 72 | Channel: "general", |
| 73 | + Channels: []string{"general", "release"}, | |
| 49 | 74 | Nick: "codex-test", |
| 50 | 75 | HTTPClient: srv.Client(), |
| 51 | 76 | }) |
| 52 | 77 | if err != nil { |
| 53 | 78 | t.Fatal(err) |
| @@ -56,71 +81,88 @@ | ||
| 56 | 81 | t.Fatal(err) |
| 57 | 82 | } |
| 58 | 83 | if err := conn.Post(context.Background(), "online"); err != nil { |
| 59 | 84 | t.Fatal(err) |
| 60 | 85 | } |
| 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) | |
| 63 | 88 | } |
| 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 | + } | |
| 66 | 93 | } |
| 67 | 94 | |
| 68 | 95 | msgs, err := conn.MessagesSince(context.Background(), base) |
| 69 | 96 | if err != nil { |
| 70 | 97 | t.Fatal(err) |
| 71 | 98 | } |
| 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) | |
| 112 | 146 | } |
| 113 | 147 | } |
| 114 | 148 | |
| 115 | 149 | func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) { |
| 116 | 150 | t.Helper() |
| 117 | 151 | |
| 118 | 152 | var deletedPath string |
| 153 | + var registerChannels []string | |
| 119 | 154 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 120 | 155 | switch { |
| 121 | 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 | |
| 122 | 164 | w.WriteHeader(http.StatusCreated) |
| 123 | 165 | _ = json.NewEncoder(w).Encode(map[string]any{ |
| 124 | 166 | "credentials": map[string]string{"passphrase": "created-pass"}, |
| 125 | 167 | }) |
| 126 | 168 | case r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/codex-1234": |
| @@ -135,11 +177,12 @@ | ||
| 135 | 177 | conn := &ircConnector{ |
| 136 | 178 | http: srv.Client(), |
| 137 | 179 | apiURL: srv.URL, |
| 138 | 180 | token: "test-token", |
| 139 | 181 | nick: "codex-1234", |
| 140 | - channel: "#general", | |
| 182 | + primary: "#general", | |
| 183 | + channels: []string{"#general", "#release"}, | |
| 141 | 184 | agentType: "worker", |
| 142 | 185 | deleteOnClose: true, |
| 143 | 186 | } |
| 144 | 187 | |
| 145 | 188 | created, pass, err := conn.registerOrRotate(context.Background()) |
| @@ -146,10 +189,13 @@ | ||
| 146 | 189 | if err != nil { |
| 147 | 190 | t.Fatal(err) |
| 148 | 191 | } |
| 149 | 192 | if !created || pass != "created-pass" { |
| 150 | 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) | |
| 151 | 197 | } |
| 152 | 198 | conn.registeredByRelay = created |
| 153 | 199 | if err := conn.cleanupRegistration(context.Background()); err != nil { |
| 154 | 200 | t.Fatal(err) |
| 155 | 201 | } |
| @@ -178,11 +224,12 @@ | ||
| 178 | 224 | conn := &ircConnector{ |
| 179 | 225 | http: srv.Client(), |
| 180 | 226 | apiURL: srv.URL, |
| 181 | 227 | token: "test-token", |
| 182 | 228 | nick: "codex-1234", |
| 183 | - channel: "#general", | |
| 229 | + primary: "#general", | |
| 230 | + channels: []string{"#general"}, | |
| 184 | 231 | agentType: "worker", |
| 185 | 232 | } |
| 186 | 233 | |
| 187 | 234 | created, pass, err := conn.registerOrRotate(context.Background()) |
| 188 | 235 | if err != nil { |
| @@ -193,5 +240,45 @@ | ||
| 193 | 240 | } |
| 194 | 241 | if !rotateCalled || pass != "rotated-pass" { |
| 195 | 242 | t.Fatalf("rotate fallback = (called=%v, pass=%q)", rotateCalled, pass) |
| 196 | 243 | } |
| 197 | 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 | +} | |
| 198 | 285 |
| --- 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 |
+7
-1
| --- skills/gemini-relay/FLEET.md | ||
| +++ skills/gemini-relay/FLEET.md | ||
| @@ -123,23 +123,29 @@ | ||
| 123 | 123 | - force a fixed nick across sessions |
| 124 | 124 | - require IRC to be up at install time |
| 125 | 125 | |
| 126 | 126 | Useful shared env knobs: |
| 127 | 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 | |
| 128 | 130 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc` |
| 129 | 131 | - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention |
| 130 | 132 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit |
| 131 | 133 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Gemini session when it appears busy |
| 132 | 134 | - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages |
| 133 | 135 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` controls HTTP presence touches; set `0` to disable |
| 134 | 136 | - `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS=6` caps how many IRC messages one final Gemini reply may emit |
| 135 | 137 | - `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 | |
| 138 | 139 | Installer auth knobs: |
| 139 | 140 | - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks |
| 140 | 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` | |
| 141 | 147 | |
| 142 | 148 | ## Operator workflow |
| 143 | 149 | |
| 144 | 150 | 1. Watch the configured channel in scuttlebot. |
| 145 | 151 | 2. Wait for a new `gemini-{repo}-{session}` online post. |
| 146 | 152 |
| --- 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 @@ | ||
| 15 | 15 | connector with `http` and `irc` transports. |
| 16 | 16 | |
| 17 | 17 | Gemini and Codex are the canonical terminal-broker reference implementations in |
| 18 | 18 | this repo. The shared path and convention contract lives in |
| 19 | 19 | `skills/scuttlebot-relay/ADDING_AGENTS.md`. |
| 20 | +For generic install/config work across runtimes, use `skills/scuttlebot-relay/SKILL.md`. | |
| 20 | 21 | |
| 21 | 22 | Gemini CLI itself supports a broad native hook surface, including |
| 22 | 23 | `SessionStart`, `SessionEnd`, `BeforeAgent`, `AfterAgent`, `BeforeToolSelection`, |
| 23 | 24 | `BeforeTool`, `AfterTool`, `BeforeModel`, `AfterModel`, `Notification`, and |
| 24 | 25 | `PreCompress`. In this repo, the relay integration intentionally uses the broker |
| 25 | 26 |
| --- 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 @@ | ||
| 89 | 89 | - `curl` and `jq` available on `PATH` |
| 90 | 90 | |
| 91 | 91 | Optional: |
| 92 | 92 | - `SCUTTLEBOT_NICK` |
| 93 | 93 | - `SCUTTLEBOT_SESSION_ID` |
| 94 | +- `SCUTTLEBOT_CHANNELS` | |
| 95 | +- `SCUTTLEBOT_CHANNEL_STATE_FILE` | |
| 94 | 96 | - `SCUTTLEBOT_TRANSPORT` |
| 95 | 97 | - `SCUTTLEBOT_IRC_ADDR` |
| 96 | 98 | - `SCUTTLEBOT_IRC_PASS` |
| 97 | 99 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` |
| 98 | 100 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| @@ -107,19 +109,21 @@ | ||
| 107 | 109 | |
| 108 | 110 | ```bash |
| 109 | 111 | export SCUTTLEBOT_URL=http://localhost:8080 |
| 110 | 112 | export SCUTTLEBOT_TOKEN=$(./run.sh token) |
| 111 | 113 | export SCUTTLEBOT_CHANNEL=general |
| 114 | +export SCUTTLEBOT_CHANNELS=general,task-42 | |
| 112 | 115 | ``` |
| 113 | 116 | |
| 114 | 117 | The hooks also auto-load a shared relay env file if it exists: |
| 115 | 118 | |
| 116 | 119 | ```bash |
| 117 | 120 | cat > ~/.config/scuttlebot-relay.env <<'EOF2' |
| 118 | 121 | SCUTTLEBOT_URL=http://localhost:8080 |
| 119 | 122 | SCUTTLEBOT_TOKEN=... |
| 120 | 123 | SCUTTLEBOT_CHANNEL=general |
| 124 | +SCUTTLEBOT_CHANNELS=general | |
| 121 | 125 | SCUTTLEBOT_TRANSPORT=http |
| 122 | 126 | SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 |
| 123 | 127 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 124 | 128 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 125 | 129 | SCUTTLEBOT_POLL_INTERVAL=2s |
| @@ -143,11 +147,12 @@ | ||
| 143 | 147 | |
| 144 | 148 | ```bash |
| 145 | 149 | bash skills/gemini-relay/scripts/install-gemini-relay.sh \ |
| 146 | 150 | --url http://localhost:8080 \ |
| 147 | 151 | --token "$(./run.sh token)" \ |
| 148 | - --channel general | |
| 152 | + --channel general \ | |
| 153 | + --channels general,task-42 | |
| 149 | 154 | ``` |
| 150 | 155 | |
| 151 | 156 | Manual path: |
| 152 | 157 | |
| 153 | 158 | Install the scripts: |
| @@ -220,16 +225,19 @@ | ||
| 220 | 225 | Ambient channel chat must not halt a live tool loop. |
| 221 | 226 | |
| 222 | 227 | ## Operational notes |
| 223 | 228 | |
| 224 | 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. | |
| 225 | 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. |
| 226 | 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. | |
| 227 | 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. |
| 228 | 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. |
| 229 | 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. |
| 230 | 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. |
| 231 | 239 | - `SCUTTLEBOT_AFTER_AGENT_MAX_POSTS` defaults to `6`; `SCUTTLEBOT_AFTER_AGENT_CHUNK_WIDTH` defaults to `360`. |
| 232 | 240 | - If scuttlebot is down or unreachable, the hooks soft-fail and return quickly. |
| 233 | 241 | - `SCUTTLEBOT_HOOKS_ENABLED=0` disables all Gemini relay hooks explicitly. |
| 234 | 242 | - They should remain in the repo as installable reference files. |
| 235 | 243 | - Do not bake tokens into the scripts. Use environment variables. |
| 236 | 244 |
| --- 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 @@ | ||
| 5 | 5 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 6 | 6 | set -a |
| 7 | 7 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 8 | 8 | set +a |
| 9 | 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 | |
| 10 | 15 | |
| 11 | 16 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 12 | 17 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 13 | 18 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 14 | 19 | SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 15 | 20 | SCUTTLEBOT_AFTER_AGENT_MAX_POSTS="${SCUTTLEBOT_AFTER_AGENT_MAX_POSTS:-6}" |
| 16 | 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 | +} | |
| 17 | 47 | |
| 18 | 48 | sanitize() { |
| 19 | 49 | local input="$1" |
| 20 | 50 | if [ -z "$input" ]; then |
| 21 | 51 | input=$(cat) |
| @@ -23,18 +53,22 @@ | ||
| 23 | 53 | printf '%s' "$input" | tr -cs '[:alnum:]_-' '-' |
| 24 | 54 | } |
| 25 | 55 | |
| 26 | 56 | post_line() { |
| 27 | 57 | local text="$1" |
| 58 | + local payload | |
| 28 | 59 | [ -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 | |
| 36 | 70 | } |
| 37 | 71 | |
| 38 | 72 | normalize_response() { |
| 39 | 73 | printf '%s' "$1" \ |
| 40 | 74 | | tr '\r\n\t' ' ' \ |
| 41 | 75 |
| --- 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 @@ | ||
| 7 | 7 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 8 | 8 | set -a |
| 9 | 9 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 10 | 10 | set +a |
| 11 | 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 | |
| 12 | 17 | |
| 13 | 18 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 14 | 19 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 15 | 20 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 16 | 21 | SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| @@ -20,10 +25,49 @@ | ||
| 20 | 25 | if [ -z "$input" ]; then |
| 21 | 26 | input=$(cat) |
| 22 | 27 | fi |
| 23 | 28 | printf '%s' "$input" | tr -cs '[:alnum:]_-' '-' |
| 24 | 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 | +} | |
| 25 | 69 | |
| 26 | 70 | base_name=$(basename "$(pwd)") |
| 27 | 71 | base_name=$(sanitize "$base_name") |
| 28 | 72 | session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}" |
| 29 | 73 | if [ -z "$session_raw" ] || [ "$session_raw" = "0" ]; then |
| @@ -35,56 +79,49 @@ | ||
| 35 | 79 | |
| 36 | 80 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; } |
| 37 | 81 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && { echo '{}'; exit 0; } |
| 38 | 82 | [ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; } |
| 39 | 83 | |
| 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}') | |
| 41 | 85 | LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key" |
| 42 | 86 | |
| 43 | -contains_mention() { | |
| 44 | - local text="$1" | |
| 45 | - [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]] | |
| 46 | -} | |
| 47 | - | |
| 48 | 87 | last_check=0 |
| 49 | 88 | if [ -f "$LAST_CHECK_FILE" ]; then |
| 50 | 89 | last_check=$(cat "$LAST_CHECK_FILE") |
| 51 | 90 | fi |
| 52 | 91 | now=$(date +%s) |
| 53 | 92 | echo "$now" > "$LAST_CHECK_FILE" |
| 54 | 93 | |
| 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 | 94 | BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]' |
| 64 | 95 | |
| 65 | 96 | 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") | |
| 80 | 117 | [ -n "$ts" ] || continue |
| 81 | 118 | [ "$ts" -gt "$last_check" ] || continue |
| 82 | 119 | 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- | |
| 85 | 122 | ) |
| 86 | 123 | |
| 87 | 124 | [ -z "$instruction" ] && { echo '{}'; exit 0; } |
| 88 | 125 | |
| 89 | 126 | echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}" |
| 90 | 127 | exit 0 |
| 91 | 128 |
| --- 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 @@ | ||
| 5 | 5 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 6 | 6 | set -a |
| 7 | 7 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 8 | 8 | set +a |
| 9 | 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 | |
| 10 | 15 | |
| 11 | 16 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 12 | 17 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 13 | 18 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 14 | 19 | 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 | +} | |
| 16 | 60 | |
| 17 | 61 | sanitize() { |
| 18 | 62 | local input="$1" |
| 19 | 63 | if [ -z "$input" ]; then |
| 20 | 64 | input=$(cat) |
| @@ -39,12 +83,10 @@ | ||
| 39 | 83 | default_nick="gemini-${base_name}-${session_suffix}" |
| 40 | 84 | SCUTTLEBOT_NICK="${SCUTTLEBOT_NICK:-$default_nick}" |
| 41 | 85 | |
| 42 | 86 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && { echo '{}'; exit 0; } |
| 43 | 87 | [ "$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 | 88 | [ -z "$SCUTTLEBOT_TOKEN" ] && { echo '{}'; exit 0; } |
| 47 | 89 | |
| 48 | 90 | case "$tool" in |
| 49 | 91 | run_shell_command|Bash) |
| 50 | 92 | cmd=$(echo "$input" | jq -r '.tool_input.command // empty' | head -c 120) |
| @@ -98,15 +140,9 @@ | ||
| 98 | 140 | ;; |
| 99 | 141 | esac |
| 100 | 142 | |
| 101 | 143 | [ -z "$msg" ] && { echo '{}'; exit 0; } |
| 102 | 144 | |
| 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" | |
| 110 | 146 | |
| 111 | 147 | echo '{}' |
| 112 | 148 | exit 0 |
| 113 | 149 |
| --- 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 |
+13
-1
| --- skills/gemini-relay/install.md | ||
| +++ skills/gemini-relay/install.md | ||
| @@ -34,10 +34,11 @@ | ||
| 34 | 34 | |
| 35 | 35 | ## Install (Gemini CLI) |
| 36 | 36 | Detailed primer: [`hooks/README.md`](hooks/README.md) |
| 37 | 37 | Shared fleet guide: [`FLEET.md`](FLEET.md) |
| 38 | 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) | |
| 39 | 40 | |
| 40 | 41 | Canonical pattern summary: |
| 41 | 42 | - broker entrypoint: `cmd/gemini-relay/main.go` |
| 42 | 43 | - tracked installer: `skills/gemini-relay/scripts/install-gemini-relay.sh` |
| 43 | 44 | - runtime docs: `skills/gemini-relay/install.md` and `skills/gemini-relay/FLEET.md` |
| @@ -50,11 +51,12 @@ | ||
| 50 | 51 | |
| 51 | 52 | ```bash |
| 52 | 53 | bash skills/gemini-relay/scripts/install-gemini-relay.sh \ |
| 53 | 54 | --url http://localhost:8080 \ |
| 54 | 55 | --token "$(./run.sh token)" \ |
| 55 | - --channel general | |
| 56 | + --channel general \ | |
| 57 | + --channels general,task-42 | |
| 56 | 58 | ``` |
| 57 | 59 | |
| 58 | 60 | Or via Make: |
| 59 | 61 | |
| 60 | 62 | ```bash |
| @@ -89,10 +91,12 @@ | ||
| 89 | 91 | |
| 90 | 92 | ## Configuration |
| 91 | 93 | |
| 92 | 94 | Useful shared env knobs in `~/.config/scuttlebot-relay.env`: |
| 93 | 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 | |
| 94 | 98 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` — sets the real IRC address when transport is `irc` |
| 95 | 99 | - `SCUTTLEBOT_IRC_PASS=...` — uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention |
| 96 | 100 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` — keeps auto-registered session nicks after clean exit |
| 97 | 101 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Gemini session when it appears busy |
| 98 | 102 | - `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages |
| @@ -102,5 +106,13 @@ | ||
| 102 | 106 | |
| 103 | 107 | Disable without uninstalling: |
| 104 | 108 | ```bash |
| 105 | 109 | SCUTTLEBOT_HOOKS_ENABLED=0 gemini-relay |
| 106 | 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. | |
| 107 | 119 |
| --- 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | Options: |
| 12 | 12 | --url URL Set SCUTTLEBOT_URL in the shared env file. |
| 13 | 13 | --token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
| 14 | 14 | --channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
| 15 | + --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file. | |
| 15 | 16 | --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http. |
| 16 | 17 | --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667. |
| 17 | 18 | --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode. |
| 18 | 19 | --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default. |
| 19 | 20 | --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
| @@ -26,10 +27,11 @@ | ||
| 26 | 27 | |
| 27 | 28 | Environment defaults: |
| 28 | 29 | SCUTTLEBOT_URL |
| 29 | 30 | SCUTTLEBOT_TOKEN |
| 30 | 31 | SCUTTLEBOT_CHANNEL |
| 32 | + SCUTTLEBOT_CHANNELS | |
| 31 | 33 | SCUTTLEBOT_TRANSPORT |
| 32 | 34 | SCUTTLEBOT_IRC_ADDR |
| 33 | 35 | SCUTTLEBOT_IRC_PASS |
| 34 | 36 | SCUTTLEBOT_HOOKS_ENABLED |
| 35 | 37 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
| @@ -54,10 +56,11 @@ | ||
| 54 | 56 | REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
| 55 | 57 | |
| 56 | 58 | SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
| 57 | 59 | SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
| 58 | 60 | SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
| 61 | +SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}" | |
| 59 | 62 | SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}" |
| 60 | 63 | SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}" |
| 61 | 64 | if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then |
| 62 | 65 | SCUTTLEBOT_IRC_PASS_MODE="fixed" |
| 63 | 66 | SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS" |
| @@ -87,10 +90,14 @@ | ||
| 87 | 90 | shift 2 |
| 88 | 91 | ;; |
| 89 | 92 | --channel) |
| 90 | 93 | SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
| 91 | 94 | shift 2 |
| 95 | + ;; | |
| 96 | + --channels) | |
| 97 | + SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}" | |
| 98 | + shift 2 | |
| 92 | 99 | ;; |
| 93 | 100 | --transport) |
| 94 | 101 | SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}" |
| 95 | 102 | shift 2 |
| 96 | 103 | ;; |
| @@ -159,10 +166,52 @@ | ||
| 159 | 166 | } |
| 160 | 167 | |
| 161 | 168 | ensure_parent_dir() { |
| 162 | 169 | mkdir -p "$(dirname "$1")" |
| 163 | 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 | |
| 164 | 213 | |
| 165 | 214 | upsert_env_var() { |
| 166 | 215 | local file="$1" |
| 167 | 216 | local key="$2" |
| 168 | 217 | local value="$3" |
| @@ -296,10 +345,13 @@ | ||
| 296 | 345 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
| 297 | 346 | fi |
| 298 | 347 | if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
| 299 | 348 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
| 300 | 349 | fi |
| 350 | +if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then | |
| 351 | + upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE" | |
| 352 | +fi | |
| 301 | 353 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE" |
| 302 | 354 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE" |
| 303 | 355 | if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then |
| 304 | 356 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE" |
| 305 | 357 | else |
| 306 | 358 |
| --- 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 @@ | ||
| 124 | 124 | - force a fixed nick across sessions |
| 125 | 125 | - require IRC to be up at install time |
| 126 | 126 | |
| 127 | 127 | Useful shared env knobs: |
| 128 | 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 | |
| 129 | 131 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc` |
| 130 | 132 | - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention |
| 131 | 133 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit |
| 132 | 134 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Codex session only when Codex appears busy; idle sessions are injected directly and auto-submitted |
| 133 | 135 | - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages |
| @@ -135,10 +137,15 @@ | ||
| 135 | 137 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts |
| 136 | 138 | |
| 137 | 139 | Installer auth knobs: |
| 138 | 140 | - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks |
| 139 | 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` | |
| 140 | 147 | |
| 141 | 148 | ## Operator workflow |
| 142 | 149 | |
| 143 | 150 | 1. Watch the configured channel in scuttlebot. |
| 144 | 151 | 2. Wait for a new `codex-{repo}-{session}` online post. |
| 145 | 152 |
| --- 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 @@ | ||
| 14 | 14 | hooks, and accept addressed instructions continuously while the session is running. |
| 15 | 15 | |
| 16 | 16 | Codex and Gemini are the canonical terminal-broker reference implementations in |
| 17 | 17 | this repo. The shared path and convention contract lives in |
| 18 | 18 | `skills/scuttlebot-relay/ADDING_AGENTS.md`. |
| 19 | +For generic install/config work across runtimes, use `skills/scuttlebot-relay/SKILL.md`. | |
| 19 | 20 | |
| 20 | 21 | Source-of-truth files in the repo: |
| 21 | 22 | - installer: `skills/openai-relay/scripts/install-codex-relay.sh` |
| 22 | 23 | - broker: `cmd/codex-relay/main.go` |
| 23 | 24 | - shared connector: `pkg/sessionrelay/` |
| 24 | 25 |
| --- 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 @@ | ||
| 75 | 75 | - `curl` and `jq` available on `PATH` |
| 76 | 76 | |
| 77 | 77 | Optional: |
| 78 | 78 | - `SCUTTLEBOT_NICK` |
| 79 | 79 | - `SCUTTLEBOT_SESSION_ID` |
| 80 | +- `SCUTTLEBOT_CHANNELS` | |
| 81 | +- `SCUTTLEBOT_CHANNEL_STATE_FILE` | |
| 80 | 82 | - `SCUTTLEBOT_TRANSPORT` |
| 81 | 83 | - `SCUTTLEBOT_IRC_ADDR` |
| 82 | 84 | - `SCUTTLEBOT_IRC_PASS` |
| 83 | 85 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` |
| 84 | 86 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| @@ -92,19 +94,21 @@ | ||
| 92 | 94 | |
| 93 | 95 | ```bash |
| 94 | 96 | export SCUTTLEBOT_URL=http://localhost:8080 |
| 95 | 97 | export SCUTTLEBOT_TOKEN=$(./run.sh token) |
| 96 | 98 | export SCUTTLEBOT_CHANNEL=general |
| 99 | +export SCUTTLEBOT_CHANNELS=general,task-42 | |
| 97 | 100 | ``` |
| 98 | 101 | |
| 99 | 102 | The hooks also auto-load a shared relay env file if it exists: |
| 100 | 103 | |
| 101 | 104 | ```bash |
| 102 | 105 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 103 | 106 | SCUTTLEBOT_URL=http://localhost:8080 |
| 104 | 107 | SCUTTLEBOT_TOKEN=... |
| 105 | 108 | SCUTTLEBOT_CHANNEL=general |
| 109 | +SCUTTLEBOT_CHANNELS=general | |
| 106 | 110 | SCUTTLEBOT_TRANSPORT=http |
| 107 | 111 | SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 |
| 108 | 112 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 109 | 113 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 110 | 114 | SCUTTLEBOT_POLL_INTERVAL=2s |
| @@ -128,11 +132,12 @@ | ||
| 128 | 132 | |
| 129 | 133 | ```bash |
| 130 | 134 | bash skills/openai-relay/scripts/install-codex-relay.sh \ |
| 131 | 135 | --url http://localhost:8080 \ |
| 132 | 136 | --token "$(./run.sh token)" \ |
| 133 | - --channel general | |
| 137 | + --channel general \ | |
| 138 | + --channels general,task-42 | |
| 134 | 139 | ``` |
| 135 | 140 | |
| 136 | 141 | Manual path: |
| 137 | 142 | |
| 138 | 143 | Install the scripts: |
| @@ -232,13 +237,15 @@ | ||
| 232 | 237 | ```text |
| 233 | 238 | /tmp/.scuttlebot-last-check-{checksum} |
| 234 | 239 | ``` |
| 235 | 240 | |
| 236 | 241 | The checksum is derived from: |
| 237 | -- channel | |
| 238 | 242 | - session nick |
| 239 | 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. | |
| 240 | 247 | |
| 241 | 248 | That avoids one session consuming another session's instructions. |
| 242 | 249 | |
| 243 | 250 | ## Smoke test |
| 244 | 251 | |
| 245 | 252 |
| --- 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 @@ | ||
| 7 | 7 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 8 | 8 | set -a |
| 9 | 9 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 10 | 10 | set +a |
| 11 | 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 | |
| 12 | 17 | |
| 13 | 18 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 14 | 19 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 15 | 20 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 16 | 21 | SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 17 | 22 | |
| 18 | 23 | sanitize() { |
| 19 | 24 | printf '%s' "$1" | tr -cs '[:alnum:]_-' '-' |
| 20 | 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 | +} | |
| 21 | 65 | |
| 22 | 66 | base_name=$(basename "$(pwd)") |
| 23 | 67 | base_name=$(sanitize "$base_name") |
| 24 | 68 | session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}" |
| 25 | 69 | session_suffix=$(sanitize "$session_suffix") |
| @@ -28,56 +72,49 @@ | ||
| 28 | 72 | |
| 29 | 73 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0 |
| 30 | 74 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0 |
| 31 | 75 | [ -z "$SCUTTLEBOT_TOKEN" ] && exit 0 |
| 32 | 76 | |
| 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}') | |
| 34 | 78 | LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key" |
| 35 | 79 | |
| 36 | -contains_mention() { | |
| 37 | - local text="$1" | |
| 38 | - [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]] | |
| 39 | -} | |
| 40 | - | |
| 41 | 80 | last_check=0 |
| 42 | 81 | if [ -f "$LAST_CHECK_FILE" ]; then |
| 43 | 82 | last_check=$(cat "$LAST_CHECK_FILE") |
| 44 | 83 | fi |
| 45 | 84 | now=$(date +%s) |
| 46 | 85 | echo "$now" > "$LAST_CHECK_FILE" |
| 47 | 86 | |
| 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 | 87 | BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]' |
| 57 | 88 | |
| 58 | 89 | 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") | |
| 73 | 110 | [ -n "$ts" ] || continue |
| 74 | 111 | [ "$ts" -gt "$last_check" ] || continue |
| 75 | 112 | 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- | |
| 78 | 115 | ) |
| 79 | 116 | |
| 80 | 117 | [ -z "$instruction" ] && exit 0 |
| 81 | 118 | |
| 82 | 119 | echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}" |
| 83 | 120 | exit 0 |
| 84 | 121 |
| --- 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 @@ | ||
| 5 | 5 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 6 | 6 | set -a |
| 7 | 7 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 8 | 8 | set +a |
| 9 | 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 | |
| 10 | 15 | |
| 11 | 16 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 12 | 17 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 13 | 18 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 14 | 19 | SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 15 | 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 | +} | |
| 16 | 61 | |
| 17 | 62 | input=$(cat) |
| 18 | 63 | |
| 19 | 64 | tool=$(echo "$input" | jq -r '.tool_name // empty') |
| 20 | 65 | cwd=$(echo "$input" | jq -r '.cwd // empty') |
| @@ -72,14 +117,7 @@ | ||
| 72 | 117 | ;; |
| 73 | 118 | esac |
| 74 | 119 | |
| 75 | 120 | [ -z "$msg" ] && exit 0 |
| 76 | 121 | |
| 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" | |
| 85 | 123 | exit 0 |
| 86 | 124 |
| --- 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 |
+14
-1
| --- skills/openai-relay/install.md | ||
| +++ skills/openai-relay/install.md | ||
| @@ -35,10 +35,11 @@ | ||
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | 37 | ## Preferred For Local Codex CLI: codex-relay broker |
| 38 | 38 | Detailed primer: [`hooks/README.md`](hooks/README.md) |
| 39 | 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) | |
| 40 | 41 | Fleet rollout guide: [`FLEET.md`](FLEET.md) |
| 41 | 42 | |
| 42 | 43 | Canonical pattern summary: |
| 43 | 44 | - broker entrypoint: `cmd/codex-relay/main.go` |
| 44 | 45 | - tracked installer: `skills/openai-relay/scripts/install-codex-relay.sh` |
| @@ -52,11 +53,12 @@ | ||
| 52 | 53 | |
| 53 | 54 | ```bash |
| 54 | 55 | bash skills/openai-relay/scripts/install-codex-relay.sh \ |
| 55 | 56 | --url http://localhost:8080 \ |
| 56 | 57 | --token "$(./run.sh token)" \ |
| 57 | - --channel general | |
| 58 | + --channel general \ | |
| 59 | + --channels general,task-42 | |
| 58 | 60 | ``` |
| 59 | 61 | |
| 60 | 62 | This installer: |
| 61 | 63 | - copies the tracked hook scripts into `~/.codex/hooks/` |
| 62 | 64 | - builds and installs `codex-relay` into `~/.local/bin/` |
| @@ -93,10 +95,12 @@ | ||
| 93 | 95 | Common knobs: |
| 94 | 96 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` |
| 95 | 97 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` |
| 96 | 98 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1` |
| 97 | 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 | |
| 98 | 102 | |
| 99 | 103 | Installer auth modes: |
| 100 | 104 | - default: omit `SCUTTLEBOT_IRC_PASS` and let the broker auto-register the session nick |
| 101 | 105 | - `--irc-pass <passphrase>`: pin a fixed NickServ password in the shared env file |
| 102 | 106 | - `--auto-register`: remove any stale `SCUTTLEBOT_IRC_PASS` entry from the shared env file |
| @@ -210,10 +214,19 @@ | ||
| 210 | 214 | - `SCUTTLEBOT_TRANSPORT=irc` switches from the HTTP bridge path to a real IRC socket |
| 211 | 215 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` points the real IRC transport at Ergo |
| 212 | 216 | - `SCUTTLEBOT_IRC_PASS=<passphrase>` skips auto-registration and uses a fixed NickServ password; leave it unset for the default broker convention |
| 213 | 217 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=0` disables HTTP presence heartbeats |
| 214 | 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. | |
| 215 | 228 | |
| 216 | 229 | If you want `codex` itself to always use the wrapper, prefer a shell alias: |
| 217 | 230 | |
| 218 | 231 | ```bash |
| 219 | 232 | alias codex="$HOME/.local/bin/codex-relay" |
| 220 | 233 |
| --- 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | Options: |
| 12 | 12 | --url URL Set SCUTTLEBOT_URL in the shared env file. |
| 13 | 13 | --token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
| 14 | 14 | --channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
| 15 | + --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file. | |
| 15 | 16 | --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: http. |
| 16 | 17 | --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667. |
| 17 | 18 | --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode. |
| 18 | 19 | --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default. |
| 19 | 20 | --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
| @@ -27,10 +28,11 @@ | ||
| 27 | 28 | |
| 28 | 29 | Environment defaults: |
| 29 | 30 | SCUTTLEBOT_URL |
| 30 | 31 | SCUTTLEBOT_TOKEN |
| 31 | 32 | SCUTTLEBOT_CHANNEL |
| 33 | + SCUTTLEBOT_CHANNELS | |
| 32 | 34 | SCUTTLEBOT_TRANSPORT |
| 33 | 35 | SCUTTLEBOT_IRC_ADDR |
| 34 | 36 | SCUTTLEBOT_IRC_PASS |
| 35 | 37 | SCUTTLEBOT_HOOKS_ENABLED |
| 36 | 38 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
| @@ -54,10 +56,11 @@ | ||
| 54 | 56 | REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
| 55 | 57 | |
| 56 | 58 | SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
| 57 | 59 | SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
| 58 | 60 | SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
| 61 | +SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}" | |
| 59 | 62 | SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-http}" |
| 60 | 63 | SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}" |
| 61 | 64 | if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then |
| 62 | 65 | SCUTTLEBOT_IRC_PASS_MODE="fixed" |
| 63 | 66 | SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS" |
| @@ -88,10 +91,14 @@ | ||
| 88 | 91 | shift 2 |
| 89 | 92 | ;; |
| 90 | 93 | --channel) |
| 91 | 94 | SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
| 92 | 95 | shift 2 |
| 96 | + ;; | |
| 97 | + --channels) | |
| 98 | + SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}" | |
| 99 | + shift 2 | |
| 93 | 100 | ;; |
| 94 | 101 | --transport) |
| 95 | 102 | SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}" |
| 96 | 103 | shift 2 |
| 97 | 104 | ;; |
| @@ -164,10 +171,52 @@ | ||
| 164 | 171 | } |
| 165 | 172 | |
| 166 | 173 | ensure_parent_dir() { |
| 167 | 174 | mkdir -p "$(dirname "$1")" |
| 168 | 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 | |
| 169 | 218 | |
| 170 | 219 | upsert_env_var() { |
| 171 | 220 | local file="$1" |
| 172 | 221 | local key="$2" |
| 173 | 222 | local value="$3" |
| @@ -349,10 +398,13 @@ | ||
| 349 | 398 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
| 350 | 399 | fi |
| 351 | 400 | if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
| 352 | 401 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
| 353 | 402 | fi |
| 403 | +if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then | |
| 404 | + upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE" | |
| 405 | +fi | |
| 354 | 406 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE" |
| 355 | 407 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE" |
| 356 | 408 | if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then |
| 357 | 409 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE" |
| 358 | 410 | else |
| 359 | 411 |
| --- 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 @@ | ||
| 83 | 83 | |
| 84 | 84 | Every terminal broker should follow these conventions: |
| 85 | 85 | - one stable nick per live session: `{runtime}-{basename}-{session}` |
| 86 | 86 | - one shared env contract using `SCUTTLEBOT_*` |
| 87 | 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 | |
| 88 | 89 | - one broker process owning `online` / `offline` |
| 89 | 90 | - one broker process owning continuous addressed operator input injection |
| 90 | 91 | - one broker process owning outbound activity and assistant-message mirroring when the runtime exposes a reliable event/session stream |
| 91 | 92 | - hooks used for pre-action fallback and for runtime-specific gaps such as post-tool summaries or final reply hooks |
| 92 | 93 | - support both `SCUTTLEBOT_TRANSPORT=http` and `SCUTTLEBOT_TRANSPORT=irc` behind the same broker contract |
| @@ -96,10 +97,11 @@ | ||
| 96 | 97 | |
| 97 | 98 | All adapters should use the same environment variables: |
| 98 | 99 | - `SCUTTLEBOT_URL` |
| 99 | 100 | - `SCUTTLEBOT_TOKEN` |
| 100 | 101 | - `SCUTTLEBOT_CHANNEL` |
| 102 | +- `SCUTTLEBOT_CHANNELS` | |
| 101 | 103 | - `SCUTTLEBOT_TRANSPORT` |
| 102 | 104 | |
| 103 | 105 | Optional: |
| 104 | 106 | - `SCUTTLEBOT_NICK` |
| 105 | 107 | - `SCUTTLEBOT_SESSION_ID` |
| @@ -108,15 +110,21 @@ | ||
| 108 | 110 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` |
| 109 | 111 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| 110 | 112 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE` |
| 111 | 113 | - `SCUTTLEBOT_POLL_INTERVAL` |
| 112 | 114 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT` |
| 115 | +- `SCUTTLEBOT_CHANNEL_STATE_FILE` | |
| 113 | 116 | |
| 114 | 117 | Do not hardcode tokens into repo scripts. |
| 115 | 118 | For terminal-session brokers, treat `SCUTTLEBOT_IRC_PASS` as an explicit |
| 116 | 119 | fixed-identity override, not a default. |
| 117 | 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 | + | |
| 118 | 126 | ## Nicking rules |
| 119 | 127 | |
| 120 | 128 | Use a stable, human-addressable session nick. |
| 121 | 129 | |
| 122 | 130 | Requirements: |
| @@ -146,15 +154,16 @@ | ||
| 146 | 154 | ## State scoping |
| 147 | 155 | |
| 148 | 156 | Do not use one global timestamp file. |
| 149 | 157 | |
| 150 | 158 | Track last-seen state by a key derived from: |
| 151 | -- channel | |
| 152 | 159 | - nick |
| 153 | 160 | - working directory |
| 154 | 161 | |
| 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. | |
| 156 | 165 | |
| 157 | 166 | ## HTTP API contract |
| 158 | 167 | |
| 159 | 168 | All adapters use the same scuttlebot HTTP API: |
| 160 | 169 | |
| 161 | 170 |
| --- 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 @@ | ||
| 101 | 101 | - force a fixed nick across sessions |
| 102 | 102 | - require IRC to be up at install time |
| 103 | 103 | |
| 104 | 104 | Useful shared env knobs: |
| 105 | 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 | |
| 106 | 108 | - `SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667` sets the real IRC address when transport is `irc` |
| 107 | 109 | - `SCUTTLEBOT_IRC_PASS=...` uses a fixed NickServ password instead of auto-registration; leave it unset for the default broker convention |
| 108 | 110 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE=0` keeps auto-registered session nicks after clean exit |
| 109 | 111 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` interrupts the live Claude session when it appears busy |
| 110 | 112 | - `SCUTTLEBOT_POLL_INTERVAL=2s` controls how often the broker checks for new addressed IRC messages |
| @@ -112,10 +114,15 @@ | ||
| 112 | 114 | - `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` tells `scuttlebot-post.sh` to stay quiet so broker-launched sessions do not duplicate activity posts |
| 113 | 115 | |
| 114 | 116 | Installer auth knobs: |
| 115 | 117 | - default or `--auto-register`: scrub `SCUTTLEBOT_IRC_PASS` from the shared env file and let the broker auto-register ephemeral session nicks |
| 116 | 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` | |
| 117 | 124 | |
| 118 | 125 | ## Operator workflow |
| 119 | 126 | |
| 120 | 127 | 1. Watch the configured channel in scuttlebot. |
| 121 | 128 | 2. Wait for a new `claude-{repo}-{session}` online post. |
| 122 | 129 | |
| 123 | 130 | ADDED skills/scuttlebot-relay/SKILL.md |
| 124 | 131 | 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 @@ | ||
| 70 | 70 | - `SCUTTLEBOT_TOKEN` |
| 71 | 71 | - `SCUTTLEBOT_CHANNEL` |
| 72 | 72 | |
| 73 | 73 | Optional: |
| 74 | 74 | - `SCUTTLEBOT_NICK` |
| 75 | +- `SCUTTLEBOT_CHANNELS` | |
| 76 | +- `SCUTTLEBOT_CHANNEL_STATE_FILE` | |
| 75 | 77 | - `SCUTTLEBOT_TRANSPORT` |
| 76 | 78 | - `SCUTTLEBOT_IRC_ADDR` |
| 77 | 79 | - `SCUTTLEBOT_IRC_PASS` |
| 78 | 80 | - `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` |
| 79 | 81 | - `SCUTTLEBOT_HOOKS_ENABLED` |
| @@ -87,19 +89,21 @@ | ||
| 87 | 89 | |
| 88 | 90 | ```bash |
| 89 | 91 | export SCUTTLEBOT_URL=http://localhost:8080 |
| 90 | 92 | export SCUTTLEBOT_TOKEN=$(./run.sh token) |
| 91 | 93 | export SCUTTLEBOT_CHANNEL=general |
| 94 | +export SCUTTLEBOT_CHANNELS=general,task-42 | |
| 92 | 95 | ``` |
| 93 | 96 | |
| 94 | 97 | The hooks also auto-load a shared relay env file if it exists: |
| 95 | 98 | |
| 96 | 99 | ```bash |
| 97 | 100 | cat > ~/.config/scuttlebot-relay.env <<'EOF' |
| 98 | 101 | SCUTTLEBOT_URL=http://localhost:8080 |
| 99 | 102 | SCUTTLEBOT_TOKEN=... |
| 100 | 103 | SCUTTLEBOT_CHANNEL=general |
| 104 | +SCUTTLEBOT_CHANNELS=general | |
| 101 | 105 | SCUTTLEBOT_TRANSPORT=irc |
| 102 | 106 | SCUTTLEBOT_IRC_ADDR=127.0.0.1:6667 |
| 103 | 107 | SCUTTLEBOT_HOOKS_ENABLED=1 |
| 104 | 108 | SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1 |
| 105 | 109 | SCUTTLEBOT_POLL_INTERVAL=2s |
| @@ -123,11 +127,12 @@ | ||
| 123 | 127 | |
| 124 | 128 | ```bash |
| 125 | 129 | bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \ |
| 126 | 130 | --url http://localhost:8080 \ |
| 127 | 131 | --token "$(./run.sh token)" \ |
| 128 | - --channel general | |
| 132 | + --channel general \ | |
| 133 | + --channels general,task-42 | |
| 129 | 134 | ``` |
| 130 | 135 | |
| 131 | 136 | Manual path: |
| 132 | 137 | |
| 133 | 138 | ```bash |
| @@ -190,15 +195,18 @@ | ||
| 190 | 195 | glengoolie: someone should inspect the schema |
| 191 | 196 | claude-otherrepo-e5f6a7b8: read config.go |
| 192 | 197 | ``` |
| 193 | 198 | |
| 194 | 199 | The last-check timestamp is stored in a session-scoped file under `/tmp`, keyed by: |
| 195 | -- channel | |
| 196 | 200 | - nick |
| 197 | 201 | - working directory |
| 198 | 202 | |
| 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. | |
| 200 | 208 | |
| 201 | 209 | ## Smoke test |
| 202 | 210 | |
| 203 | 211 | Use the matching commands from `skills/scuttlebot-relay/install.md`, replacing the |
| 204 | 212 | nick in the operator message with your Claude session nick. |
| 205 | 213 |
| --- 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 @@ | ||
| 7 | 7 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 8 | 8 | set -a |
| 9 | 9 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 10 | 10 | set +a |
| 11 | 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 | |
| 12 | 17 | |
| 13 | 18 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 14 | 19 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 15 | 20 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 16 | 21 | SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| @@ -19,10 +24,49 @@ | ||
| 19 | 24 | session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null | head -c 8) |
| 20 | 25 | |
| 21 | 26 | sanitize() { |
| 22 | 27 | printf '%s' "$1" | tr -cs '[:alnum:]_-' '-' |
| 23 | 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 | +} | |
| 24 | 68 | |
| 25 | 69 | cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null) |
| 26 | 70 | if [ -z "$cwd" ]; then cwd=$(pwd); fi |
| 27 | 71 | base_name=$(sanitize "$(basename "$cwd")") |
| 28 | 72 | session_suffix="${session_id:-$PPID}" |
| @@ -31,56 +75,49 @@ | ||
| 31 | 75 | |
| 32 | 76 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "0" ] && exit 0 |
| 33 | 77 | [ "$SCUTTLEBOT_HOOKS_ENABLED" = "false" ] && exit 0 |
| 34 | 78 | [ -z "$SCUTTLEBOT_TOKEN" ] && exit 0 |
| 35 | 79 | |
| 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}') | |
| 37 | 81 | LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key" |
| 38 | 82 | |
| 39 | -contains_mention() { | |
| 40 | - local text="$1" | |
| 41 | - [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]] | |
| 42 | -} | |
| 43 | - | |
| 44 | 83 | last_check=0 |
| 45 | 84 | if [ -f "$LAST_CHECK_FILE" ]; then |
| 46 | 85 | last_check=$(cat "$LAST_CHECK_FILE") |
| 47 | 86 | fi |
| 48 | 87 | now=$(date +%s) |
| 49 | 88 | echo "$now" > "$LAST_CHECK_FILE" |
| 50 | 89 | |
| 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 | 90 | BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]' |
| 60 | 91 | |
| 61 | 92 | 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") | |
| 76 | 113 | [ -n "$ts" ] || continue |
| 77 | 114 | [ "$ts" -gt "$last_check" ] || continue |
| 78 | 115 | 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- | |
| 81 | 118 | ) |
| 82 | 119 | |
| 83 | 120 | [ -z "$instruction" ] && exit 0 |
| 84 | 121 | |
| 85 | 122 | echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}" |
| 86 | 123 | exit 0 |
| 87 | 124 |
| --- 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 @@ | ||
| 1 | 1 | #!/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. | |
| 4 | 3 | |
| 5 | 4 | SCUTTLEBOT_CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}" |
| 6 | 5 | if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then |
| 7 | 6 | set -a |
| 8 | 7 | . "$SCUTTLEBOT_CONFIG_FILE" |
| 9 | 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 | |
| 10 | 14 | fi |
| 11 | 15 | |
| 12 | 16 | SCUTTLEBOT_URL="${SCUTTLEBOT_URL:-http://localhost:8080}" |
| 13 | 17 | SCUTTLEBOT_TOKEN="${SCUTTLEBOT_TOKEN}" |
| 14 | 18 | SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL:-general}" |
| 15 | 19 | SCUTTLEBOT_HOOKS_ENABLED="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
| 16 | 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 | +} | |
| 17 | 61 | |
| 18 | 62 | input=$(cat) |
| 19 | 63 | |
| 20 | 64 | tool=$(echo "$input" | jq -r '.tool_name // empty') |
| 21 | 65 | cwd=$(echo "$input" | jq -r '.cwd // empty') |
| @@ -73,14 +117,7 @@ | ||
| 73 | 117 | ;; |
| 74 | 118 | esac |
| 75 | 119 | |
| 76 | 120 | [ -z "$msg" ] && exit 0 |
| 77 | 121 | |
| 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" | |
| 86 | 123 | exit 0 |
| 87 | 124 |
| --- 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 |
+14
-1
| --- skills/scuttlebot-relay/install.md | ||
| +++ skills/scuttlebot-relay/install.md | ||
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | # scuttlebot-relay skill |
| 2 | 2 | |
| 3 | 3 | Installs Claude Code hooks that post your activity to an IRC channel in real time |
| 4 | 4 | and surface human instructions from IRC back into your context before each action. |
| 5 | 5 | |
| 6 | +Shared relay skill entry: [`SKILL.md`](SKILL.md) | |
| 7 | + | |
| 6 | 8 | ## What it does |
| 7 | 9 | |
| 8 | 10 | The relay provides an interactive broker that: |
| 9 | 11 | - starts your Claude session on a real PTY |
| 10 | 12 | - posts an "online" message immediately |
| @@ -22,11 +24,12 @@ | ||
| 22 | 24 | |
| 23 | 25 | ```bash |
| 24 | 26 | bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \ |
| 25 | 27 | --url http://localhost:8080 \ |
| 26 | 28 | --token "$(./run.sh token)" \ |
| 27 | - --channel general | |
| 29 | + --channel general \ | |
| 30 | + --channels general,task-42 | |
| 28 | 31 | ``` |
| 29 | 32 | |
| 30 | 33 | Or via Make: |
| 31 | 34 | |
| 32 | 35 | ```bash |
| @@ -55,13 +58,23 @@ | ||
| 55 | 58 | |
| 56 | 59 | ## Configuration |
| 57 | 60 | |
| 58 | 61 | Useful shared env knobs in `~/.config/scuttlebot-relay.env`: |
| 59 | 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 | |
| 60 | 65 | - `SCUTTLEBOT_INTERRUPT_ON_MESSAGE=1` — interrupts the live Claude session when it appears busy |
| 61 | 66 | - `SCUTTLEBOT_POLL_INTERVAL=2s` — controls how often the broker checks for new addressed IRC messages |
| 62 | 67 | - `SCUTTLEBOT_PRESENCE_HEARTBEAT=60s` — controls HTTP presence touches; set `0` to disable |
| 63 | 68 | |
| 64 | 69 | Disable without uninstalling: |
| 65 | 70 | ```bash |
| 66 | 71 | SCUTTLEBOT_HOOKS_ENABLED=0 claude-relay |
| 67 | 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. | |
| 68 | 81 |
| --- 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | Options: |
| 12 | 12 | --url URL Set SCUTTLEBOT_URL in the shared env file. |
| 13 | 13 | --token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
| 14 | 14 | --channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
| 15 | + --channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file. | |
| 15 | 16 | --transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: irc. |
| 16 | 17 | --irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667. |
| 17 | 18 | --irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode. |
| 18 | 19 | --auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default. |
| 19 | 20 | --enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
| @@ -26,10 +27,11 @@ | ||
| 26 | 27 | |
| 27 | 28 | Environment defaults: |
| 28 | 29 | SCUTTLEBOT_URL |
| 29 | 30 | SCUTTLEBOT_TOKEN |
| 30 | 31 | SCUTTLEBOT_CHANNEL |
| 32 | + SCUTTLEBOT_CHANNELS | |
| 31 | 33 | SCUTTLEBOT_TRANSPORT |
| 32 | 34 | SCUTTLEBOT_IRC_ADDR |
| 33 | 35 | SCUTTLEBOT_IRC_PASS |
| 34 | 36 | SCUTTLEBOT_IRC_DELETE_ON_CLOSE |
| 35 | 37 | SCUTTLEBOT_HOOKS_ENABLED |
| @@ -55,10 +57,11 @@ | ||
| 55 | 57 | REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
| 56 | 58 | |
| 57 | 59 | SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
| 58 | 60 | SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
| 59 | 61 | SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
| 62 | +SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}" | |
| 60 | 63 | SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-irc}" |
| 61 | 64 | SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}" |
| 62 | 65 | if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then |
| 63 | 66 | SCUTTLEBOT_IRC_PASS_MODE="fixed" |
| 64 | 67 | SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS" |
| @@ -88,10 +91,14 @@ | ||
| 88 | 91 | shift 2 |
| 89 | 92 | ;; |
| 90 | 93 | --channel) |
| 91 | 94 | SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
| 92 | 95 | shift 2 |
| 96 | + ;; | |
| 97 | + --channels) | |
| 98 | + SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}" | |
| 99 | + shift 2 | |
| 93 | 100 | ;; |
| 94 | 101 | --transport) |
| 95 | 102 | SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}" |
| 96 | 103 | shift 2 |
| 97 | 104 | ;; |
| @@ -160,10 +167,52 @@ | ||
| 160 | 167 | } |
| 161 | 168 | |
| 162 | 169 | ensure_parent_dir() { |
| 163 | 170 | mkdir -p "$(dirname "$1")" |
| 164 | 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 | |
| 165 | 214 | |
| 166 | 215 | upsert_env_var() { |
| 167 | 216 | local file="$1" |
| 168 | 217 | local key="$2" |
| 169 | 218 | local value="$3" |
| @@ -277,10 +326,13 @@ | ||
| 277 | 326 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
| 278 | 327 | fi |
| 279 | 328 | if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
| 280 | 329 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
| 281 | 330 | fi |
| 331 | +if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then | |
| 332 | + upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE" | |
| 333 | +fi | |
| 282 | 334 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE" |
| 283 | 335 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE" |
| 284 | 336 | if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then |
| 285 | 337 | upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE" |
| 286 | 338 | else |
| 287 | 339 |
| --- 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 |
+7
-1
| --- tests/smoke/test-installers.sh | ||
| +++ tests/smoke/test-installers.sh | ||
| @@ -34,11 +34,12 @@ | ||
| 34 | 34 | # 1. Codex |
| 35 | 35 | printf 'Testing Codex installer...\n' |
| 36 | 36 | bash "$REPO_ROOT/skills/openai-relay/scripts/install-codex-relay.sh" \ |
| 37 | 37 | --url http://localhost:8080 \ |
| 38 | 38 | --token "test-token" \ |
| 39 | - --channel general | |
| 39 | + --channel general \ | |
| 40 | + --channels general,task-42 | |
| 40 | 41 | |
| 41 | 42 | # Verify files |
| 42 | 43 | [ -f "$HOME/.codex/hooks/scuttlebot-post.sh" ] |
| 43 | 44 | [ -f "$HOME/.codex/hooks/scuttlebot-check.sh" ] |
| 44 | 45 | [ -f "$HOME/.codex/hooks.json" ] |
| @@ -45,25 +46,28 @@ | ||
| 45 | 46 | [ -f "$HOME/.codex/config.toml" ] |
| 46 | 47 | [ -f "$HOME/.local/bin/codex-relay" ] |
| 47 | 48 | [ -f "$HOME/.config/scuttlebot-relay.env" ] |
| 48 | 49 | ! grep -q '^SCUTTLEBOT_IRC_PASS=' "$SCUTTLEBOT_CONFIG_FILE" |
| 49 | 50 | grep -q '^SCUTTLEBOT_IRC_DELETE_ON_CLOSE=1$' "$SCUTTLEBOT_CONFIG_FILE" |
| 51 | +grep -q '^SCUTTLEBOT_CHANNELS=general,task-42$' "$SCUTTLEBOT_CONFIG_FILE" | |
| 50 | 52 | |
| 51 | 53 | # 2. Gemini |
| 52 | 54 | printf 'Testing Gemini installer...\n' |
| 53 | 55 | bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \ |
| 54 | 56 | --url http://localhost:8080 \ |
| 55 | 57 | --token "test-token" \ |
| 56 | 58 | --channel general \ |
| 59 | + --channels general,release \ | |
| 57 | 60 | --irc-pass "gemini-fixed" |
| 58 | 61 | |
| 59 | 62 | # Verify files |
| 60 | 63 | [ -f "$HOME/.gemini/hooks/scuttlebot-post.sh" ] |
| 61 | 64 | [ -f "$HOME/.gemini/hooks/scuttlebot-check.sh" ] |
| 62 | 65 | [ -f "$HOME/.gemini/settings.json" ] |
| 63 | 66 | [ -f "$HOME/.local/bin/gemini-relay" ] |
| 64 | 67 | grep -q '^SCUTTLEBOT_IRC_PASS=gemini-fixed$' "$SCUTTLEBOT_CONFIG_FILE" |
| 68 | +grep -q '^SCUTTLEBOT_CHANNELS=general,release$' "$SCUTTLEBOT_CONFIG_FILE" | |
| 65 | 69 | |
| 66 | 70 | printf 'Testing Gemini auto-register scrub...\n' |
| 67 | 71 | bash "$REPO_ROOT/skills/gemini-relay/scripts/install-gemini-relay.sh" \ |
| 68 | 72 | --channel general \ |
| 69 | 73 | --auto-register |
| @@ -73,10 +77,11 @@ | ||
| 73 | 77 | printf 'Testing Claude installer...\n' |
| 74 | 78 | bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \ |
| 75 | 79 | --url http://localhost:8080 \ |
| 76 | 80 | --token "test-token" \ |
| 77 | 81 | --channel general \ |
| 82 | + --channels general,ops \ | |
| 78 | 83 | --transport irc \ |
| 79 | 84 | --irc-addr 127.0.0.1:6667 \ |
| 80 | 85 | --irc-pass "claude-fixed" |
| 81 | 86 | |
| 82 | 87 | # Verify files |
| @@ -84,10 +89,11 @@ | ||
| 84 | 89 | [ -f "$HOME/.claude/hooks/scuttlebot-check.sh" ] |
| 85 | 90 | [ -f "$HOME/.claude/settings.json" ] |
| 86 | 91 | [ -f "$HOME/.local/bin/claude-relay" ] |
| 87 | 92 | grep -q '^SCUTTLEBOT_IRC_PASS=claude-fixed$' "$SCUTTLEBOT_CONFIG_FILE" |
| 88 | 93 | grep -q '^SCUTTLEBOT_TRANSPORT=irc$' "$SCUTTLEBOT_CONFIG_FILE" |
| 94 | +grep -q '^SCUTTLEBOT_CHANNELS=general,ops$' "$SCUTTLEBOT_CONFIG_FILE" | |
| 89 | 95 | |
| 90 | 96 | printf 'Testing Claude auto-register scrub...\n' |
| 91 | 97 | bash "$REPO_ROOT/skills/scuttlebot-relay/scripts/install-claude-relay.sh" \ |
| 92 | 98 | --channel general \ |
| 93 | 99 | --auto-register |
| 94 | 100 |
| --- 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 |