ScuttleBot

feat(#46): group @mention addressing — Envelope.To + MatchesRecipient Add structured group addressing to the wire protocol: - Envelope.To []string field (omitempty, backwards compat) - NewTo() constructor for addressed envelopes - MatchesRecipient(env, nick, agentType) SDK helper covering: @all, @workers, @operators, @orchestrators, @observers, @prefix-* nick glob, and exact nick match - OR semantics across multiple To entries - Empty To = broadcast (all existing messages still match)

lmata 2026-04-02 22:42 trunk
Commit 9ef9425bc6dfe4efc67bcbfa292693e8782c549edf224b924e73543f78009b32
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -6,10 +6,11 @@
66
77
import (
88
"encoding/json"
99
"fmt"
1010
"math/rand"
11
+ "strings"
1112
"time"
1213
1314
"github.com/oklog/ulid/v2"
1415
)
1516
@@ -29,16 +30,24 @@
2930
type Envelope struct {
3031
V int `json:"v"`
3132
Type string `json:"type"`
3233
ID string `json:"id"`
3334
From string `json:"from"`
35
+ To []string `json:"to,omitempty"`
3436
TS int64 `json:"ts"`
3537
Payload json.RawMessage `json:"payload,omitempty"`
3638
}
3739
3840
// New creates a new Envelope with a generated ID and current timestamp.
41
+// To is left empty (unaddressed — matches all recipients).
3942
func New(msgType, from string, payload any) (*Envelope, error) {
43
+ return NewTo(msgType, from, nil, payload)
44
+}
45
+
46
+// NewTo creates a new Envelope addressed to specific recipients.
47
+// See MatchesRecipient for supported To patterns.
48
+func NewTo(msgType, from string, to []string, payload any) (*Envelope, error) {
4049
var raw json.RawMessage
4150
if payload != nil {
4251
b, err := json.Marshal(payload)
4352
if err != nil {
4453
return nil, fmt.Errorf("protocol: marshal payload: %w", err)
@@ -48,10 +57,11 @@
4857
return &Envelope{
4958
V: Version,
5059
Type: msgType,
5160
ID: newID(),
5261
From: from,
62
+ To: to,
5363
TS: time.Now().UnixMilli(),
5464
Payload: raw,
5565
}, nil
5666
}
5767
@@ -100,10 +110,70 @@
100110
if e.From == "" {
101111
return fmt.Errorf("protocol: missing from")
102112
}
103113
return nil
104114
}
115
+
116
+// Group addressing tokens for use in Envelope.To.
117
+const (
118
+ ToAll = "@all"
119
+ ToOperators = "@operators"
120
+ ToOrchestrators = "@orchestrators"
121
+ ToWorkers = "@workers"
122
+ ToObservers = "@observers"
123
+)
124
+
125
+// MatchesRecipient reports whether env is addressed to the agent identified by
126
+// nick and agentType.
127
+//
128
+// Matching rules (OR'd across all To entries):
129
+// - empty/nil To → true (unaddressed = broadcast, backwards compat)
130
+// - "@all" → true
131
+// - "@operators" etc. → agentType == "operator" etc.
132
+// - "@prefix-*" → strings.HasPrefix(nick, "prefix-")
133
+// - bare string → nick == token
134
+func MatchesRecipient(env *Envelope, nick, agentType string) bool {
135
+ if len(env.To) == 0 {
136
+ return true
137
+ }
138
+ for _, token := range env.To {
139
+ switch token {
140
+ case ToAll:
141
+ return true
142
+ case ToOperators:
143
+ if agentType == "operator" {
144
+ return true
145
+ }
146
+ case ToOrchestrators:
147
+ if agentType == "orchestrator" {
148
+ return true
149
+ }
150
+ case ToWorkers:
151
+ if agentType == "worker" {
152
+ return true
153
+ }
154
+ case ToObservers:
155
+ if agentType == "observer" {
156
+ return true
157
+ }
158
+ default:
159
+ if strings.HasPrefix(token, "@") {
160
+ // @prefix-* glob: strip leading "@" and trailing "-*"
161
+ prefix := strings.TrimPrefix(token, "@")
162
+ if strings.HasSuffix(prefix, "-*") {
163
+ prefix = strings.TrimSuffix(prefix, "*")
164
+ if strings.HasPrefix(nick, prefix) {
165
+ return true
166
+ }
167
+ }
168
+ } else if token == nick {
169
+ return true
170
+ }
171
+ }
172
+ }
173
+ return false
174
+}
105175
106176
func newID() string {
107177
entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) //nolint:gosec
108178
return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
109179
}
110180
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -6,10 +6,11 @@
6
7 import (
8 "encoding/json"
9 "fmt"
10 "math/rand"
 
11 "time"
12
13 "github.com/oklog/ulid/v2"
14 )
15
@@ -29,16 +30,24 @@
29 type Envelope struct {
30 V int `json:"v"`
31 Type string `json:"type"`
32 ID string `json:"id"`
33 From string `json:"from"`
 
34 TS int64 `json:"ts"`
35 Payload json.RawMessage `json:"payload,omitempty"`
36 }
37
38 // New creates a new Envelope with a generated ID and current timestamp.
 
39 func New(msgType, from string, payload any) (*Envelope, error) {
 
 
 
 
 
 
40 var raw json.RawMessage
41 if payload != nil {
42 b, err := json.Marshal(payload)
43 if err != nil {
44 return nil, fmt.Errorf("protocol: marshal payload: %w", err)
@@ -48,10 +57,11 @@
48 return &Envelope{
49 V: Version,
50 Type: msgType,
51 ID: newID(),
52 From: from,
 
53 TS: time.Now().UnixMilli(),
54 Payload: raw,
55 }, nil
56 }
57
@@ -100,10 +110,70 @@
100 if e.From == "" {
101 return fmt.Errorf("protocol: missing from")
102 }
103 return nil
104 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
106 func newID() string {
107 entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) //nolint:gosec
108 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
109 }
110
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -6,10 +6,11 @@
6
7 import (
8 "encoding/json"
9 "fmt"
10 "math/rand"
11 "strings"
12 "time"
13
14 "github.com/oklog/ulid/v2"
15 )
16
@@ -29,16 +30,24 @@
30 type Envelope struct {
31 V int `json:"v"`
32 Type string `json:"type"`
33 ID string `json:"id"`
34 From string `json:"from"`
35 To []string `json:"to,omitempty"`
36 TS int64 `json:"ts"`
37 Payload json.RawMessage `json:"payload,omitempty"`
38 }
39
40 // New creates a new Envelope with a generated ID and current timestamp.
41 // To is left empty (unaddressed — matches all recipients).
42 func New(msgType, from string, payload any) (*Envelope, error) {
43 return NewTo(msgType, from, nil, payload)
44 }
45
46 // NewTo creates a new Envelope addressed to specific recipients.
47 // See MatchesRecipient for supported To patterns.
48 func NewTo(msgType, from string, to []string, payload any) (*Envelope, error) {
49 var raw json.RawMessage
50 if payload != nil {
51 b, err := json.Marshal(payload)
52 if err != nil {
53 return nil, fmt.Errorf("protocol: marshal payload: %w", err)
@@ -48,10 +57,11 @@
57 return &Envelope{
58 V: Version,
59 Type: msgType,
60 ID: newID(),
61 From: from,
62 To: to,
63 TS: time.Now().UnixMilli(),
64 Payload: raw,
65 }, nil
66 }
67
@@ -100,10 +110,70 @@
110 if e.From == "" {
111 return fmt.Errorf("protocol: missing from")
112 }
113 return nil
114 }
115
116 // Group addressing tokens for use in Envelope.To.
117 const (
118 ToAll = "@all"
119 ToOperators = "@operators"
120 ToOrchestrators = "@orchestrators"
121 ToWorkers = "@workers"
122 ToObservers = "@observers"
123 )
124
125 // MatchesRecipient reports whether env is addressed to the agent identified by
126 // nick and agentType.
127 //
128 // Matching rules (OR'd across all To entries):
129 // - empty/nil To → true (unaddressed = broadcast, backwards compat)
130 // - "@all" → true
131 // - "@operators" etc. → agentType == "operator" etc.
132 // - "@prefix-*" → strings.HasPrefix(nick, "prefix-")
133 // - bare string → nick == token
134 func MatchesRecipient(env *Envelope, nick, agentType string) bool {
135 if len(env.To) == 0 {
136 return true
137 }
138 for _, token := range env.To {
139 switch token {
140 case ToAll:
141 return true
142 case ToOperators:
143 if agentType == "operator" {
144 return true
145 }
146 case ToOrchestrators:
147 if agentType == "orchestrator" {
148 return true
149 }
150 case ToWorkers:
151 if agentType == "worker" {
152 return true
153 }
154 case ToObservers:
155 if agentType == "observer" {
156 return true
157 }
158 default:
159 if strings.HasPrefix(token, "@") {
160 // @prefix-* glob: strip leading "@" and trailing "-*"
161 prefix := strings.TrimPrefix(token, "@")
162 if strings.HasSuffix(prefix, "-*") {
163 prefix = strings.TrimSuffix(prefix, "*")
164 if strings.HasPrefix(nick, prefix) {
165 return true
166 }
167 }
168 } else if token == nick {
169 return true
170 }
171 }
172 }
173 return false
174 }
175
176 func newID() string {
177 entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) //nolint:gosec
178 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
179 }
180
--- pkg/protocol/protocol_test.go
+++ pkg/protocol/protocol_test.go
@@ -106,10 +106,110 @@
106106
107107
if len(got.Payload) != 0 {
108108
t.Errorf("expected empty payload, got %s", got.Payload)
109109
}
110110
}
111
+
112
+func TestMatchesRecipient(t *testing.T) {
113
+ cases := []struct {
114
+ name string
115
+ to []string
116
+ nick string
117
+ agentType string
118
+ want bool
119
+ }{
120
+ // backwards compat
121
+ {"empty to matches all", nil, "claude-1", "worker", true},
122
+
123
+ // @all
124
+ {"@all matches worker", []string{"@all"}, "claude-1", "worker", true},
125
+ {"@all matches operator", []string{"@all"}, "glengoolie", "operator", true},
126
+
127
+ // role tokens
128
+ {"@workers matches worker", []string{"@workers"}, "claude-1", "worker", true},
129
+ {"@workers no match orchestrator", []string{"@workers"}, "claude-1", "orchestrator", false},
130
+ {"@operators matches operator", []string{"@operators"}, "glengoolie", "operator", true},
131
+ {"@orchestrators matches orchestrator", []string{"@orchestrators"}, "claude-1", "orchestrator", true},
132
+ {"@observers matches observer", []string{"@observers"}, "sentinel", "observer", true},
133
+
134
+ // prefix glob
135
+ {"@claude-* matches claude-1", []string{"@claude-*"}, "claude-1", "worker", true},
136
+ {"@claude-* matches claude-sonnet", []string{"@claude-*"}, "claude-sonnet", "worker", true},
137
+ {"@claude-* no match codex-1", []string{"@claude-*"}, "codex-1", "worker", false},
138
+ {"@gemini-* matches gemini-pro", []string{"@gemini-*"}, "gemini-pro", "worker", true},
139
+
140
+ // exact nick
141
+ {"exact nick match", []string{"codex-7"}, "codex-7", "worker", true},
142
+ {"exact nick no match", []string{"codex-7"}, "codex-8", "worker", false},
143
+
144
+ // OR semantics
145
+ {"OR: second token matches", []string{"@operators", "codex-7"}, "codex-7", "worker", true},
146
+ {"OR: first token matches", []string{"@workers", "codex-7"}, "claude-1", "worker", true},
147
+ {"OR: none match", []string{"@operators", "codex-7"}, "claude-1", "worker", false},
148
+ }
149
+
150
+ for _, tc := range cases {
151
+ t.Run(tc.name, func(t *testing.T) {
152
+ env := &protocol.Envelope{
153
+ V: protocol.Version,
154
+ Type: protocol.TypeTaskCreate,
155
+ ID: "test",
156
+ From: "orchestrator",
157
+ To: tc.to,
158
+ TS: 1,
159
+ }
160
+ got := protocol.MatchesRecipient(env, tc.nick, tc.agentType)
161
+ if got != tc.want {
162
+ t.Errorf("MatchesRecipient(%v, %q, %q) = %v, want %v", tc.to, tc.nick, tc.agentType, got, tc.want)
163
+ }
164
+ })
165
+ }
166
+}
167
+
168
+func TestNewTo(t *testing.T) {
169
+ env, err := protocol.NewTo(protocol.TypeTaskCreate, "orchestrator-1", []string{"@workers", "@claude-*"}, nil)
170
+ if err != nil {
171
+ t.Fatalf("NewTo: %v", err)
172
+ }
173
+ if len(env.To) != 2 {
174
+ t.Fatalf("To length: got %d, want 2", len(env.To))
175
+ }
176
+ if env.To[0] != "@workers" || env.To[1] != "@claude-*" {
177
+ t.Errorf("To: got %v", env.To)
178
+ }
179
+
180
+ // round-trip
181
+ data, err := protocol.Marshal(env)
182
+ if err != nil {
183
+ t.Fatalf("Marshal: %v", err)
184
+ }
185
+ got, err := protocol.Unmarshal(data)
186
+ if err != nil {
187
+ t.Fatalf("Unmarshal: %v", err)
188
+ }
189
+ if len(got.To) != 2 || got.To[0] != "@workers" {
190
+ t.Errorf("round-trip To: got %v", got.To)
191
+ }
192
+}
193
+
194
+func TestToOmittedWhenEmpty(t *testing.T) {
195
+ env, err := protocol.New(protocol.TypeAgentHello, "agent", nil)
196
+ if err != nil {
197
+ t.Fatalf("New: %v", err)
198
+ }
199
+ data, err := protocol.Marshal(env)
200
+ if err != nil {
201
+ t.Fatalf("Marshal: %v", err)
202
+ }
203
+ var raw map[string]any
204
+ if err := json.Unmarshal(data, &raw); err != nil {
205
+ t.Fatalf("json.Unmarshal: %v", err)
206
+ }
207
+ if _, ok := raw["to"]; ok {
208
+ t.Error("expected 'to' key to be omitted when empty")
209
+ }
210
+}
111211
112212
func TestAllMessageTypes(t *testing.T) {
113213
types := []string{
114214
protocol.TypeTaskCreate,
115215
protocol.TypeTaskUpdate,
116216
--- pkg/protocol/protocol_test.go
+++ pkg/protocol/protocol_test.go
@@ -106,10 +106,110 @@
106
107 if len(got.Payload) != 0 {
108 t.Errorf("expected empty payload, got %s", got.Payload)
109 }
110 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
112 func TestAllMessageTypes(t *testing.T) {
113 types := []string{
114 protocol.TypeTaskCreate,
115 protocol.TypeTaskUpdate,
116
--- pkg/protocol/protocol_test.go
+++ pkg/protocol/protocol_test.go
@@ -106,10 +106,110 @@
106
107 if len(got.Payload) != 0 {
108 t.Errorf("expected empty payload, got %s", got.Payload)
109 }
110 }
111
112 func TestMatchesRecipient(t *testing.T) {
113 cases := []struct {
114 name string
115 to []string
116 nick string
117 agentType string
118 want bool
119 }{
120 // backwards compat
121 {"empty to matches all", nil, "claude-1", "worker", true},
122
123 // @all
124 {"@all matches worker", []string{"@all"}, "claude-1", "worker", true},
125 {"@all matches operator", []string{"@all"}, "glengoolie", "operator", true},
126
127 // role tokens
128 {"@workers matches worker", []string{"@workers"}, "claude-1", "worker", true},
129 {"@workers no match orchestrator", []string{"@workers"}, "claude-1", "orchestrator", false},
130 {"@operators matches operator", []string{"@operators"}, "glengoolie", "operator", true},
131 {"@orchestrators matches orchestrator", []string{"@orchestrators"}, "claude-1", "orchestrator", true},
132 {"@observers matches observer", []string{"@observers"}, "sentinel", "observer", true},
133
134 // prefix glob
135 {"@claude-* matches claude-1", []string{"@claude-*"}, "claude-1", "worker", true},
136 {"@claude-* matches claude-sonnet", []string{"@claude-*"}, "claude-sonnet", "worker", true},
137 {"@claude-* no match codex-1", []string{"@claude-*"}, "codex-1", "worker", false},
138 {"@gemini-* matches gemini-pro", []string{"@gemini-*"}, "gemini-pro", "worker", true},
139
140 // exact nick
141 {"exact nick match", []string{"codex-7"}, "codex-7", "worker", true},
142 {"exact nick no match", []string{"codex-7"}, "codex-8", "worker", false},
143
144 // OR semantics
145 {"OR: second token matches", []string{"@operators", "codex-7"}, "codex-7", "worker", true},
146 {"OR: first token matches", []string{"@workers", "codex-7"}, "claude-1", "worker", true},
147 {"OR: none match", []string{"@operators", "codex-7"}, "claude-1", "worker", false},
148 }
149
150 for _, tc := range cases {
151 t.Run(tc.name, func(t *testing.T) {
152 env := &protocol.Envelope{
153 V: protocol.Version,
154 Type: protocol.TypeTaskCreate,
155 ID: "test",
156 From: "orchestrator",
157 To: tc.to,
158 TS: 1,
159 }
160 got := protocol.MatchesRecipient(env, tc.nick, tc.agentType)
161 if got != tc.want {
162 t.Errorf("MatchesRecipient(%v, %q, %q) = %v, want %v", tc.to, tc.nick, tc.agentType, got, tc.want)
163 }
164 })
165 }
166 }
167
168 func TestNewTo(t *testing.T) {
169 env, err := protocol.NewTo(protocol.TypeTaskCreate, "orchestrator-1", []string{"@workers", "@claude-*"}, nil)
170 if err != nil {
171 t.Fatalf("NewTo: %v", err)
172 }
173 if len(env.To) != 2 {
174 t.Fatalf("To length: got %d, want 2", len(env.To))
175 }
176 if env.To[0] != "@workers" || env.To[1] != "@claude-*" {
177 t.Errorf("To: got %v", env.To)
178 }
179
180 // round-trip
181 data, err := protocol.Marshal(env)
182 if err != nil {
183 t.Fatalf("Marshal: %v", err)
184 }
185 got, err := protocol.Unmarshal(data)
186 if err != nil {
187 t.Fatalf("Unmarshal: %v", err)
188 }
189 if len(got.To) != 2 || got.To[0] != "@workers" {
190 t.Errorf("round-trip To: got %v", got.To)
191 }
192 }
193
194 func TestToOmittedWhenEmpty(t *testing.T) {
195 env, err := protocol.New(protocol.TypeAgentHello, "agent", nil)
196 if err != nil {
197 t.Fatalf("New: %v", err)
198 }
199 data, err := protocol.Marshal(env)
200 if err != nil {
201 t.Fatalf("Marshal: %v", err)
202 }
203 var raw map[string]any
204 if err := json.Unmarshal(data, &raw); err != nil {
205 t.Fatalf("json.Unmarshal: %v", err)
206 }
207 if _, ok := raw["to"]; ok {
208 t.Error("expected 'to' key to be omitted when empty")
209 }
210 }
211
212 func TestAllMessageTypes(t *testing.T) {
213 types := []string{
214 protocol.TypeTaskCreate,
215 protocol.TypeTaskUpdate,
216

Keyboard Shortcuts

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