ScuttleBot

Merge pull request #147 from ConflictHQ/feature/90-agent-coordination feat: agent coordination — topology API, skills, on-join delivery, blockers

noreply 2026-04-05 17:45 trunk merge
Commit ba75f34cff38b3e71dabdedc94cf3d04848bccf321e1f465f04f2ce199848f55
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -244,10 +244,25 @@
244244
os.Exit(1)
245245
}
246246
}
247247
if bridgeBot != nil {
248248
bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
249
+ // Deliver on-join instructions when agents join channels.
250
+ bridgeBot.SetOnUserJoin(func(channel, nick string) {
251
+ p := policyStore.Get()
252
+ msg, ok := p.OnJoinMessages[channel]
253
+ if !ok || msg == "" {
254
+ return
255
+ }
256
+ msg = strings.ReplaceAll(msg, "{nick}", nick)
257
+ msg = strings.ReplaceAll(msg, "{channel}", channel)
258
+ for _, line := range strings.Split(msg, "\n") {
259
+ if line != "" {
260
+ bridgeBot.Notice(nick, line)
261
+ }
262
+ }
263
+ })
249264
}
250265
251266
// Admin store — bcrypt-hashed admin accounts.
252267
adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
253268
if err != nil {
254269
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -244,10 +244,25 @@
244 os.Exit(1)
245 }
246 }
247 if bridgeBot != nil {
248 bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249 }
250
251 // Admin store — bcrypt-hashed admin accounts.
252 adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
253 if err != nil {
254
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -244,10 +244,25 @@
244 os.Exit(1)
245 }
246 }
247 if bridgeBot != nil {
248 bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
249 // Deliver on-join instructions when agents join channels.
250 bridgeBot.SetOnUserJoin(func(channel, nick string) {
251 p := policyStore.Get()
252 msg, ok := p.OnJoinMessages[channel]
253 if !ok || msg == "" {
254 return
255 }
256 msg = strings.ReplaceAll(msg, "{nick}", nick)
257 msg = strings.ReplaceAll(msg, "{channel}", channel)
258 for _, line := range strings.Split(msg, "\n") {
259 if line != "" {
260 bridgeBot.Notice(nick, line)
261 }
262 }
263 })
264 }
265
266 // Admin store — bcrypt-hashed admin accounts.
267 adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
268 if err != nil {
269
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -12,10 +12,11 @@
1212
Nick string `json:"nick"`
1313
Type registry.AgentType `json:"type"`
1414
Channels []string `json:"channels"`
1515
OpsChannels []string `json:"ops_channels,omitempty"`
1616
Permissions []string `json:"permissions"`
17
+ Skills []string `json:"skills,omitempty"`
1718
RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
1819
Rules *registry.EngagementRules `json:"engagement,omitempty"`
1920
}
2021
2122
type registerResponse struct {
@@ -57,10 +58,17 @@
5758
s.log.Error("register agent", "nick", req.Nick, "err", err)
5859
writeError(w, http.StatusInternalServerError, "registration failed")
5960
return
6061
}
6162
63
+ // Set skills if provided.
64
+ if len(req.Skills) > 0 {
65
+ if agent, err := s.registry.Get(req.Nick); err == nil {
66
+ agent.Skills = req.Skills
67
+ _ = s.registry.Update(agent)
68
+ }
69
+ }
6270
s.registry.Touch(req.Nick)
6371
s.setAgentModes(req.Nick, req.Type, cfg)
6472
writeJSON(w, http.StatusCreated, registerResponse{
6573
Credentials: creds,
6674
Payload: payload,
@@ -203,10 +211,23 @@
203211
w.WriteHeader(http.StatusNoContent)
204212
}
205213
206214
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
207215
agents := s.registry.List()
216
+ // Filter by skill if ?skill= query param is present.
217
+ if skill := r.URL.Query().Get("skill"); skill != "" {
218
+ filtered := make([]*registry.Agent, 0)
219
+ for _, a := range agents {
220
+ for _, s := range a.Skills {
221
+ if strings.EqualFold(s, skill) {
222
+ filtered = append(filtered, a)
223
+ break
224
+ }
225
+ }
226
+ }
227
+ agents = filtered
228
+ }
208229
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
209230
}
210231
211232
func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
212233
nick := r.PathValue("nick")
@@ -215,10 +236,41 @@
215236
writeError(w, http.StatusNotFound, err.Error())
216237
return
217238
}
218239
writeJSON(w, http.StatusOK, agent)
219240
}
241
+
242
+// handleAgentBlocker handles POST /v1/agents/{nick}/blocker.
243
+// Agents or relays call this to escalate that an agent is stuck.
244
+func (s *Server) handleAgentBlocker(w http.ResponseWriter, r *http.Request) {
245
+ nick := r.PathValue("nick")
246
+ var req struct {
247
+ Channel string `json:"channel,omitempty"`
248
+ Message string `json:"message"`
249
+ }
250
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
251
+ writeError(w, http.StatusBadRequest, "invalid request body")
252
+ return
253
+ }
254
+ if req.Message == "" {
255
+ writeError(w, http.StatusBadRequest, "message is required")
256
+ return
257
+ }
258
+
259
+ alert := "[blocker] " + nick
260
+ if req.Channel != "" {
261
+ alert += " in " + req.Channel
262
+ }
263
+ alert += ": " + req.Message
264
+
265
+ // Post to #ops if bridge is available.
266
+ if s.bridge != nil {
267
+ _ = s.bridge.Send(r.Context(), "#ops", alert, "")
268
+ }
269
+ s.log.Warn("agent blocker", "nick", nick, "channel", req.Channel, "message", req.Message)
270
+ w.WriteHeader(http.StatusNoContent)
271
+}
220272
221273
// agentModeLevel maps an agent type to the ChanServ access level it should
222274
// receive. Returns "" for types that get no special mode.
223275
func agentModeLevel(t registry.AgentType) string {
224276
switch t {
225277
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -12,10 +12,11 @@
12 Nick string `json:"nick"`
13 Type registry.AgentType `json:"type"`
14 Channels []string `json:"channels"`
15 OpsChannels []string `json:"ops_channels,omitempty"`
16 Permissions []string `json:"permissions"`
 
17 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
18 Rules *registry.EngagementRules `json:"engagement,omitempty"`
19 }
20
21 type registerResponse struct {
@@ -57,10 +58,17 @@
57 s.log.Error("register agent", "nick", req.Nick, "err", err)
58 writeError(w, http.StatusInternalServerError, "registration failed")
59 return
60 }
61
 
 
 
 
 
 
 
62 s.registry.Touch(req.Nick)
63 s.setAgentModes(req.Nick, req.Type, cfg)
64 writeJSON(w, http.StatusCreated, registerResponse{
65 Credentials: creds,
66 Payload: payload,
@@ -203,10 +211,23 @@
203 w.WriteHeader(http.StatusNoContent)
204 }
205
206 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
207 agents := s.registry.List()
 
 
 
 
 
 
 
 
 
 
 
 
 
208 writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
209 }
210
211 func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
212 nick := r.PathValue("nick")
@@ -215,10 +236,41 @@
215 writeError(w, http.StatusNotFound, err.Error())
216 return
217 }
218 writeJSON(w, http.StatusOK, agent)
219 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
221 // agentModeLevel maps an agent type to the ChanServ access level it should
222 // receive. Returns "" for types that get no special mode.
223 func agentModeLevel(t registry.AgentType) string {
224 switch t {
225
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -12,10 +12,11 @@
12 Nick string `json:"nick"`
13 Type registry.AgentType `json:"type"`
14 Channels []string `json:"channels"`
15 OpsChannels []string `json:"ops_channels,omitempty"`
16 Permissions []string `json:"permissions"`
17 Skills []string `json:"skills,omitempty"`
18 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
19 Rules *registry.EngagementRules `json:"engagement,omitempty"`
20 }
21
22 type registerResponse struct {
@@ -57,10 +58,17 @@
58 s.log.Error("register agent", "nick", req.Nick, "err", err)
59 writeError(w, http.StatusInternalServerError, "registration failed")
60 return
61 }
62
63 // Set skills if provided.
64 if len(req.Skills) > 0 {
65 if agent, err := s.registry.Get(req.Nick); err == nil {
66 agent.Skills = req.Skills
67 _ = s.registry.Update(agent)
68 }
69 }
70 s.registry.Touch(req.Nick)
71 s.setAgentModes(req.Nick, req.Type, cfg)
72 writeJSON(w, http.StatusCreated, registerResponse{
73 Credentials: creds,
74 Payload: payload,
@@ -203,10 +211,23 @@
211 w.WriteHeader(http.StatusNoContent)
212 }
213
214 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
215 agents := s.registry.List()
216 // Filter by skill if ?skill= query param is present.
217 if skill := r.URL.Query().Get("skill"); skill != "" {
218 filtered := make([]*registry.Agent, 0)
219 for _, a := range agents {
220 for _, s := range a.Skills {
221 if strings.EqualFold(s, skill) {
222 filtered = append(filtered, a)
223 break
224 }
225 }
226 }
227 agents = filtered
228 }
229 writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
230 }
231
232 func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
233 nick := r.PathValue("nick")
@@ -215,10 +236,41 @@
236 writeError(w, http.StatusNotFound, err.Error())
237 return
238 }
239 writeJSON(w, http.StatusOK, agent)
240 }
241
242 // handleAgentBlocker handles POST /v1/agents/{nick}/blocker.
243 // Agents or relays call this to escalate that an agent is stuck.
244 func (s *Server) handleAgentBlocker(w http.ResponseWriter, r *http.Request) {
245 nick := r.PathValue("nick")
246 var req struct {
247 Channel string `json:"channel,omitempty"`
248 Message string `json:"message"`
249 }
250 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
251 writeError(w, http.StatusBadRequest, "invalid request body")
252 return
253 }
254 if req.Message == "" {
255 writeError(w, http.StatusBadRequest, "message is required")
256 return
257 }
258
259 alert := "[blocker] " + nick
260 if req.Channel != "" {
261 alert += " in " + req.Channel
262 }
263 alert += ": " + req.Message
264
265 // Post to #ops if bridge is available.
266 if s.bridge != nil {
267 _ = s.bridge.Send(r.Context(), "#ops", alert, "")
268 }
269 s.log.Warn("agent blocker", "nick", nick, "channel", req.Channel, "message", req.Message)
270 w.WriteHeader(http.StatusNoContent)
271 }
272
273 // agentModeLevel maps an agent type to the ChanServ access level it should
274 // receive. Returns "" for types that get no special mode.
275 func agentModeLevel(t registry.AgentType) string {
276 switch t {
277
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -18,15 +18,17 @@
1818
RevokeAccess(nick, channel string)
1919
ListChannels() []topology.ChannelInfo
2020
}
2121
2222
type provisionChannelRequest struct {
23
- Name string `json:"name"`
24
- Topic string `json:"topic,omitempty"`
25
- Ops []string `json:"ops,omitempty"`
26
- Voice []string `json:"voice,omitempty"`
27
- Autojoin []string `json:"autojoin,omitempty"`
23
+ Name string `json:"name"`
24
+ Topic string `json:"topic,omitempty"`
25
+ Ops []string `json:"ops,omitempty"`
26
+ Voice []string `json:"voice,omitempty"`
27
+ Autojoin []string `json:"autojoin,omitempty"`
28
+ Instructions string `json:"instructions,omitempty"`
29
+ MirrorDetail string `json:"mirror_detail,omitempty"`
2830
}
2931
3032
type provisionChannelResponse struct {
3133
Channel string `json:"channel"`
3234
Type string `json:"type,omitempty"`
@@ -75,10 +77,28 @@
7577
if err := s.topoMgr.ProvisionChannel(ch); err != nil {
7678
s.log.Error("provision channel", "channel", req.Name, "err", err)
7779
writeError(w, http.StatusInternalServerError, "provision failed")
7880
return
7981
}
82
+
83
+ // Save instructions to policies if provided.
84
+ if req.Instructions != "" && s.policies != nil {
85
+ p := s.policies.Get()
86
+ if p.OnJoinMessages == nil {
87
+ p.OnJoinMessages = make(map[string]string)
88
+ }
89
+ p.OnJoinMessages[req.Name] = req.Instructions
90
+ if req.MirrorDetail != "" {
91
+ if p.Bridge.ChannelDisplay == nil {
92
+ p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
93
+ }
94
+ cfg := p.Bridge.ChannelDisplay[req.Name]
95
+ cfg.MirrorDetail = req.MirrorDetail
96
+ p.Bridge.ChannelDisplay[req.Name] = cfg
97
+ }
98
+ _ = s.policies.Set(p)
99
+ }
80100
81101
resp := provisionChannelResponse{
82102
Channel: req.Name,
83103
Autojoin: autojoin,
84104
}
@@ -117,10 +137,64 @@
117137
return
118138
}
119139
s.topoMgr.DropChannel(channel)
120140
w.WriteHeader(http.StatusNoContent)
121141
}
142
+
143
+// handleGetInstructions handles GET /v1/channels/{channel}/instructions.
144
+func (s *Server) handleGetInstructions(w http.ResponseWriter, r *http.Request) {
145
+ channel := "#" + r.PathValue("channel")
146
+ if s.policies == nil {
147
+ writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": ""})
148
+ return
149
+ }
150
+ p := s.policies.Get()
151
+ msg := p.OnJoinMessages[channel]
152
+ writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": msg})
153
+}
154
+
155
+// handlePutInstructions handles PUT /v1/channels/{channel}/instructions.
156
+func (s *Server) handlePutInstructions(w http.ResponseWriter, r *http.Request) {
157
+ channel := "#" + r.PathValue("channel")
158
+ if s.policies == nil {
159
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
160
+ return
161
+ }
162
+ var req struct {
163
+ Instructions string `json:"instructions"`
164
+ }
165
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
166
+ writeError(w, http.StatusBadRequest, "invalid request body")
167
+ return
168
+ }
169
+ p := s.policies.Get()
170
+ if p.OnJoinMessages == nil {
171
+ p.OnJoinMessages = make(map[string]string)
172
+ }
173
+ p.OnJoinMessages[channel] = req.Instructions
174
+ if err := s.policies.Set(p); err != nil {
175
+ writeError(w, http.StatusInternalServerError, "save failed")
176
+ return
177
+ }
178
+ w.WriteHeader(http.StatusNoContent)
179
+}
180
+
181
+// handleDeleteInstructions handles DELETE /v1/channels/{channel}/instructions.
182
+func (s *Server) handleDeleteInstructions(w http.ResponseWriter, r *http.Request) {
183
+ channel := "#" + r.PathValue("channel")
184
+ if s.policies == nil {
185
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
186
+ return
187
+ }
188
+ p := s.policies.Get()
189
+ delete(p.OnJoinMessages, channel)
190
+ if err := s.policies.Set(p); err != nil {
191
+ writeError(w, http.StatusInternalServerError, "save failed")
192
+ return
193
+ }
194
+ w.WriteHeader(http.StatusNoContent)
195
+}
122196
123197
// handleGetTopology handles GET /v1/topology.
124198
// Returns the channel type rules and static channel names declared in config.
125199
func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
126200
if s.topoMgr == nil {
127201
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -18,15 +18,17 @@
18 RevokeAccess(nick, channel string)
19 ListChannels() []topology.ChannelInfo
20 }
21
22 type provisionChannelRequest struct {
23 Name string `json:"name"`
24 Topic string `json:"topic,omitempty"`
25 Ops []string `json:"ops,omitempty"`
26 Voice []string `json:"voice,omitempty"`
27 Autojoin []string `json:"autojoin,omitempty"`
 
 
28 }
29
30 type provisionChannelResponse struct {
31 Channel string `json:"channel"`
32 Type string `json:"type,omitempty"`
@@ -75,10 +77,28 @@
75 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
76 s.log.Error("provision channel", "channel", req.Name, "err", err)
77 writeError(w, http.StatusInternalServerError, "provision failed")
78 return
79 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
81 resp := provisionChannelResponse{
82 Channel: req.Name,
83 Autojoin: autojoin,
84 }
@@ -117,10 +137,64 @@
117 return
118 }
119 s.topoMgr.DropChannel(channel)
120 w.WriteHeader(http.StatusNoContent)
121 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
123 // handleGetTopology handles GET /v1/topology.
124 // Returns the channel type rules and static channel names declared in config.
125 func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
126 if s.topoMgr == nil {
127
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -18,15 +18,17 @@
18 RevokeAccess(nick, channel string)
19 ListChannels() []topology.ChannelInfo
20 }
21
22 type provisionChannelRequest struct {
23 Name string `json:"name"`
24 Topic string `json:"topic,omitempty"`
25 Ops []string `json:"ops,omitempty"`
26 Voice []string `json:"voice,omitempty"`
27 Autojoin []string `json:"autojoin,omitempty"`
28 Instructions string `json:"instructions,omitempty"`
29 MirrorDetail string `json:"mirror_detail,omitempty"`
30 }
31
32 type provisionChannelResponse struct {
33 Channel string `json:"channel"`
34 Type string `json:"type,omitempty"`
@@ -75,10 +77,28 @@
77 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
78 s.log.Error("provision channel", "channel", req.Name, "err", err)
79 writeError(w, http.StatusInternalServerError, "provision failed")
80 return
81 }
82
83 // Save instructions to policies if provided.
84 if req.Instructions != "" && s.policies != nil {
85 p := s.policies.Get()
86 if p.OnJoinMessages == nil {
87 p.OnJoinMessages = make(map[string]string)
88 }
89 p.OnJoinMessages[req.Name] = req.Instructions
90 if req.MirrorDetail != "" {
91 if p.Bridge.ChannelDisplay == nil {
92 p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
93 }
94 cfg := p.Bridge.ChannelDisplay[req.Name]
95 cfg.MirrorDetail = req.MirrorDetail
96 p.Bridge.ChannelDisplay[req.Name] = cfg
97 }
98 _ = s.policies.Set(p)
99 }
100
101 resp := provisionChannelResponse{
102 Channel: req.Name,
103 Autojoin: autojoin,
104 }
@@ -117,10 +137,64 @@
137 return
138 }
139 s.topoMgr.DropChannel(channel)
140 w.WriteHeader(http.StatusNoContent)
141 }
142
143 // handleGetInstructions handles GET /v1/channels/{channel}/instructions.
144 func (s *Server) handleGetInstructions(w http.ResponseWriter, r *http.Request) {
145 channel := "#" + r.PathValue("channel")
146 if s.policies == nil {
147 writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": ""})
148 return
149 }
150 p := s.policies.Get()
151 msg := p.OnJoinMessages[channel]
152 writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": msg})
153 }
154
155 // handlePutInstructions handles PUT /v1/channels/{channel}/instructions.
156 func (s *Server) handlePutInstructions(w http.ResponseWriter, r *http.Request) {
157 channel := "#" + r.PathValue("channel")
158 if s.policies == nil {
159 writeError(w, http.StatusServiceUnavailable, "policies not configured")
160 return
161 }
162 var req struct {
163 Instructions string `json:"instructions"`
164 }
165 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
166 writeError(w, http.StatusBadRequest, "invalid request body")
167 return
168 }
169 p := s.policies.Get()
170 if p.OnJoinMessages == nil {
171 p.OnJoinMessages = make(map[string]string)
172 }
173 p.OnJoinMessages[channel] = req.Instructions
174 if err := s.policies.Set(p); err != nil {
175 writeError(w, http.StatusInternalServerError, "save failed")
176 return
177 }
178 w.WriteHeader(http.StatusNoContent)
179 }
180
181 // handleDeleteInstructions handles DELETE /v1/channels/{channel}/instructions.
182 func (s *Server) handleDeleteInstructions(w http.ResponseWriter, r *http.Request) {
183 channel := "#" + r.PathValue("channel")
184 if s.policies == nil {
185 writeError(w, http.StatusServiceUnavailable, "policies not configured")
186 return
187 }
188 p := s.policies.Get()
189 delete(p.OnJoinMessages, channel)
190 if err := s.policies.Set(p); err != nil {
191 writeError(w, http.StatusInternalServerError, "save failed")
192 return
193 }
194 w.WriteHeader(http.StatusNoContent)
195 }
196
197 // handleGetTopology handles GET /v1/topology.
198 // Returns the channel type rules and static channel names declared in config.
199 func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
200 if s.topoMgr == nil {
201
--- internal/api/server.go
+++ internal/api/server.go
@@ -96,10 +96,19 @@
9696
if s.topoMgr != nil {
9797
apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
9898
apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
9999
apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
100100
}
101
+ // Blocker escalation — agents can signal they're stuck.
102
+ if s.bridge != nil {
103
+ apiMux.HandleFunc("POST /v1/agents/{nick}/blocker", s.requireScope(auth.ScopeAgents, s.handleAgentBlocker))
104
+ }
105
+
106
+ // Instructions — available even without topology (uses policies store).
107
+ apiMux.HandleFunc("GET /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleGetInstructions))
108
+ apiMux.HandleFunc("PUT /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handlePutInstructions))
109
+ apiMux.HandleFunc("DELETE /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleDeleteInstructions))
101110
102111
// Config — config scope.
103112
if s.cfgStore != nil {
104113
apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
105114
apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
106115
--- internal/api/server.go
+++ internal/api/server.go
@@ -96,10 +96,19 @@
96 if s.topoMgr != nil {
97 apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
98 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
99 apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
100 }
 
 
 
 
 
 
 
 
 
101
102 // Config — config scope.
103 if s.cfgStore != nil {
104 apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
105 apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
106
--- internal/api/server.go
+++ internal/api/server.go
@@ -96,10 +96,19 @@
96 if s.topoMgr != nil {
97 apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
98 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
99 apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
100 }
101 // Blocker escalation — agents can signal they're stuck.
102 if s.bridge != nil {
103 apiMux.HandleFunc("POST /v1/agents/{nick}/blocker", s.requireScope(auth.ScopeAgents, s.handleAgentBlocker))
104 }
105
106 // Instructions — available even without topology (uses policies store).
107 apiMux.HandleFunc("GET /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleGetInstructions))
108 apiMux.HandleFunc("PUT /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handlePutInstructions))
109 apiMux.HandleFunc("DELETE /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleDeleteInstructions))
110
111 // Config — config scope.
112 if s.cfgStore != nil {
113 apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
114 apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
115
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -102,12 +102,13 @@
102102
// webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
103103
webUserTTL time.Duration
104104
105105
msgTotal atomic.Int64
106106
107
- joinCh chan string
108
- client *girc.Client
107
+ joinCh chan string
108
+ client *girc.Client
109
+ onUserJoin func(channel, nick string) // optional callback when a non-bridge user joins
109110
110111
// RELAYMSG support detected from ISUPPORT.
111112
relaySep string // separator (e.g. "/"), empty if unsupported
112113
}
113114
@@ -152,10 +153,22 @@
152153
}
153154
b.mu.Lock()
154155
b.webUserTTL = ttl
155156
b.mu.Unlock()
156157
}
158
+
159
+// SetOnUserJoin registers a callback invoked when a non-bridge user joins a channel.
160
+func (b *Bot) SetOnUserJoin(fn func(channel, nick string)) {
161
+ b.onUserJoin = fn
162
+}
163
+
164
+// Notice sends an IRC NOTICE to the given target (nick or channel).
165
+func (b *Bot) Notice(target, text string) {
166
+ if b.client != nil {
167
+ b.client.Cmd.Notice(target, text)
168
+ }
169
+}
157170
158171
// Name returns the bot's IRC nick.
159172
func (b *Bot) Name() string { return b.nick }
160173
161174
// Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -204,25 +217,33 @@
204217
b.JoinChannel(ch)
205218
}
206219
})
207220
208221
c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
209
- if len(e.Params) < 1 || e.Source == nil || e.Source.Name != b.nick {
222
+ if len(e.Params) < 1 || e.Source == nil {
210223
return
211224
}
212225
channel := e.Params[0]
213
- b.mu.Lock()
214
- if !b.joined[channel] {
215
- b.joined[channel] = true
216
- if b.buffers[channel] == nil {
217
- b.buffers[channel] = newRingBuf(b.bufSize)
218
- b.subs[channel] = make(map[uint64]chan Message)
219
- }
220
- }
221
- b.mu.Unlock()
222
- if b.log != nil {
223
- b.log.Info("bridge joined channel", "channel", channel)
226
+ nick := e.Source.Name
227
+
228
+ if nick == b.nick {
229
+ // Bridge itself joined — initialize buffers.
230
+ b.mu.Lock()
231
+ if !b.joined[channel] {
232
+ b.joined[channel] = true
233
+ if b.buffers[channel] == nil {
234
+ b.buffers[channel] = newRingBuf(b.bufSize)
235
+ b.subs[channel] = make(map[uint64]chan Message)
236
+ }
237
+ }
238
+ b.mu.Unlock()
239
+ if b.log != nil {
240
+ b.log.Info("bridge joined channel", "channel", channel)
241
+ }
242
+ } else if b.onUserJoin != nil {
243
+ // Another user joined — fire callback for on-join instructions.
244
+ go b.onUserJoin(channel, nick)
224245
}
225246
})
226247
227248
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
228249
if len(e.Params) < 1 || e.Source == nil {
229250
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -102,12 +102,13 @@
102 // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
103 webUserTTL time.Duration
104
105 msgTotal atomic.Int64
106
107 joinCh chan string
108 client *girc.Client
 
109
110 // RELAYMSG support detected from ISUPPORT.
111 relaySep string // separator (e.g. "/"), empty if unsupported
112 }
113
@@ -152,10 +153,22 @@
152 }
153 b.mu.Lock()
154 b.webUserTTL = ttl
155 b.mu.Unlock()
156 }
 
 
 
 
 
 
 
 
 
 
 
 
157
158 // Name returns the bot's IRC nick.
159 func (b *Bot) Name() string { return b.nick }
160
161 // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -204,25 +217,33 @@
204 b.JoinChannel(ch)
205 }
206 })
207
208 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
209 if len(e.Params) < 1 || e.Source == nil || e.Source.Name != b.nick {
210 return
211 }
212 channel := e.Params[0]
213 b.mu.Lock()
214 if !b.joined[channel] {
215 b.joined[channel] = true
216 if b.buffers[channel] == nil {
217 b.buffers[channel] = newRingBuf(b.bufSize)
218 b.subs[channel] = make(map[uint64]chan Message)
219 }
220 }
221 b.mu.Unlock()
222 if b.log != nil {
223 b.log.Info("bridge joined channel", "channel", channel)
 
 
 
 
 
 
 
 
224 }
225 })
226
227 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
228 if len(e.Params) < 1 || e.Source == nil {
229
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -102,12 +102,13 @@
102 // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
103 webUserTTL time.Duration
104
105 msgTotal atomic.Int64
106
107 joinCh chan string
108 client *girc.Client
109 onUserJoin func(channel, nick string) // optional callback when a non-bridge user joins
110
111 // RELAYMSG support detected from ISUPPORT.
112 relaySep string // separator (e.g. "/"), empty if unsupported
113 }
114
@@ -152,10 +153,22 @@
153 }
154 b.mu.Lock()
155 b.webUserTTL = ttl
156 b.mu.Unlock()
157 }
158
159 // SetOnUserJoin registers a callback invoked when a non-bridge user joins a channel.
160 func (b *Bot) SetOnUserJoin(fn func(channel, nick string)) {
161 b.onUserJoin = fn
162 }
163
164 // Notice sends an IRC NOTICE to the given target (nick or channel).
165 func (b *Bot) Notice(target, text string) {
166 if b.client != nil {
167 b.client.Cmd.Notice(target, text)
168 }
169 }
170
171 // Name returns the bot's IRC nick.
172 func (b *Bot) Name() string { return b.nick }
173
174 // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -204,25 +217,33 @@
217 b.JoinChannel(ch)
218 }
219 })
220
221 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
222 if len(e.Params) < 1 || e.Source == nil {
223 return
224 }
225 channel := e.Params[0]
226 nick := e.Source.Name
227
228 if nick == b.nick {
229 // Bridge itself joined — initialize buffers.
230 b.mu.Lock()
231 if !b.joined[channel] {
232 b.joined[channel] = true
233 if b.buffers[channel] == nil {
234 b.buffers[channel] = newRingBuf(b.bufSize)
235 b.subs[channel] = make(map[uint64]chan Message)
236 }
237 }
238 b.mu.Unlock()
239 if b.log != nil {
240 b.log.Info("bridge joined channel", "channel", channel)
241 }
242 } else if b.onUserJoin != nil {
243 // Another user joined — fire callback for on-join instructions.
244 go b.onUserJoin(channel, nick)
245 }
246 })
247
248 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
249 if len(e.Params) < 1 || e.Source == nil {
250
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -35,10 +35,11 @@
3535
Nick string `json:"nick"`
3636
Type AgentType `json:"type"`
3737
Channels []string `json:"channels"` // convenience: same as Config.Channels
3838
Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
3939
Config EngagementConfig `json:"config"`
40
+ Skills []string `json:"skills,omitempty"` // agent capabilities (e.g. "go", "python", "react")
4041
CreatedAt time.Time `json:"created_at"`
4142
Revoked bool `json:"revoked"`
4243
LastSeen *time.Time `json:"last_seen,omitempty"`
4344
Online bool `json:"online"`
4445
}
@@ -354,10 +355,22 @@
354355
return nil
355356
}
356357
357358
// UpdateChannels replaces the channel list for an active agent.
358359
// Used by relay brokers to sync runtime /join and /part changes back to the registry.
360
+// Update persists changes to an existing agent record.
361
+func (r *Registry) Update(agent *Agent) error {
362
+ r.mu.Lock()
363
+ defer r.mu.Unlock()
364
+ if _, ok := r.agents[agent.Nick]; !ok {
365
+ return fmt.Errorf("registry: agent %q not found", agent.Nick)
366
+ }
367
+ r.agents[agent.Nick] = agent
368
+ r.saveOne(agent)
369
+ return nil
370
+}
371
+
359372
func (r *Registry) UpdateChannels(nick string, channels []string) error {
360373
r.mu.Lock()
361374
defer r.mu.Unlock()
362375
agent, err := r.get(nick)
363376
if err != nil {
364377
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -35,10 +35,11 @@
35 Nick string `json:"nick"`
36 Type AgentType `json:"type"`
37 Channels []string `json:"channels"` // convenience: same as Config.Channels
38 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39 Config EngagementConfig `json:"config"`
 
40 CreatedAt time.Time `json:"created_at"`
41 Revoked bool `json:"revoked"`
42 LastSeen *time.Time `json:"last_seen,omitempty"`
43 Online bool `json:"online"`
44 }
@@ -354,10 +355,22 @@
354 return nil
355 }
356
357 // UpdateChannels replaces the channel list for an active agent.
358 // Used by relay brokers to sync runtime /join and /part changes back to the registry.
 
 
 
 
 
 
 
 
 
 
 
 
359 func (r *Registry) UpdateChannels(nick string, channels []string) error {
360 r.mu.Lock()
361 defer r.mu.Unlock()
362 agent, err := r.get(nick)
363 if err != nil {
364
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -35,10 +35,11 @@
35 Nick string `json:"nick"`
36 Type AgentType `json:"type"`
37 Channels []string `json:"channels"` // convenience: same as Config.Channels
38 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39 Config EngagementConfig `json:"config"`
40 Skills []string `json:"skills,omitempty"` // agent capabilities (e.g. "go", "python", "react")
41 CreatedAt time.Time `json:"created_at"`
42 Revoked bool `json:"revoked"`
43 LastSeen *time.Time `json:"last_seen,omitempty"`
44 Online bool `json:"online"`
45 }
@@ -354,10 +355,22 @@
355 return nil
356 }
357
358 // UpdateChannels replaces the channel list for an active agent.
359 // Used by relay brokers to sync runtime /join and /part changes back to the registry.
360 // Update persists changes to an existing agent record.
361 func (r *Registry) Update(agent *Agent) error {
362 r.mu.Lock()
363 defer r.mu.Unlock()
364 if _, ok := r.agents[agent.Nick]; !ok {
365 return fmt.Errorf("registry: agent %q not found", agent.Nick)
366 }
367 r.agents[agent.Nick] = agent
368 r.saveOne(agent)
369 return nil
370 }
371
372 func (r *Registry) UpdateChannels(nick string, channels []string) error {
373 r.mu.Lock()
374 defer r.mu.Unlock()
375 agent, err := r.get(nick)
376 if err != nil {
377

Keyboard Shortcuts

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