ScuttleBot

fix: address Codex review findings (#24 #25 #26 #27 #29 #30) Mirror failure (#24): - mirrorSessionLoop posts a notice on session discovery error or tail error so operators know when IRC activity is blind, instead of silently returning Input loop death (#29): - relayInputLoop in claude/codex/gemini-relay posts a notice before exiting on handleRelayCommand or injectMessages error Registry drift (#25): - registry.UpdateChannels syncs channel list in-place and saves - PATCH /v1/agents/{nick} calls UpdateChannels; registered in server.go - ircConnector.syncChannelsToRegistry PATCHes the registry after every live /join or /part so the Agents tab stays accurate Hook same-second message loss (#26): - Replace epoch_seconds with epoch_millis (preserves sub-second precision from RFC3339Nano API timestamps) - Replace 'date +%s' with now_millis() (python3 / date +%s%3N fallback) - Migrate stale second-precision LAST_CHECK files on first upgrade - Applied to all three runtimes: claude, codex, gemini fleet-cmd (#27): - Add --channel flag (default: general) to map and broadcast - Accept 204 No Content as broadcast success (was incorrectly expecting 200) - Close resp.Body in broadcast Stale script (#30): - Delete skills/scuttlebot-relay/scripts/claude-relay.sh; the canonical entry point is the claude-relay binary built by install-claude-relay.sh

lmata 2026-04-01 20:24 trunk
Commit 87e69785676cc58a8459f969925095557fb34a3d83c6594f25f5fa132fd0489d
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -263,20 +263,25 @@
263263
// --- Session mirroring ---
264264
265265
func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
266266
sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
267267
if err != nil {
268
+ if ctx.Err() == nil {
269
+ _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
270
+ }
268271
return
269272
}
270
- _ = tailSessionFile(ctx, sessionPath, func(text string) {
273
+ if err := tailSessionFile(ctx, sessionPath, func(text string) {
271274
for _, line := range splitMirrorText(text) {
272275
if line == "" {
273276
continue
274277
}
275278
_ = relay.Post(ctx, line)
276279
}
277
- })
280
+ }); err != nil && ctx.Err() == nil {
281
+ _ = relay.Post(context.Background(), fmt.Sprintf("mirror lost: %v — session activity no longer visible in IRC", err))
282
+ }
278283
}
279284
280285
func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
281286
root, err := claudeSessionsRoot(cfg.TargetCWD)
282287
if err != nil {
@@ -595,10 +600,13 @@
595600
lastSeen = newest
596601
pending := make([]message, 0, len(batch))
597602
for _, msg := range batch {
598603
handled, err := handleRelayCommand(ctx, relay, cfg, msg)
599604
if err != nil {
605
+ if ctx.Err() == nil {
606
+ _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
607
+ }
600608
return
601609
}
602610
if handled {
603611
continue
604612
}
@@ -606,10 +614,13 @@
606614
}
607615
if len(pending) == 0 {
608616
continue
609617
}
610618
if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
619
+ if ctx.Err() == nil {
620
+ _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
621
+ }
611622
return
612623
}
613624
}
614625
}
615626
}
616627
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -263,20 +263,25 @@
263 // --- Session mirroring ---
264
265 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
266 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
267 if err != nil {
 
 
 
268 return
269 }
270 _ = tailSessionFile(ctx, sessionPath, func(text string) {
271 for _, line := range splitMirrorText(text) {
272 if line == "" {
273 continue
274 }
275 _ = relay.Post(ctx, line)
276 }
277 })
 
 
278 }
279
280 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
281 root, err := claudeSessionsRoot(cfg.TargetCWD)
282 if err != nil {
@@ -595,10 +600,13 @@
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 }
@@ -606,10 +614,13 @@
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 }
616
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -263,20 +263,25 @@
263 // --- Session mirroring ---
264
265 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
266 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
267 if err != nil {
268 if ctx.Err() == nil {
269 _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
270 }
271 return
272 }
273 if err := tailSessionFile(ctx, sessionPath, func(text string) {
274 for _, line := range splitMirrorText(text) {
275 if line == "" {
276 continue
277 }
278 _ = relay.Post(ctx, line)
279 }
280 }); err != nil && ctx.Err() == nil {
281 _ = relay.Post(context.Background(), fmt.Sprintf("mirror lost: %v — session activity no longer visible in IRC", err))
282 }
283 }
284
285 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
286 root, err := claudeSessionsRoot(cfg.TargetCWD)
287 if err != nil {
@@ -595,10 +600,13 @@
600 lastSeen = newest
601 pending := make([]message, 0, len(batch))
602 for _, msg := range batch {
603 handled, err := handleRelayCommand(ctx, relay, cfg, msg)
604 if err != nil {
605 if ctx.Err() == nil {
606 _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
607 }
608 return
609 }
610 if handled {
611 continue
612 }
@@ -606,10 +614,13 @@
614 }
615 if len(pending) == 0 {
616 continue
617 }
618 if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
619 if ctx.Err() == nil {
620 _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
621 }
622 return
623 }
624 }
625 }
626 }
627
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -302,10 +302,13 @@
302302
lastSeen = newest
303303
pending := make([]message, 0, len(batch))
304304
for _, msg := range batch {
305305
handled, err := handleRelayCommand(ctx, relay, cfg, msg)
306306
if err != nil {
307
+ if ctx.Err() == nil {
308
+ _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
309
+ }
307310
return
308311
}
309312
if handled {
310313
continue
311314
}
@@ -313,10 +316,13 @@
313316
}
314317
if len(pending) == 0 {
315318
continue
316319
}
317320
if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
321
+ if ctx.Err() == nil {
322
+ _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
323
+ }
318324
return
319325
}
320326
}
321327
}
322328
}
@@ -709,20 +715,25 @@
709715
}
710716
711717
func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
712718
sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
713719
if err != nil {
720
+ if ctx.Err() == nil {
721
+ _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
722
+ }
714723
return
715724
}
716
- _ = tailSessionFile(ctx, sessionPath, func(text string) {
725
+ if err := tailSessionFile(ctx, sessionPath, func(text string) {
717726
for _, line := range splitMirrorText(text) {
718727
if line == "" {
719728
continue
720729
}
721730
_ = relay.Post(ctx, line)
722731
}
723
- })
732
+ }); err != nil && ctx.Err() == nil {
733
+ _ = relay.Post(context.Background(), fmt.Sprintf("mirror lost: %v — session activity no longer visible in IRC", err))
734
+ }
724735
}
725736
726737
func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
727738
root, err := codexSessionsRoot()
728739
if err != nil {
729740
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -302,10 +302,13 @@
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 }
@@ -313,10 +316,13 @@
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 }
@@ -709,20 +715,25 @@
709 }
710
711 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
712 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
713 if err != nil {
 
 
 
714 return
715 }
716 _ = tailSessionFile(ctx, sessionPath, func(text string) {
717 for _, line := range splitMirrorText(text) {
718 if line == "" {
719 continue
720 }
721 _ = relay.Post(ctx, line)
722 }
723 })
 
 
724 }
725
726 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
727 root, err := codexSessionsRoot()
728 if err != nil {
729
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -302,10 +302,13 @@
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 if ctx.Err() == nil {
308 _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
309 }
310 return
311 }
312 if handled {
313 continue
314 }
@@ -313,10 +316,13 @@
316 }
317 if len(pending) == 0 {
318 continue
319 }
320 if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
321 if ctx.Err() == nil {
322 _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
323 }
324 return
325 }
326 }
327 }
328 }
@@ -709,20 +715,25 @@
715 }
716
717 func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) {
718 sessionPath, err := discoverSessionPath(ctx, cfg, startedAt)
719 if err != nil {
720 if ctx.Err() == nil {
721 _ = relay.Post(context.Background(), fmt.Sprintf("mirror failed: %v — session activity not visible in IRC", err))
722 }
723 return
724 }
725 if err := tailSessionFile(ctx, sessionPath, func(text string) {
726 for _, line := range splitMirrorText(text) {
727 if line == "" {
728 continue
729 }
730 _ = relay.Post(ctx, line)
731 }
732 }); err != nil && ctx.Err() == nil {
733 _ = relay.Post(context.Background(), fmt.Sprintf("mirror lost: %v — session activity no longer visible in IRC", err))
734 }
735 }
736
737 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
738 root, err := codexSessionsRoot()
739 if err != nil {
740
--- cmd/fleet-cmd/main.go
+++ cmd/fleet-cmd/main.go
@@ -37,34 +37,54 @@
3737
3838
if len(os.Args) < 2 {
3939
usage()
4040
}
4141
42
- switch os.Args[1] {
42
+ // Parse optional --channel flag before the subcommand.
43
+ channel := "general"
44
+ args := os.Args[1:]
45
+ for i := 0; i < len(args); i++ {
46
+ if args[i] == "--channel" && i+1 < len(args) {
47
+ channel = strings.TrimPrefix(args[i+1], "#")
48
+ args = append(args[:i], args[i+2:]...)
49
+ break
50
+ }
51
+ if strings.HasPrefix(args[i], "--channel=") {
52
+ channel = strings.TrimPrefix(strings.TrimPrefix(args[i], "--channel="), "#")
53
+ args = append(args[:i], args[i+1:]...)
54
+ break
55
+ }
56
+ }
57
+
58
+ if len(args) == 0 {
59
+ usage()
60
+ }
61
+
62
+ switch args[0] {
4363
case "map":
44
- mapFleet(url, token)
64
+ mapFleet(url, token, channel)
4565
case "broadcast":
46
- if len(os.Args) < 3 {
66
+ if len(args) < 2 {
4767
log.Fatal("usage: fleet-cmd broadcast <message>")
4868
}
49
- broadcast(url, token, strings.Join(os.Args[2:], " "))
69
+ broadcast(url, token, channel, strings.Join(args[1:], " "))
5070
default:
5171
usage()
5272
}
5373
}
5474
5575
func usage() {
56
- fmt.Println("Usage: fleet-cmd <command> [args]")
76
+ fmt.Println("Usage: fleet-cmd [--channel <channel>] <command> [args]")
5777
fmt.Println("Commands:")
5878
fmt.Println(" map Show all agents and their last activity")
59
- fmt.Println(" broadcast Send a message to all agents in #general")
79
+ fmt.Println(" broadcast Send a message to the channel")
6080
os.Exit(1)
6181
}
6282
63
-func mapFleet(url, token string) {
83
+func mapFleet(url, token, channel string) {
6484
agents := fetchAgents(url, token)
65
- messages := fetchMessages(url, token, "general")
85
+ messages := fetchMessages(url, token, channel)
6686
6787
// Filter for actual session nicks (ones with suffixes)
6888
sessions := make(map[string]Message)
6989
for _, m := range messages {
7090
if strings.Contains(m.Nick, "-") {
@@ -94,27 +114,28 @@
94114
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", nick, nickType, truncate(m.Text, 40), timeSince(m.At))
95115
}
96116
w.Flush()
97117
}
98118
99
-func broadcast(url, token, msg string) {
119
+func broadcast(url, token, channel, msg string) {
100120
body, _ := json.Marshal(map[string]string{
101121
"nick": "commander",
102122
"text": msg,
103123
})
104
- req, _ := http.NewRequest("POST", url+"/v1/channels/general/messages", strings.NewReader(string(body)))
124
+ req, _ := http.NewRequest("POST", url+"/v1/channels/"+channel+"/messages", strings.NewReader(string(body)))
105125
req.Header.Set("Authorization", "Bearer "+token)
106126
req.Header.Set("Content-Type", "application/json")
107127
108128
resp, err := http.DefaultClient.Do(req)
109129
if err != nil {
110130
log.Fatal(err)
111131
}
112
- if resp.StatusCode != http.StatusOK {
132
+ defer resp.Body.Close()
133
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
113134
log.Fatalf("broadcast failed: %s", resp.Status)
114135
}
115
- fmt.Printf("Broadcast sent: %s\n", msg)
136
+ fmt.Printf("Broadcast sent to #%s: %s\n", channel, msg)
116137
}
117138
118139
func fetchAgents(url, token string) []Agent {
119140
req, _ := http.NewRequest("GET", url+"/v1/agents", nil)
120141
req.Header.Set("Authorization", "Bearer "+token)
121142
--- cmd/fleet-cmd/main.go
+++ cmd/fleet-cmd/main.go
@@ -37,34 +37,54 @@
37
38 if len(os.Args) < 2 {
39 usage()
40 }
41
42 switch os.Args[1] {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43 case "map":
44 mapFleet(url, token)
45 case "broadcast":
46 if len(os.Args) < 3 {
47 log.Fatal("usage: fleet-cmd broadcast <message>")
48 }
49 broadcast(url, token, strings.Join(os.Args[2:], " "))
50 default:
51 usage()
52 }
53 }
54
55 func usage() {
56 fmt.Println("Usage: fleet-cmd <command> [args]")
57 fmt.Println("Commands:")
58 fmt.Println(" map Show all agents and their last activity")
59 fmt.Println(" broadcast Send a message to all agents in #general")
60 os.Exit(1)
61 }
62
63 func mapFleet(url, token string) {
64 agents := fetchAgents(url, token)
65 messages := fetchMessages(url, token, "general")
66
67 // Filter for actual session nicks (ones with suffixes)
68 sessions := make(map[string]Message)
69 for _, m := range messages {
70 if strings.Contains(m.Nick, "-") {
@@ -94,27 +114,28 @@
94 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", nick, nickType, truncate(m.Text, 40), timeSince(m.At))
95 }
96 w.Flush()
97 }
98
99 func broadcast(url, token, msg string) {
100 body, _ := json.Marshal(map[string]string{
101 "nick": "commander",
102 "text": msg,
103 })
104 req, _ := http.NewRequest("POST", url+"/v1/channels/general/messages", strings.NewReader(string(body)))
105 req.Header.Set("Authorization", "Bearer "+token)
106 req.Header.Set("Content-Type", "application/json")
107
108 resp, err := http.DefaultClient.Do(req)
109 if err != nil {
110 log.Fatal(err)
111 }
112 if resp.StatusCode != http.StatusOK {
 
113 log.Fatalf("broadcast failed: %s", resp.Status)
114 }
115 fmt.Printf("Broadcast sent: %s\n", msg)
116 }
117
118 func fetchAgents(url, token string) []Agent {
119 req, _ := http.NewRequest("GET", url+"/v1/agents", nil)
120 req.Header.Set("Authorization", "Bearer "+token)
121
--- cmd/fleet-cmd/main.go
+++ cmd/fleet-cmd/main.go
@@ -37,34 +37,54 @@
37
38 if len(os.Args) < 2 {
39 usage()
40 }
41
42 // Parse optional --channel flag before the subcommand.
43 channel := "general"
44 args := os.Args[1:]
45 for i := 0; i < len(args); i++ {
46 if args[i] == "--channel" && i+1 < len(args) {
47 channel = strings.TrimPrefix(args[i+1], "#")
48 args = append(args[:i], args[i+2:]...)
49 break
50 }
51 if strings.HasPrefix(args[i], "--channel=") {
52 channel = strings.TrimPrefix(strings.TrimPrefix(args[i], "--channel="), "#")
53 args = append(args[:i], args[i+1:]...)
54 break
55 }
56 }
57
58 if len(args) == 0 {
59 usage()
60 }
61
62 switch args[0] {
63 case "map":
64 mapFleet(url, token, channel)
65 case "broadcast":
66 if len(args) < 2 {
67 log.Fatal("usage: fleet-cmd broadcast <message>")
68 }
69 broadcast(url, token, channel, strings.Join(args[1:], " "))
70 default:
71 usage()
72 }
73 }
74
75 func usage() {
76 fmt.Println("Usage: fleet-cmd [--channel <channel>] <command> [args]")
77 fmt.Println("Commands:")
78 fmt.Println(" map Show all agents and their last activity")
79 fmt.Println(" broadcast Send a message to the channel")
80 os.Exit(1)
81 }
82
83 func mapFleet(url, token, channel string) {
84 agents := fetchAgents(url, token)
85 messages := fetchMessages(url, token, channel)
86
87 // Filter for actual session nicks (ones with suffixes)
88 sessions := make(map[string]Message)
89 for _, m := range messages {
90 if strings.Contains(m.Nick, "-") {
@@ -94,27 +114,28 @@
114 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", nick, nickType, truncate(m.Text, 40), timeSince(m.At))
115 }
116 w.Flush()
117 }
118
119 func broadcast(url, token, channel, msg string) {
120 body, _ := json.Marshal(map[string]string{
121 "nick": "commander",
122 "text": msg,
123 })
124 req, _ := http.NewRequest("POST", url+"/v1/channels/"+channel+"/messages", strings.NewReader(string(body)))
125 req.Header.Set("Authorization", "Bearer "+token)
126 req.Header.Set("Content-Type", "application/json")
127
128 resp, err := http.DefaultClient.Do(req)
129 if err != nil {
130 log.Fatal(err)
131 }
132 defer resp.Body.Close()
133 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
134 log.Fatalf("broadcast failed: %s", resp.Status)
135 }
136 fmt.Printf("Broadcast sent to #%s: %s\n", channel, msg)
137 }
138
139 func fetchAgents(url, token string) []Agent {
140 req, _ := http.NewRequest("GET", url+"/v1/agents", nil)
141 req.Header.Set("Authorization", "Bearer "+token)
142
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -251,10 +251,13 @@
251251
lastSeen = newest
252252
pending := make([]message, 0, len(batch))
253253
for _, msg := range batch {
254254
handled, err := handleRelayCommand(ctx, relay, cfg, msg)
255255
if err != nil {
256
+ if ctx.Err() == nil {
257
+ _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
258
+ }
256259
return
257260
}
258261
if handled {
259262
continue
260263
}
@@ -262,10 +265,13 @@
262265
}
263266
if len(pending) == 0 {
264267
continue
265268
}
266269
if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
270
+ if ctx.Err() == nil {
271
+ _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
272
+ }
267273
return
268274
}
269275
}
270276
}
271277
}
272278
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -251,10 +251,13 @@
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 }
@@ -262,10 +265,13 @@
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 }
272
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -251,10 +251,13 @@
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 if ctx.Err() == nil {
257 _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
258 }
259 return
260 }
261 if handled {
262 continue
263 }
@@ -262,10 +265,13 @@
265 }
266 if len(pending) == 0 {
267 continue
268 }
269 if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
270 if ctx.Err() == nil {
271 _ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
272 }
273 return
274 }
275 }
276 }
277 }
278
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -131,10 +131,31 @@
131131
return
132132
}
133133
s.log.Error("delete agent", "nick", nick, "err", err)
134134
writeError(w, http.StatusInternalServerError, "deletion failed")
135135
return
136
+ }
137
+ w.WriteHeader(http.StatusNoContent)
138
+}
139
+
140
+func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
141
+ nick := r.PathValue("nick")
142
+ var req struct {
143
+ Channels []string `json:"channels"`
144
+ }
145
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
146
+ writeError(w, http.StatusBadRequest, "invalid request body")
147
+ return
148
+ }
149
+ if err := s.registry.UpdateChannels(nick, req.Channels); err != nil {
150
+ if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
151
+ writeError(w, http.StatusNotFound, err.Error())
152
+ return
153
+ }
154
+ s.log.Error("update agent channels", "nick", nick, "err", err)
155
+ writeError(w, http.StatusInternalServerError, "update failed")
156
+ return
136157
}
137158
w.WriteHeader(http.StatusNoContent)
138159
}
139160
140161
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
141162
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -131,10 +131,31 @@
131 return
132 }
133 s.log.Error("delete agent", "nick", nick, "err", err)
134 writeError(w, http.StatusInternalServerError, "deletion failed")
135 return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136 }
137 w.WriteHeader(http.StatusNoContent)
138 }
139
140 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
141
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -131,10 +131,31 @@
131 return
132 }
133 s.log.Error("delete agent", "nick", nick, "err", err)
134 writeError(w, http.StatusInternalServerError, "deletion failed")
135 return
136 }
137 w.WriteHeader(http.StatusNoContent)
138 }
139
140 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
141 nick := r.PathValue("nick")
142 var req struct {
143 Channels []string `json:"channels"`
144 }
145 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
146 writeError(w, http.StatusBadRequest, "invalid request body")
147 return
148 }
149 if err := s.registry.UpdateChannels(nick, req.Channels); err != nil {
150 if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
151 writeError(w, http.StatusNotFound, err.Error())
152 return
153 }
154 s.log.Error("update agent channels", "nick", nick, "err", err)
155 writeError(w, http.StatusInternalServerError, "update failed")
156 return
157 }
158 w.WriteHeader(http.StatusNoContent)
159 }
160
161 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
162
--- internal/api/server.go
+++ internal/api/server.go
@@ -58,10 +58,11 @@
5858
apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
5959
apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
6060
}
6161
apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
6262
apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
63
+ apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
6364
apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
6465
apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
6566
apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
6667
apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
6768
apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
6869
--- internal/api/server.go
+++ internal/api/server.go
@@ -58,10 +58,11 @@
58 apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
59 apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
60 }
61 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
62 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
 
63 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
64 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
65 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
66 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
67 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
68
--- internal/api/server.go
+++ internal/api/server.go
@@ -58,10 +58,11 @@
58 apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
59 apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
60 }
61 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
62 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
63 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
64 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
65 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
66 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
67 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
68 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
69
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -281,10 +281,25 @@
281281
282282
delete(r.agents, nick)
283283
r.save()
284284
return nil
285285
}
286
+
287
+// UpdateChannels replaces the channel list for an active agent.
288
+// Used by relay brokers to sync runtime /join and /part changes back to the registry.
289
+func (r *Registry) UpdateChannels(nick string, channels []string) error {
290
+ r.mu.Lock()
291
+ defer r.mu.Unlock()
292
+ agent, err := r.get(nick)
293
+ if err != nil {
294
+ return err
295
+ }
296
+ agent.Channels = append([]string(nil), channels...)
297
+ agent.Config.Channels = append([]string(nil), channels...)
298
+ r.save()
299
+ return nil
300
+}
286301
287302
// Get returns the agent with the given nick.
288303
func (r *Registry) Get(nick string) (*Agent, error) {
289304
r.mu.RLock()
290305
defer r.mu.RUnlock()
291306
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -281,10 +281,25 @@
281
282 delete(r.agents, nick)
283 r.save()
284 return nil
285 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
287 // Get returns the agent with the given nick.
288 func (r *Registry) Get(nick string) (*Agent, error) {
289 r.mu.RLock()
290 defer r.mu.RUnlock()
291
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -281,10 +281,25 @@
281
282 delete(r.agents, nick)
283 r.save()
284 return nil
285 }
286
287 // UpdateChannels replaces the channel list for an active agent.
288 // Used by relay brokers to sync runtime /join and /part changes back to the registry.
289 func (r *Registry) UpdateChannels(nick string, channels []string) error {
290 r.mu.Lock()
291 defer r.mu.Unlock()
292 agent, err := r.get(nick)
293 if err != nil {
294 return err
295 }
296 agent.Channels = append([]string(nil), channels...)
297 agent.Config.Channels = append([]string(nil), channels...)
298 r.save()
299 return nil
300 }
301
302 // Get returns the agent with the given nick.
303 func (r *Registry) Get(nick string) (*Agent, error) {
304 r.mu.RLock()
305 defer r.mu.RUnlock()
306
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -168,11 +168,11 @@
168168
169169
func (c *ircConnector) Touch(context.Context) error {
170170
return nil
171171
}
172172
173
-func (c *ircConnector) JoinChannel(_ context.Context, channel string) error {
173
+func (c *ircConnector) JoinChannel(ctx context.Context, channel string) error {
174174
channel = normalizeChannel(channel)
175175
if channel == "" {
176176
return fmt.Errorf("sessionrelay: join channel is required")
177177
}
178178
c.mu.Lock()
@@ -184,14 +184,15 @@
184184
client := c.client
185185
c.mu.Unlock()
186186
if client != nil {
187187
client.Cmd.Join(channel)
188188
}
189
+ go c.syncChannelsToRegistry(ctx)
189190
return nil
190191
}
191192
192
-func (c *ircConnector) PartChannel(_ context.Context, channel string) error {
193
+func (c *ircConnector) PartChannel(ctx context.Context, channel string) error {
193194
channel = normalizeChannel(channel)
194195
if channel == "" {
195196
return fmt.Errorf("sessionrelay: part channel is required")
196197
}
197198
if channel == c.primary {
@@ -213,12 +214,37 @@
213214
client := c.client
214215
c.mu.Unlock()
215216
if client != nil {
216217
client.Cmd.Part(channel)
217218
}
219
+ go c.syncChannelsToRegistry(ctx)
218220
return nil
219221
}
222
+
223
+// syncChannelsToRegistry PATCHes the agent's channel list in the registry so
224
+// the Agents tab stays in sync after live /join and /part commands.
225
+func (c *ircConnector) syncChannelsToRegistry(ctx context.Context) {
226
+ if c.apiURL == "" || c.token == "" || c.nick == "" {
227
+ return
228
+ }
229
+ channels := c.Channels()
230
+ body, err := json.Marshal(map[string]any{"channels": channels})
231
+ if err != nil {
232
+ return
233
+ }
234
+ req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.apiURL+"/v1/agents/"+c.nick, bytes.NewReader(body))
235
+ if err != nil {
236
+ return
237
+ }
238
+ req.Header.Set("Authorization", "Bearer "+c.token)
239
+ req.Header.Set("Content-Type", "application/json")
240
+ resp, err := c.http.Do(req)
241
+ if err != nil {
242
+ return
243
+ }
244
+ resp.Body.Close()
245
+}
220246
221247
func (c *ircConnector) Channels() []string {
222248
c.mu.RLock()
223249
defer c.mu.RUnlock()
224250
return append([]string(nil), c.channels...)
225251
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -168,11 +168,11 @@
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()
@@ -184,14 +184,15 @@
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 {
@@ -213,12 +214,37 @@
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
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -168,11 +168,11 @@
168
169 func (c *ircConnector) Touch(context.Context) error {
170 return nil
171 }
172
173 func (c *ircConnector) JoinChannel(ctx 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()
@@ -184,14 +184,15 @@
184 client := c.client
185 c.mu.Unlock()
186 if client != nil {
187 client.Cmd.Join(channel)
188 }
189 go c.syncChannelsToRegistry(ctx)
190 return nil
191 }
192
193 func (c *ircConnector) PartChannel(ctx context.Context, channel string) error {
194 channel = normalizeChannel(channel)
195 if channel == "" {
196 return fmt.Errorf("sessionrelay: part channel is required")
197 }
198 if channel == c.primary {
@@ -213,12 +214,37 @@
214 client := c.client
215 c.mu.Unlock()
216 if client != nil {
217 client.Cmd.Part(channel)
218 }
219 go c.syncChannelsToRegistry(ctx)
220 return nil
221 }
222
223 // syncChannelsToRegistry PATCHes the agent's channel list in the registry so
224 // the Agents tab stays in sync after live /join and /part commands.
225 func (c *ircConnector) syncChannelsToRegistry(ctx context.Context) {
226 if c.apiURL == "" || c.token == "" || c.nick == "" {
227 return
228 }
229 channels := c.Channels()
230 body, err := json.Marshal(map[string]any{"channels": channels})
231 if err != nil {
232 return
233 }
234 req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.apiURL+"/v1/agents/"+c.nick, bytes.NewReader(body))
235 if err != nil {
236 return
237 }
238 req.Header.Set("Authorization", "Bearer "+c.token)
239 req.Header.Set("Content-Type", "application/json")
240 resp, err := c.http.Do(req)
241 if err != nil {
242 return
243 }
244 resp.Body.Close()
245 }
246
247 func (c *ircConnector) Channels() []string {
248 c.mu.RLock()
249 defer c.mu.RUnlock()
250 return append([]string(nil), c.channels...)
251
--- scuttlectl
+++ scuttlectl
cannot compute difference between binary files
11
--- scuttlectl
+++ scuttlectl
0 annot compute difference between binary files
1
--- scuttlectl
+++ scuttlectl
0 annot compute difference between binary files
1
--- skills/gemini-relay/hooks/scuttlebot-check.sh
+++ skills/gemini-relay/hooks/scuttlebot-check.sh
@@ -56,17 +56,29 @@
5656
contains_mention() {
5757
local text="$1"
5858
[[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
5959
}
6060
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"
61
+epoch_millis() {
62
+ local at="$1" ts_secs ts_frac ts_clean frac
63
+ ts_frac=$(printf '%s' "$at" | grep -oE '\.[0-9]+' | head -1)
64
+ ts_clean=$(printf '%s' "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
65
+ ts_secs=$(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
+ [ -n "$ts_secs" ] || return
68
+ if [ -n "$ts_frac" ]; then
69
+ frac="${ts_frac#.}000"
70
+ printf '%s%s' "$ts_secs" "${frac:0:3}"
71
+ else
72
+ printf '%s000' "$ts_secs"
73
+ fi
74
+}
75
+
76
+now_millis() {
77
+ python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || \
78
+ date +%s%3N 2>/dev/null || \
79
+ printf '%s000' "$(date +%s)"
6880
}
6981
7082
base_name=$(basename "$(pwd)")
7183
base_name=$(sanitize "$base_name")
7284
session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}"
@@ -85,12 +97,16 @@
8597
LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
8698
8799
last_check=0
88100
if [ -f "$LAST_CHECK_FILE" ]; then
89101
last_check=$(cat "$LAST_CHECK_FILE")
102
+ # Migrate from second-precision to millisecond-precision on first upgrade.
103
+ if [ "$last_check" -lt 100000000000 ] 2>/dev/null; then
104
+ last_check=$((last_check * 1000))
105
+ fi
90106
fi
91
-now=$(date +%s)
107
+now=$(now_millis)
92108
echo "$now" > "$LAST_CHECK_FILE"
93109
94110
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
95111
96112
instruction=$(
@@ -111,11 +127,11 @@
111127
$n != $self
112128
)
113129
| "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
114130
' 2>/dev/null
115131
done | while IFS=$'\t' read -r at channel nick text; do
116
- ts=$(epoch_seconds "$at")
132
+ ts=$(epoch_millis "$at")
117133
[ -n "$ts" ] || continue
118134
[ "$ts" -gt "$last_check" ] || continue
119135
contains_mention "$text" || continue
120136
printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
121137
done | sort -n | tail -1 | cut -f2-
122138
--- skills/gemini-relay/hooks/scuttlebot-check.sh
+++ skills/gemini-relay/hooks/scuttlebot-check.sh
@@ -56,17 +56,29 @@
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}}"
@@ -85,12 +97,16 @@
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=$(
@@ -111,11 +127,11 @@
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
--- skills/gemini-relay/hooks/scuttlebot-check.sh
+++ skills/gemini-relay/hooks/scuttlebot-check.sh
@@ -56,17 +56,29 @@
56 contains_mention() {
57 local text="$1"
58 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
59 }
60
61 epoch_millis() {
62 local at="$1" ts_secs ts_frac ts_clean frac
63 ts_frac=$(printf '%s' "$at" | grep -oE '\.[0-9]+' | head -1)
64 ts_clean=$(printf '%s' "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
65 ts_secs=$(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 [ -n "$ts_secs" ] || return
68 if [ -n "$ts_frac" ]; then
69 frac="${ts_frac#.}000"
70 printf '%s%s' "$ts_secs" "${frac:0:3}"
71 else
72 printf '%s000' "$ts_secs"
73 fi
74 }
75
76 now_millis() {
77 python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || \
78 date +%s%3N 2>/dev/null || \
79 printf '%s000' "$(date +%s)"
80 }
81
82 base_name=$(basename "$(pwd)")
83 base_name=$(sanitize "$base_name")
84 session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}"
@@ -85,12 +97,16 @@
97 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
98
99 last_check=0
100 if [ -f "$LAST_CHECK_FILE" ]; then
101 last_check=$(cat "$LAST_CHECK_FILE")
102 # Migrate from second-precision to millisecond-precision on first upgrade.
103 if [ "$last_check" -lt 100000000000 ] 2>/dev/null; then
104 last_check=$((last_check * 1000))
105 fi
106 fi
107 now=$(now_millis)
108 echo "$now" > "$LAST_CHECK_FILE"
109
110 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
111
112 instruction=$(
@@ -111,11 +127,11 @@
127 $n != $self
128 )
129 | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
130 ' 2>/dev/null
131 done | while IFS=$'\t' read -r at channel nick text; do
132 ts=$(epoch_millis "$at")
133 [ -n "$ts" ] || continue
134 [ "$ts" -gt "$last_check" ] || continue
135 contains_mention "$text" || continue
136 printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
137 done | sort -n | tail -1 | cut -f2-
138
--- skills/openai-relay/hooks/scuttlebot-check.sh
+++ skills/openai-relay/hooks/scuttlebot-check.sh
@@ -52,17 +52,29 @@
5252
contains_mention() {
5353
local text="$1"
5454
[[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
5555
}
5656
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"
57
+epoch_millis() {
58
+ local at="$1" ts_secs ts_frac ts_clean frac
59
+ ts_frac=$(printf '%s' "$at" | grep -oE '\.[0-9]+' | head -1)
60
+ ts_clean=$(printf '%s' "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
61
+ ts_secs=$(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
+ [ -n "$ts_secs" ] || return
64
+ if [ -n "$ts_frac" ]; then
65
+ frac="${ts_frac#.}000"
66
+ printf '%s%s' "$ts_secs" "${frac:0:3}"
67
+ else
68
+ printf '%s000' "$ts_secs"
69
+ fi
70
+}
71
+
72
+now_millis() {
73
+ python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || \
74
+ date +%s%3N 2>/dev/null || \
75
+ printf '%s000' "$(date +%s)"
6476
}
6577
6678
base_name=$(basename "$(pwd)")
6779
base_name=$(sanitize "$base_name")
6880
session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}"
@@ -78,12 +90,16 @@
7890
LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
7991
8092
last_check=0
8193
if [ -f "$LAST_CHECK_FILE" ]; then
8294
last_check=$(cat "$LAST_CHECK_FILE")
95
+ # Migrate from second-precision to millisecond-precision on first upgrade.
96
+ if [ "$last_check" -lt 100000000000 ] 2>/dev/null; then
97
+ last_check=$((last_check * 1000))
98
+ fi
8399
fi
84
-now=$(date +%s)
100
+now=$(now_millis)
85101
echo "$now" > "$LAST_CHECK_FILE"
86102
87103
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
88104
89105
instruction=$(
@@ -104,11 +120,11 @@
104120
$n != $self
105121
)
106122
| "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
107123
' 2>/dev/null
108124
done | while IFS=$'\t' read -r at channel nick text; do
109
- ts=$(epoch_seconds "$at")
125
+ ts=$(epoch_millis "$at")
110126
[ -n "$ts" ] || continue
111127
[ "$ts" -gt "$last_check" ] || continue
112128
contains_mention "$text" || continue
113129
printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
114130
done | sort -n | tail -1 | cut -f2-
115131
--- skills/openai-relay/hooks/scuttlebot-check.sh
+++ skills/openai-relay/hooks/scuttlebot-check.sh
@@ -52,17 +52,29 @@
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}}"
@@ -78,12 +90,16 @@
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=$(
@@ -104,11 +120,11 @@
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
--- skills/openai-relay/hooks/scuttlebot-check.sh
+++ skills/openai-relay/hooks/scuttlebot-check.sh
@@ -52,17 +52,29 @@
52 contains_mention() {
53 local text="$1"
54 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
55 }
56
57 epoch_millis() {
58 local at="$1" ts_secs ts_frac ts_clean frac
59 ts_frac=$(printf '%s' "$at" | grep -oE '\.[0-9]+' | head -1)
60 ts_clean=$(printf '%s' "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
61 ts_secs=$(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 [ -n "$ts_secs" ] || return
64 if [ -n "$ts_frac" ]; then
65 frac="${ts_frac#.}000"
66 printf '%s%s' "$ts_secs" "${frac:0:3}"
67 else
68 printf '%s000' "$ts_secs"
69 fi
70 }
71
72 now_millis() {
73 python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || \
74 date +%s%3N 2>/dev/null || \
75 printf '%s000' "$(date +%s)"
76 }
77
78 base_name=$(basename "$(pwd)")
79 base_name=$(sanitize "$base_name")
80 session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}"
@@ -78,12 +90,16 @@
90 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
91
92 last_check=0
93 if [ -f "$LAST_CHECK_FILE" ]; then
94 last_check=$(cat "$LAST_CHECK_FILE")
95 # Migrate from second-precision to millisecond-precision on first upgrade.
96 if [ "$last_check" -lt 100000000000 ] 2>/dev/null; then
97 last_check=$((last_check * 1000))
98 fi
99 fi
100 now=$(now_millis)
101 echo "$now" > "$LAST_CHECK_FILE"
102
103 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
104
105 instruction=$(
@@ -104,11 +120,11 @@
120 $n != $self
121 )
122 | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
123 ' 2>/dev/null
124 done | while IFS=$'\t' read -r at channel nick text; do
125 ts=$(epoch_millis "$at")
126 [ -n "$ts" ] || continue
127 [ "$ts" -gt "$last_check" ] || continue
128 contains_mention "$text" || continue
129 printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
130 done | sort -n | tail -1 | cut -f2-
131
--- skills/scuttlebot-relay/hooks/scuttlebot-check.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-check.sh
@@ -55,17 +55,29 @@
5555
contains_mention() {
5656
local text="$1"
5757
[[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
5858
}
5959
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"
60
+epoch_millis() {
61
+ local at="$1" ts_secs ts_frac ts_clean frac
62
+ ts_frac=$(printf '%s' "$at" | grep -oE '\.[0-9]+' | head -1)
63
+ ts_clean=$(printf '%s' "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
64
+ ts_secs=$(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
+ [ -n "$ts_secs" ] || return
67
+ if [ -n "$ts_frac" ]; then
68
+ frac="${ts_frac#.}000"
69
+ printf '%s%s' "$ts_secs" "${frac:0:3}"
70
+ else
71
+ printf '%s000' "$ts_secs"
72
+ fi
73
+}
74
+
75
+now_millis() {
76
+ python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || \
77
+ date +%s%3N 2>/dev/null || \
78
+ printf '%s000' "$(date +%s)"
6779
}
6880
6981
cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null)
7082
if [ -z "$cwd" ]; then cwd=$(pwd); fi
7183
base_name=$(sanitize "$(basename "$cwd")")
@@ -81,12 +93,16 @@
8193
LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
8294
8395
last_check=0
8496
if [ -f "$LAST_CHECK_FILE" ]; then
8597
last_check=$(cat "$LAST_CHECK_FILE")
98
+ # Migrate from second-precision to millisecond-precision on first upgrade.
99
+ if [ "$last_check" -lt 100000000000 ] 2>/dev/null; then
100
+ last_check=$((last_check * 1000))
101
+ fi
86102
fi
87
-now=$(date +%s)
103
+now=$(now_millis)
88104
echo "$now" > "$LAST_CHECK_FILE"
89105
90106
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
91107
92108
instruction=$(
@@ -107,11 +123,11 @@
107123
$n != $self
108124
)
109125
| "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
110126
' 2>/dev/null
111127
done | while IFS=$'\t' read -r at channel nick text; do
112
- ts=$(epoch_seconds "$at")
128
+ ts=$(epoch_millis "$at")
113129
[ -n "$ts" ] || continue
114130
[ "$ts" -gt "$last_check" ] || continue
115131
contains_mention "$text" || continue
116132
printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
117133
done | sort -n | tail -1 | cut -f2-
118134
119135
DELETED skills/scuttlebot-relay/scripts/claude-relay.sh
--- skills/scuttlebot-relay/hooks/scuttlebot-check.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-check.sh
@@ -55,17 +55,29 @@
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")")
@@ -81,12 +93,16 @@
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=$(
@@ -107,11 +123,11 @@
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 ELETED skills/scuttlebot-relay/scripts/claude-relay.sh
--- skills/scuttlebot-relay/hooks/scuttlebot-check.sh
+++ skills/scuttlebot-relay/hooks/scuttlebot-check.sh
@@ -55,17 +55,29 @@
55 contains_mention() {
56 local text="$1"
57 [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]]
58 }
59
60 epoch_millis() {
61 local at="$1" ts_secs ts_frac ts_clean frac
62 ts_frac=$(printf '%s' "$at" | grep -oE '\.[0-9]+' | head -1)
63 ts_clean=$(printf '%s' "$at" | sed 's/\.[0-9]*//' | sed 's/\([+-][0-9][0-9]\):\([0-9][0-9]\)$/\1\2/')
64 ts_secs=$(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 [ -n "$ts_secs" ] || return
67 if [ -n "$ts_frac" ]; then
68 frac="${ts_frac#.}000"
69 printf '%s%s' "$ts_secs" "${frac:0:3}"
70 else
71 printf '%s000' "$ts_secs"
72 fi
73 }
74
75 now_millis() {
76 python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || \
77 date +%s%3N 2>/dev/null || \
78 printf '%s000' "$(date +%s)"
79 }
80
81 cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null)
82 if [ -z "$cwd" ]; then cwd=$(pwd); fi
83 base_name=$(sanitize "$(basename "$cwd")")
@@ -81,12 +93,16 @@
93 LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key"
94
95 last_check=0
96 if [ -f "$LAST_CHECK_FILE" ]; then
97 last_check=$(cat "$LAST_CHECK_FILE")
98 # Migrate from second-precision to millisecond-precision on first upgrade.
99 if [ "$last_check" -lt 100000000000 ] 2>/dev/null; then
100 last_check=$((last_check * 1000))
101 fi
102 fi
103 now=$(now_millis)
104 echo "$now" > "$LAST_CHECK_FILE"
105
106 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]'
107
108 instruction=$(
@@ -107,11 +123,11 @@
123 $n != $self
124 )
125 | "\(.at)\t\($channel)\t\(.nick)\t\(.text)"
126 ' 2>/dev/null
127 done | while IFS=$'\t' read -r at channel nick text; do
128 ts=$(epoch_millis "$at")
129 [ -n "$ts" ] || continue
130 [ "$ts" -gt "$last_check" ] || continue
131 contains_mention "$text" || continue
132 printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text"
133 done | sort -n | tail -1 | cut -f2-
134
135 ELETED skills/scuttlebot-relay/scripts/claude-relay.sh
D skills/scuttlebot-relay/scripts/claude-relay.sh
-196
--- a/skills/scuttlebot-relay/scripts/claude-relay.sh
+++ b/skills/scuttlebot-relay/scripts/claude-relay.sh
@@ -1,196 +0,0 @@
1
-#!/usr/bin/env bash
2
-# Launch Claude with a fleet-style session nick.
3
-# Registers a claude-{project}-{session} nick, starts the IRC agent in the
4
-# background under that nick (so hook activity and IRC responses share one
5
-# identity), then runs the Claude CLI. Deregisters on exit.
6
-
7
-set -u
8
-
9
-SCUTTLEBOT_CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
10
-if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
11
- set -a
12
- . "$SCUTTLEBOT_CONFIG_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_IRC="${SCUTTLEBOT_IRC:-127.0.0.1:6667}"
21
-SCUTTLEBOT_BACKEND="${SCUTTLEBOT_BACKEND:-anthro}"
22
-CLAUDE_AGENT_BIN="${CLAUDE_AGENT_BIN:-}"
23
-CLAUDE_BIN="${CLAUDE_BIN:-claude}"
24
-
25
-sanitize() {
26
- local input="$1"
27
- if [ -z "$input" ]; then
28
- input=$(cat)
29
- fi
30
- printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
31
-}
32
-
33
-target_cwd() {
34
- local cwd="$PWD"
35
- local prev=""
36
- local arg
37
- for arg in "$@"; do
38
- if [ "$prev" = "-C" ] || [ "$prev" = "--cd" ]; then
39
- cwd="$arg"
40
- prev=""
41
- continue
42
- fi
43
- case "$arg" in
44
- -C|--cd)
45
- prev="$arg"
46
- ;;
47
- -C=*|--cd=*)
48
- cwd="${arg#*=}"
49
- ;;
50
- esac
51
- done
52
- if [ -d "$cwd" ]; then
53
- (cd "$cwd" && pwd)
54
- else
55
- printf '%s\n' "$PWD"
56
- fi
57
-}
58
-
59
-hooks_enabled() {
60
- [ "$SCUTTLEBOT_HOOKS_ENABLED" != "0" ] &&
61
- [ "$SCUTTLEBOT_HOOKS_ENABLED" != "false" ] &&
62
- [ -n "$SCUTTLEBOT_TOKEN" ]
63
-}
64
-
65
-post_status() {
66
- local text="$1"
67
- hooks_enabled || return 0
68
- command -v curl >/dev/null 2>&1 || return 0
69
- command -v jq >/dev/null 2>&1 || return 0
70
- curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
71
- --connect-timeout 1 \
72
- --max-time 2 \
73
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
74
- -H "Content-Type: application/json" \
75
- -d "{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
76
- > /dev/null || true
77
-}
78
-
79
-if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
80
- printf 'claude-relay: %s not found in PATH\n' "$CLAUDE_BIN" >&2
81
- exit 127
82
-fi
83
-
84
-TARGET_CWD=$(target_cwd "$@")
85
-BASE_NAME=$(sanitize "$(basename "$TARGET_CWD")")
86
-
87
-if [ -z "${SCUTTLEBOT_SESSION_ID:-}" ]; then
88
- SCUTTLEBOT_SESSION_ID=$(
89
- printf '%s' "$TARGET_CWD|$$|$PPID|$(date +%s)" | cksum | awk '{print $1}' | cut -c 1-8
90
- )
91
-fi
92
-SCUTTLEBOT_SESSION_ID=$(sanitize "$SCUTTLEBOT_SESSION_ID")
93
-if [ -z "${SCUTTLEBOT_NICK:-}" ]; then
94
- SCUTTLEBOT_NICK="claude-${BASE_NAME}-${SCUTTLEBOT_SESSION_ID}"
95
-fi
96
-SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL#\#}"
97
-
98
-export SCUTTLEBOT_CONFIG_FILE
99
-export SCUTTLEBOT_URL
100
-export SCUTTLEBOT_TOKEN
101
-export SCUTTLEBOT_CHANNEL
102
-export SCUTTLEBOT_HOOKS_ENABLED
103
-export SCUTTLEBOT_SESSION_ID
104
-export SCUTTLEBOT_NICK
105
-
106
-printf 'claude-relay: nick %s\n' "$SCUTTLEBOT_NICK" >&2
107
-
108
-# --- IRC agent: register nick and start in background ---
109
-irc_agent_pid=""
110
-irc_agent_nick=""
111
-
112
-_start_irc_agent() {
113
- [ -n "$SCUTTLEBOT_TOKEN" ] || return 0
114
-
115
- # Find the claude-agent binary: next to this script, in PATH, or skip.
116
- local bin="$CLAUDE_AGENT_BIN"
117
- if [ -z "$bin" ]; then
118
- local script_dir; script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
119
- local repo_root; repo_root=$(CDPATH= cd -- "$script_dir/../../.." && pwd)
120
- if [ -x "$repo_root/bin/claude-agent" ]; then
121
- bin="$repo_root/bin/claude-agent"
122
- elif command -v claude-agent >/dev/null 2>&1; then
123
- bin="claude-agent"
124
- else
125
- printf 'claude-relay: claude-agent not found, IRC responses disabled\n' >&2
126
- return 0
127
- fi
128
- fi
129
-
130
- local resp; resp=$(curl -sf -X POST \
131
- --connect-timeout 2 --max-time 5 \
132
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
133
- -H "Content-Type: application/json" \
134
- -d "{\"nick\":\"$SCUTTLEBOT_NICK\",\"type\":\"worker\",\"channels\":[\"#$SCUTTLEBOT_CHANNEL\"]}" \
135
- "$SCUTTLEBOT_URL/v1/agents/register" 2>/dev/null) || return 0
136
-
137
- local pass; pass=$(printf '%s' "$resp" | grep -o '"passphrase":"[^"]*"' | cut -d'"' -f4)
138
- [ -n "$pass" ] || return 0
139
-
140
- irc_agent_nick="$SCUTTLEBOT_NICK"
141
- "$bin" \
142
- --irc "$SCUTTLEBOT_IRC" \
143
- --nick "$irc_agent_nick" \
144
- --pass "$pass" \
145
- --channels "#$SCUTTLEBOT_CHANNEL" \
146
- --api-url "$SCUTTLEBOT_URL" \
147
- --token "$SCUTTLEBOT_TOKEN" \
148
- --backend "$SCUTTLEBOT_BACKEND" \
149
- 2>/dev/null &
150
- irc_agent_pid=$!
151
- printf 'claude-relay: IRC agent started (pid %s)\n' "$irc_agent_pid" >&2
152
-}
153
-
154
-_stop_irc_agent() {
155
- if [ -n "$irc_agent_pid" ]; then
156
- kill "$irc_agent_pid" 2>/dev/null || true
157
- irc_agent_pid=""
158
- fi
159
- if [ -n "$irc_agent_nick" ] && [ -n "$SCUTTLEBOT_TOKEN" ]; then
160
- curl -sf -X DELETE \
161
- --connect-timeout 2 --max-time 5 \
162
- -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
163
- "$SCUTTLEBOT_URL/v1/agents/$irc_agent_nick" >/dev/null 2>&1 || true
164
- irc_agent_nick=""
165
- fi
166
-}
167
-
168
-_start_irc_agent
169
-
170
-# --- Claude CLI ---
171
-post_status "online in $(basename "$TARGET_CWD"); mention $SCUTTLEBOT_NICK to interrupt"
172
-
173
-child_pid=""
174
-_cleanup() {
175
- [ -n "$child_pid" ] && kill "$child_pid" 2>/dev/null || true
176
- _stop_irc_agent
177
- post_status "offline"
178
-}
179
-
180
-forward_signal() {
181
- local signal="$1"
182
- [ -n "$child_pid" ] && kill "-$signal" "$child_pid" 2>/dev/null || true
183
-}
184
-
185
-trap '_cleanup' EXIT
186
-trap 'forward_signal TERM' TERM
187
-trap 'forward_signal INT' INT
188
-trap 'forward_signal HUP' HUP
189
-
190
-"$CLAUDE_BIN" "$@" &
191
-child_pid=$!
192
-wait "$child_pid"
193
-status=$?
194
-child_pid=""
195
-
196
-exit "$status"
--- a/skills/scuttlebot-relay/scripts/claude-relay.sh
+++ b/skills/scuttlebot-relay/scripts/claude-relay.sh
@@ -1,196 +0,0 @@
1 #!/usr/bin/env bash
2 # Launch Claude with a fleet-style session nick.
3 # Registers a claude-{project}-{session} nick, starts the IRC agent in the
4 # background under that nick (so hook activity and IRC responses share one
5 # identity), then runs the Claude CLI. Deregisters on exit.
6
7 set -u
8
9 SCUTTLEBOT_CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
10 if [ -f "$SCUTTLEBOT_CONFIG_FILE" ]; then
11 set -a
12 . "$SCUTTLEBOT_CONFIG_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_IRC="${SCUTTLEBOT_IRC:-127.0.0.1:6667}"
21 SCUTTLEBOT_BACKEND="${SCUTTLEBOT_BACKEND:-anthro}"
22 CLAUDE_AGENT_BIN="${CLAUDE_AGENT_BIN:-}"
23 CLAUDE_BIN="${CLAUDE_BIN:-claude}"
24
25 sanitize() {
26 local input="$1"
27 if [ -z "$input" ]; then
28 input=$(cat)
29 fi
30 printf '%s' "$input" | tr -cs '[:alnum:]_-' '-'
31 }
32
33 target_cwd() {
34 local cwd="$PWD"
35 local prev=""
36 local arg
37 for arg in "$@"; do
38 if [ "$prev" = "-C" ] || [ "$prev" = "--cd" ]; then
39 cwd="$arg"
40 prev=""
41 continue
42 fi
43 case "$arg" in
44 -C|--cd)
45 prev="$arg"
46 ;;
47 -C=*|--cd=*)
48 cwd="${arg#*=}"
49 ;;
50 esac
51 done
52 if [ -d "$cwd" ]; then
53 (cd "$cwd" && pwd)
54 else
55 printf '%s\n' "$PWD"
56 fi
57 }
58
59 hooks_enabled() {
60 [ "$SCUTTLEBOT_HOOKS_ENABLED" != "0" ] &&
61 [ "$SCUTTLEBOT_HOOKS_ENABLED" != "false" ] &&
62 [ -n "$SCUTTLEBOT_TOKEN" ]
63 }
64
65 post_status() {
66 local text="$1"
67 hooks_enabled || return 0
68 command -v curl >/dev/null 2>&1 || return 0
69 command -v jq >/dev/null 2>&1 || return 0
70 curl -sf -X POST "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" \
71 --connect-timeout 1 \
72 --max-time 2 \
73 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
74 -H "Content-Type: application/json" \
75 -d "{\"text\": $(printf '%s' "$text" | jq -Rs .), \"nick\": \"$SCUTTLEBOT_NICK\"}" \
76 > /dev/null || true
77 }
78
79 if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
80 printf 'claude-relay: %s not found in PATH\n' "$CLAUDE_BIN" >&2
81 exit 127
82 fi
83
84 TARGET_CWD=$(target_cwd "$@")
85 BASE_NAME=$(sanitize "$(basename "$TARGET_CWD")")
86
87 if [ -z "${SCUTTLEBOT_SESSION_ID:-}" ]; then
88 SCUTTLEBOT_SESSION_ID=$(
89 printf '%s' "$TARGET_CWD|$$|$PPID|$(date +%s)" | cksum | awk '{print $1}' | cut -c 1-8
90 )
91 fi
92 SCUTTLEBOT_SESSION_ID=$(sanitize "$SCUTTLEBOT_SESSION_ID")
93 if [ -z "${SCUTTLEBOT_NICK:-}" ]; then
94 SCUTTLEBOT_NICK="claude-${BASE_NAME}-${SCUTTLEBOT_SESSION_ID}"
95 fi
96 SCUTTLEBOT_CHANNEL="${SCUTTLEBOT_CHANNEL#\#}"
97
98 export SCUTTLEBOT_CONFIG_FILE
99 export SCUTTLEBOT_URL
100 export SCUTTLEBOT_TOKEN
101 export SCUTTLEBOT_CHANNEL
102 export SCUTTLEBOT_HOOKS_ENABLED
103 export SCUTTLEBOT_SESSION_ID
104 export SCUTTLEBOT_NICK
105
106 printf 'claude-relay: nick %s\n' "$SCUTTLEBOT_NICK" >&2
107
108 # --- IRC agent: register nick and start in background ---
109 irc_agent_pid=""
110 irc_agent_nick=""
111
112 _start_irc_agent() {
113 [ -n "$SCUTTLEBOT_TOKEN" ] || return 0
114
115 # Find the claude-agent binary: next to this script, in PATH, or skip.
116 local bin="$CLAUDE_AGENT_BIN"
117 if [ -z "$bin" ]; then
118 local script_dir; script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
119 local repo_root; repo_root=$(CDPATH= cd -- "$script_dir/../../.." && pwd)
120 if [ -x "$repo_root/bin/claude-agent" ]; then
121 bin="$repo_root/bin/claude-agent"
122 elif command -v claude-agent >/dev/null 2>&1; then
123 bin="claude-agent"
124 else
125 printf 'claude-relay: claude-agent not found, IRC responses disabled\n' >&2
126 return 0
127 fi
128 fi
129
130 local resp; resp=$(curl -sf -X POST \
131 --connect-timeout 2 --max-time 5 \
132 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
133 -H "Content-Type: application/json" \
134 -d "{\"nick\":\"$SCUTTLEBOT_NICK\",\"type\":\"worker\",\"channels\":[\"#$SCUTTLEBOT_CHANNEL\"]}" \
135 "$SCUTTLEBOT_URL/v1/agents/register" 2>/dev/null) || return 0
136
137 local pass; pass=$(printf '%s' "$resp" | grep -o '"passphrase":"[^"]*"' | cut -d'"' -f4)
138 [ -n "$pass" ] || return 0
139
140 irc_agent_nick="$SCUTTLEBOT_NICK"
141 "$bin" \
142 --irc "$SCUTTLEBOT_IRC" \
143 --nick "$irc_agent_nick" \
144 --pass "$pass" \
145 --channels "#$SCUTTLEBOT_CHANNEL" \
146 --api-url "$SCUTTLEBOT_URL" \
147 --token "$SCUTTLEBOT_TOKEN" \
148 --backend "$SCUTTLEBOT_BACKEND" \
149 2>/dev/null &
150 irc_agent_pid=$!
151 printf 'claude-relay: IRC agent started (pid %s)\n' "$irc_agent_pid" >&2
152 }
153
154 _stop_irc_agent() {
155 if [ -n "$irc_agent_pid" ]; then
156 kill "$irc_agent_pid" 2>/dev/null || true
157 irc_agent_pid=""
158 fi
159 if [ -n "$irc_agent_nick" ] && [ -n "$SCUTTLEBOT_TOKEN" ]; then
160 curl -sf -X DELETE \
161 --connect-timeout 2 --max-time 5 \
162 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
163 "$SCUTTLEBOT_URL/v1/agents/$irc_agent_nick" >/dev/null 2>&1 || true
164 irc_agent_nick=""
165 fi
166 }
167
168 _start_irc_agent
169
170 # --- Claude CLI ---
171 post_status "online in $(basename "$TARGET_CWD"); mention $SCUTTLEBOT_NICK to interrupt"
172
173 child_pid=""
174 _cleanup() {
175 [ -n "$child_pid" ] && kill "$child_pid" 2>/dev/null || true
176 _stop_irc_agent
177 post_status "offline"
178 }
179
180 forward_signal() {
181 local signal="$1"
182 [ -n "$child_pid" ] && kill "-$signal" "$child_pid" 2>/dev/null || true
183 }
184
185 trap '_cleanup' EXIT
186 trap 'forward_signal TERM' TERM
187 trap 'forward_signal INT' INT
188 trap 'forward_signal HUP' HUP
189
190 "$CLAUDE_BIN" "$@" &
191 child_pid=$!
192 wait "$child_pid"
193 status=$?
194 child_pid=""
195
196 exit "$status"
--- a/skills/scuttlebot-relay/scripts/claude-relay.sh
+++ b/skills/scuttlebot-relay/scripts/claude-relay.sh
@@ -1,196 +0,0 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Keyboard Shortcuts

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