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
87e69785676cc58a8459f969925095557fb34a3d83c6594f25f5fa132fd0489d
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -263,20 +263,25 @@ | ||
| 263 | 263 | // --- Session mirroring --- |
| 264 | 264 | |
| 265 | 265 | func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) { |
| 266 | 266 | sessionPath, err := discoverSessionPath(ctx, cfg, startedAt) |
| 267 | 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 | + } | |
| 268 | 271 | return |
| 269 | 272 | } |
| 270 | - _ = tailSessionFile(ctx, sessionPath, func(text string) { | |
| 273 | + if err := tailSessionFile(ctx, sessionPath, func(text string) { | |
| 271 | 274 | for _, line := range splitMirrorText(text) { |
| 272 | 275 | if line == "" { |
| 273 | 276 | continue |
| 274 | 277 | } |
| 275 | 278 | _ = relay.Post(ctx, line) |
| 276 | 279 | } |
| 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 | + } | |
| 278 | 283 | } |
| 279 | 284 | |
| 280 | 285 | func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) { |
| 281 | 286 | root, err := claudeSessionsRoot(cfg.TargetCWD) |
| 282 | 287 | if err != nil { |
| @@ -595,10 +600,13 @@ | ||
| 595 | 600 | lastSeen = newest |
| 596 | 601 | pending := make([]message, 0, len(batch)) |
| 597 | 602 | for _, msg := range batch { |
| 598 | 603 | handled, err := handleRelayCommand(ctx, relay, cfg, msg) |
| 599 | 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 | + } | |
| 600 | 608 | return |
| 601 | 609 | } |
| 602 | 610 | if handled { |
| 603 | 611 | continue |
| 604 | 612 | } |
| @@ -606,10 +614,13 @@ | ||
| 606 | 614 | } |
| 607 | 615 | if len(pending) == 0 { |
| 608 | 616 | continue |
| 609 | 617 | } |
| 610 | 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 | + } | |
| 611 | 622 | return |
| 612 | 623 | } |
| 613 | 624 | } |
| 614 | 625 | } |
| 615 | 626 | } |
| 616 | 627 |
| --- 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 @@ | ||
| 302 | 302 | lastSeen = newest |
| 303 | 303 | pending := make([]message, 0, len(batch)) |
| 304 | 304 | for _, msg := range batch { |
| 305 | 305 | handled, err := handleRelayCommand(ctx, relay, cfg, msg) |
| 306 | 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 | + } | |
| 307 | 310 | return |
| 308 | 311 | } |
| 309 | 312 | if handled { |
| 310 | 313 | continue |
| 311 | 314 | } |
| @@ -313,10 +316,13 @@ | ||
| 313 | 316 | } |
| 314 | 317 | if len(pending) == 0 { |
| 315 | 318 | continue |
| 316 | 319 | } |
| 317 | 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 | + } | |
| 318 | 324 | return |
| 319 | 325 | } |
| 320 | 326 | } |
| 321 | 327 | } |
| 322 | 328 | } |
| @@ -709,20 +715,25 @@ | ||
| 709 | 715 | } |
| 710 | 716 | |
| 711 | 717 | func mirrorSessionLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, startedAt time.Time) { |
| 712 | 718 | sessionPath, err := discoverSessionPath(ctx, cfg, startedAt) |
| 713 | 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 | + } | |
| 714 | 723 | return |
| 715 | 724 | } |
| 716 | - _ = tailSessionFile(ctx, sessionPath, func(text string) { | |
| 725 | + if err := tailSessionFile(ctx, sessionPath, func(text string) { | |
| 717 | 726 | for _, line := range splitMirrorText(text) { |
| 718 | 727 | if line == "" { |
| 719 | 728 | continue |
| 720 | 729 | } |
| 721 | 730 | _ = relay.Post(ctx, line) |
| 722 | 731 | } |
| 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 | + } | |
| 724 | 735 | } |
| 725 | 736 | |
| 726 | 737 | func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) { |
| 727 | 738 | root, err := codexSessionsRoot() |
| 728 | 739 | if err != nil { |
| 729 | 740 |
| --- 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 @@ | ||
| 37 | 37 | |
| 38 | 38 | if len(os.Args) < 2 { |
| 39 | 39 | usage() |
| 40 | 40 | } |
| 41 | 41 | |
| 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] { | |
| 43 | 63 | case "map": |
| 44 | - mapFleet(url, token) | |
| 64 | + mapFleet(url, token, channel) | |
| 45 | 65 | case "broadcast": |
| 46 | - if len(os.Args) < 3 { | |
| 66 | + if len(args) < 2 { | |
| 47 | 67 | log.Fatal("usage: fleet-cmd broadcast <message>") |
| 48 | 68 | } |
| 49 | - broadcast(url, token, strings.Join(os.Args[2:], " ")) | |
| 69 | + broadcast(url, token, channel, strings.Join(args[1:], " ")) | |
| 50 | 70 | default: |
| 51 | 71 | usage() |
| 52 | 72 | } |
| 53 | 73 | } |
| 54 | 74 | |
| 55 | 75 | func usage() { |
| 56 | - fmt.Println("Usage: fleet-cmd <command> [args]") | |
| 76 | + fmt.Println("Usage: fleet-cmd [--channel <channel>] <command> [args]") | |
| 57 | 77 | fmt.Println("Commands:") |
| 58 | 78 | 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") | |
| 60 | 80 | os.Exit(1) |
| 61 | 81 | } |
| 62 | 82 | |
| 63 | -func mapFleet(url, token string) { | |
| 83 | +func mapFleet(url, token, channel string) { | |
| 64 | 84 | agents := fetchAgents(url, token) |
| 65 | - messages := fetchMessages(url, token, "general") | |
| 85 | + messages := fetchMessages(url, token, channel) | |
| 66 | 86 | |
| 67 | 87 | // Filter for actual session nicks (ones with suffixes) |
| 68 | 88 | sessions := make(map[string]Message) |
| 69 | 89 | for _, m := range messages { |
| 70 | 90 | if strings.Contains(m.Nick, "-") { |
| @@ -94,27 +114,28 @@ | ||
| 94 | 114 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", nick, nickType, truncate(m.Text, 40), timeSince(m.At)) |
| 95 | 115 | } |
| 96 | 116 | w.Flush() |
| 97 | 117 | } |
| 98 | 118 | |
| 99 | -func broadcast(url, token, msg string) { | |
| 119 | +func broadcast(url, token, channel, msg string) { | |
| 100 | 120 | body, _ := json.Marshal(map[string]string{ |
| 101 | 121 | "nick": "commander", |
| 102 | 122 | "text": msg, |
| 103 | 123 | }) |
| 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))) | |
| 105 | 125 | req.Header.Set("Authorization", "Bearer "+token) |
| 106 | 126 | req.Header.Set("Content-Type", "application/json") |
| 107 | 127 | |
| 108 | 128 | resp, err := http.DefaultClient.Do(req) |
| 109 | 129 | if err != nil { |
| 110 | 130 | log.Fatal(err) |
| 111 | 131 | } |
| 112 | - if resp.StatusCode != http.StatusOK { | |
| 132 | + defer resp.Body.Close() | |
| 133 | + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { | |
| 113 | 134 | log.Fatalf("broadcast failed: %s", resp.Status) |
| 114 | 135 | } |
| 115 | - fmt.Printf("Broadcast sent: %s\n", msg) | |
| 136 | + fmt.Printf("Broadcast sent to #%s: %s\n", channel, msg) | |
| 116 | 137 | } |
| 117 | 138 | |
| 118 | 139 | func fetchAgents(url, token string) []Agent { |
| 119 | 140 | req, _ := http.NewRequest("GET", url+"/v1/agents", nil) |
| 120 | 141 | req.Header.Set("Authorization", "Bearer "+token) |
| 121 | 142 |
| --- 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 @@ | ||
| 251 | 251 | lastSeen = newest |
| 252 | 252 | pending := make([]message, 0, len(batch)) |
| 253 | 253 | for _, msg := range batch { |
| 254 | 254 | handled, err := handleRelayCommand(ctx, relay, cfg, msg) |
| 255 | 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 | + } | |
| 256 | 259 | return |
| 257 | 260 | } |
| 258 | 261 | if handled { |
| 259 | 262 | continue |
| 260 | 263 | } |
| @@ -262,10 +265,13 @@ | ||
| 262 | 265 | } |
| 263 | 266 | if len(pending) == 0 { |
| 264 | 267 | continue |
| 265 | 268 | } |
| 266 | 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 | + } | |
| 267 | 273 | return |
| 268 | 274 | } |
| 269 | 275 | } |
| 270 | 276 | } |
| 271 | 277 | } |
| 272 | 278 |
| --- 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 @@ | ||
| 131 | 131 | return |
| 132 | 132 | } |
| 133 | 133 | s.log.Error("delete agent", "nick", nick, "err", err) |
| 134 | 134 | writeError(w, http.StatusInternalServerError, "deletion failed") |
| 135 | 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 | |
| 136 | 157 | } |
| 137 | 158 | w.WriteHeader(http.StatusNoContent) |
| 138 | 159 | } |
| 139 | 160 | |
| 140 | 161 | func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { |
| 141 | 162 |
| --- 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 @@ | ||
| 58 | 58 | apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies) |
| 59 | 59 | apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies) |
| 60 | 60 | } |
| 61 | 61 | apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) |
| 62 | 62 | apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) |
| 63 | + apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent) | |
| 63 | 64 | apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) |
| 64 | 65 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) |
| 65 | 66 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt) |
| 66 | 67 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) |
| 67 | 68 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete) |
| 68 | 69 |
| --- 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 @@ | ||
| 281 | 281 | |
| 282 | 282 | delete(r.agents, nick) |
| 283 | 283 | r.save() |
| 284 | 284 | return nil |
| 285 | 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 | +} | |
| 286 | 301 | |
| 287 | 302 | // Get returns the agent with the given nick. |
| 288 | 303 | func (r *Registry) Get(nick string) (*Agent, error) { |
| 289 | 304 | r.mu.RLock() |
| 290 | 305 | defer r.mu.RUnlock() |
| 291 | 306 |
| --- 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 @@ | ||
| 168 | 168 | |
| 169 | 169 | func (c *ircConnector) Touch(context.Context) error { |
| 170 | 170 | return nil |
| 171 | 171 | } |
| 172 | 172 | |
| 173 | -func (c *ircConnector) JoinChannel(_ context.Context, channel string) error { | |
| 173 | +func (c *ircConnector) JoinChannel(ctx context.Context, channel string) error { | |
| 174 | 174 | channel = normalizeChannel(channel) |
| 175 | 175 | if channel == "" { |
| 176 | 176 | return fmt.Errorf("sessionrelay: join channel is required") |
| 177 | 177 | } |
| 178 | 178 | c.mu.Lock() |
| @@ -184,14 +184,15 @@ | ||
| 184 | 184 | client := c.client |
| 185 | 185 | c.mu.Unlock() |
| 186 | 186 | if client != nil { |
| 187 | 187 | client.Cmd.Join(channel) |
| 188 | 188 | } |
| 189 | + go c.syncChannelsToRegistry(ctx) | |
| 189 | 190 | return nil |
| 190 | 191 | } |
| 191 | 192 | |
| 192 | -func (c *ircConnector) PartChannel(_ context.Context, channel string) error { | |
| 193 | +func (c *ircConnector) PartChannel(ctx context.Context, channel string) error { | |
| 193 | 194 | channel = normalizeChannel(channel) |
| 194 | 195 | if channel == "" { |
| 195 | 196 | return fmt.Errorf("sessionrelay: part channel is required") |
| 196 | 197 | } |
| 197 | 198 | if channel == c.primary { |
| @@ -213,12 +214,37 @@ | ||
| 213 | 214 | client := c.client |
| 214 | 215 | c.mu.Unlock() |
| 215 | 216 | if client != nil { |
| 216 | 217 | client.Cmd.Part(channel) |
| 217 | 218 | } |
| 219 | + go c.syncChannelsToRegistry(ctx) | |
| 218 | 220 | return nil |
| 219 | 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 | +} | |
| 220 | 246 | |
| 221 | 247 | func (c *ircConnector) Channels() []string { |
| 222 | 248 | c.mu.RLock() |
| 223 | 249 | defer c.mu.RUnlock() |
| 224 | 250 | return append([]string(nil), c.channels...) |
| 225 | 251 |
| --- 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 | ||
| 1 | 1 |
| --- 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 @@ | ||
| 56 | 56 | contains_mention() { |
| 57 | 57 | local text="$1" |
| 58 | 58 | [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]] |
| 59 | 59 | } |
| 60 | 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" | |
| 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)" | |
| 68 | 80 | } |
| 69 | 81 | |
| 70 | 82 | base_name=$(basename "$(pwd)") |
| 71 | 83 | base_name=$(sanitize "$base_name") |
| 72 | 84 | session_raw="${SCUTTLEBOT_SESSION_ID:-${GEMINI_SESSION_ID:-$PPID}}" |
| @@ -85,12 +97,16 @@ | ||
| 85 | 97 | LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key" |
| 86 | 98 | |
| 87 | 99 | last_check=0 |
| 88 | 100 | if [ -f "$LAST_CHECK_FILE" ]; then |
| 89 | 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 | |
| 90 | 106 | fi |
| 91 | -now=$(date +%s) | |
| 107 | +now=$(now_millis) | |
| 92 | 108 | echo "$now" > "$LAST_CHECK_FILE" |
| 93 | 109 | |
| 94 | 110 | BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]' |
| 95 | 111 | |
| 96 | 112 | instruction=$( |
| @@ -111,11 +127,11 @@ | ||
| 111 | 127 | $n != $self |
| 112 | 128 | ) |
| 113 | 129 | | "\(.at)\t\($channel)\t\(.nick)\t\(.text)" |
| 114 | 130 | ' 2>/dev/null |
| 115 | 131 | done | while IFS=$'\t' read -r at channel nick text; do |
| 116 | - ts=$(epoch_seconds "$at") | |
| 132 | + ts=$(epoch_millis "$at") | |
| 117 | 133 | [ -n "$ts" ] || continue |
| 118 | 134 | [ "$ts" -gt "$last_check" ] || continue |
| 119 | 135 | contains_mention "$text" || continue |
| 120 | 136 | printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text" |
| 121 | 137 | done | sort -n | tail -1 | cut -f2- |
| 122 | 138 |
| --- 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 @@ | ||
| 52 | 52 | contains_mention() { |
| 53 | 53 | local text="$1" |
| 54 | 54 | [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]] |
| 55 | 55 | } |
| 56 | 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" | |
| 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)" | |
| 64 | 76 | } |
| 65 | 77 | |
| 66 | 78 | base_name=$(basename "$(pwd)") |
| 67 | 79 | base_name=$(sanitize "$base_name") |
| 68 | 80 | session_suffix="${SCUTTLEBOT_SESSION_ID:-${CODEX_SESSION_ID:-$PPID}}" |
| @@ -78,12 +90,16 @@ | ||
| 78 | 90 | LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key" |
| 79 | 91 | |
| 80 | 92 | last_check=0 |
| 81 | 93 | if [ -f "$LAST_CHECK_FILE" ]; then |
| 82 | 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 | |
| 83 | 99 | fi |
| 84 | -now=$(date +%s) | |
| 100 | +now=$(now_millis) | |
| 85 | 101 | echo "$now" > "$LAST_CHECK_FILE" |
| 86 | 102 | |
| 87 | 103 | BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]' |
| 88 | 104 | |
| 89 | 105 | instruction=$( |
| @@ -104,11 +120,11 @@ | ||
| 104 | 120 | $n != $self |
| 105 | 121 | ) |
| 106 | 122 | | "\(.at)\t\($channel)\t\(.nick)\t\(.text)" |
| 107 | 123 | ' 2>/dev/null |
| 108 | 124 | done | while IFS=$'\t' read -r at channel nick text; do |
| 109 | - ts=$(epoch_seconds "$at") | |
| 125 | + ts=$(epoch_millis "$at") | |
| 110 | 126 | [ -n "$ts" ] || continue |
| 111 | 127 | [ "$ts" -gt "$last_check" ] || continue |
| 112 | 128 | contains_mention "$text" || continue |
| 113 | 129 | printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text" |
| 114 | 130 | done | sort -n | tail -1 | cut -f2- |
| 115 | 131 |
| --- 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 @@ | ||
| 55 | 55 | contains_mention() { |
| 56 | 56 | local text="$1" |
| 57 | 57 | [[ "$text" =~ (^|[^[:alnum:]_./\\-])$SCUTTLEBOT_NICK($|[^[:alnum:]_./\\-]) ]] |
| 58 | 58 | } |
| 59 | 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" | |
| 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)" | |
| 67 | 79 | } |
| 68 | 80 | |
| 69 | 81 | cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null) |
| 70 | 82 | if [ -z "$cwd" ]; then cwd=$(pwd); fi |
| 71 | 83 | base_name=$(sanitize "$(basename "$cwd")") |
| @@ -81,12 +93,16 @@ | ||
| 81 | 93 | LAST_CHECK_FILE="/tmp/.scuttlebot-last-check-$state_key" |
| 82 | 94 | |
| 83 | 95 | last_check=0 |
| 84 | 96 | if [ -f "$LAST_CHECK_FILE" ]; then |
| 85 | 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 | |
| 86 | 102 | fi |
| 87 | -now=$(date +%s) | |
| 103 | +now=$(now_millis) | |
| 88 | 104 | echo "$now" > "$LAST_CHECK_FILE" |
| 89 | 105 | |
| 90 | 106 | BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot","claude"]' |
| 91 | 107 | |
| 92 | 108 | instruction=$( |
| @@ -107,11 +123,11 @@ | ||
| 107 | 123 | $n != $self |
| 108 | 124 | ) |
| 109 | 125 | | "\(.at)\t\($channel)\t\(.nick)\t\(.text)" |
| 110 | 126 | ' 2>/dev/null |
| 111 | 127 | done | while IFS=$'\t' read -r at channel nick text; do |
| 112 | - ts=$(epoch_seconds "$at") | |
| 128 | + ts=$(epoch_millis "$at") | |
| 113 | 129 | [ -n "$ts" ] || continue |
| 114 | 130 | [ "$ts" -gt "$last_check" ] || continue |
| 115 | 131 | contains_mention "$text" || continue |
| 116 | 132 | printf '%s\t[#%s] %s: %s\n' "$ts" "$channel" "$nick" "$text" |
| 117 | 133 | done | sort -n | tail -1 | cut -f2- |
| 118 | 134 | |
| 119 | 135 | 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 |
| --- 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 @@ | |