ScuttleBot

scuttlebot / internal / bots / shepherd / shepherd.go
Source Blame History 454 lines
039edb2… noreply 1 // Package shepherd implements a goal-directed agent coordination bot.
039edb2… noreply 2 //
039edb2… noreply 3 // Shepherd monitors channels, tracks agent activity, assigns work from
039edb2… noreply 4 // configured goal sources, checks in on progress, and reports status.
039edb2… noreply 5 // It uses an LLM to reason about priorities, detect blockers, and
039edb2… noreply 6 // generate summaries.
039edb2… noreply 7 //
039edb2… noreply 8 // Commands (via DM or channel mention):
039edb2… noreply 9 //
039edb2… noreply 10 // GOAL <text> — set a goal for the current channel
039edb2… noreply 11 // STATUS — report progress on current goals
039edb2… noreply 12 // ASSIGN <nick> <task> — manually assign a task to an agent
039edb2… noreply 13 // CHECKIN — trigger a check-in round
039edb2… noreply 14 // PLAN — generate a work plan from current goals
039edb2… noreply 15 package shepherd
039edb2… noreply 16
039edb2… noreply 17 import (
039edb2… noreply 18 "context"
039edb2… noreply 19 "fmt"
039edb2… noreply 20 "log/slog"
039edb2… noreply 21 "net"
039edb2… noreply 22 "strconv"
039edb2… noreply 23 "strings"
039edb2… noreply 24 "sync"
039edb2… noreply 25 "time"
039edb2… noreply 26
039edb2… noreply 27 "github.com/lrstanley/girc"
039edb2… noreply 28
039edb2… noreply 29 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
039edb2… noreply 30 )
039edb2… noreply 31
039edb2… noreply 32 const defaultNick = "shepherd"
039edb2… noreply 33
039edb2… noreply 34 // LLMProvider calls a language model for reasoning.
039edb2… noreply 35 type LLMProvider interface {
039edb2… noreply 36 Summarize(ctx context.Context, prompt string) (string, error)
039edb2… noreply 37 }
039edb2… noreply 38
039edb2… noreply 39 // Config controls shepherd's behaviour.
039edb2… noreply 40 type Config struct {
039edb2… noreply 41 IRCAddr string
039edb2… noreply 42 Nick string
039edb2… noreply 43 Password string
039edb2… noreply 44
039edb2… noreply 45 // Channels to join and monitor.
039edb2… noreply 46 Channels []string
039edb2… noreply 47
039edb2… noreply 48 // ReportChannel is where status reports go (e.g. "#ops").
039edb2… noreply 49 ReportChannel string
039edb2… noreply 50
039edb2… noreply 51 // CheckinInterval is how often to check in on agents. 0 = disabled.
039edb2… noreply 52 CheckinInterval time.Duration
039edb2… noreply 53
039edb2… noreply 54 // GoalSource is an optional URL for seeding goals (e.g. GitHub milestone).
039edb2… noreply 55 GoalSource string
039edb2… noreply 56 }
039edb2… noreply 57
039edb2… noreply 58 // Goal is a tracked objective.
039edb2… noreply 59 type Goal struct {
039edb2… noreply 60 ID string `json:"id"`
039edb2… noreply 61 Channel string `json:"channel"`
039edb2… noreply 62 Description string `json:"description"`
039edb2… noreply 63 CreatedAt time.Time `json:"created_at"`
039edb2… noreply 64 CreatedBy string `json:"created_by"`
039edb2… noreply 65 Status string `json:"status"` // "active", "done", "blocked"
039edb2… noreply 66 }
039edb2… noreply 67
039edb2… noreply 68 // Assignment tracks which agent is working on what.
039edb2… noreply 69 type Assignment struct {
039edb2… noreply 70 Nick string `json:"nick"`
039edb2… noreply 71 Task string `json:"task"`
039edb2… noreply 72 Channel string `json:"channel"`
039edb2… noreply 73 AssignedAt time.Time `json:"assigned_at"`
039edb2… noreply 74 LastUpdate time.Time `json:"last_update"`
039edb2… noreply 75 }
039edb2… noreply 76
039edb2… noreply 77 // Bot is the shepherd bot.
039edb2… noreply 78 type Bot struct {
039edb2… noreply 79 cfg Config
039edb2… noreply 80 llm LLMProvider
039edb2… noreply 81 log *slog.Logger
039edb2… noreply 82 client *girc.Client
039edb2… noreply 83
039edb2… noreply 84 mu sync.Mutex
039edb2… noreply 85 goals map[string][]Goal // channel → goals
039edb2… noreply 86 assignments map[string]*Assignment // nick → assignment
039edb2… noreply 87 activity map[string]time.Time // nick → last message time
039edb2… noreply 88 history map[string][]string // channel → recent messages for LLM context
039edb2… noreply 89 }
039edb2… noreply 90
039edb2… noreply 91 // New creates a shepherd bot.
039edb2… noreply 92 func New(cfg Config, llm LLMProvider, log *slog.Logger) *Bot {
039edb2… noreply 93 if cfg.Nick == "" {
039edb2… noreply 94 cfg.Nick = defaultNick
039edb2… noreply 95 }
039edb2… noreply 96 return &Bot{
039edb2… noreply 97 cfg: cfg,
039edb2… noreply 98 llm: llm,
039edb2… noreply 99 log: log,
039edb2… noreply 100 goals: make(map[string][]Goal),
039edb2… noreply 101 assignments: make(map[string]*Assignment),
039edb2… noreply 102 activity: make(map[string]time.Time),
039edb2… noreply 103 history: make(map[string][]string),
039edb2… noreply 104 }
039edb2… noreply 105 }
039edb2… noreply 106
039edb2… noreply 107 // Name returns the bot's IRC nick.
039edb2… noreply 108 func (b *Bot) Name() string { return b.cfg.Nick }
039edb2… noreply 109
039edb2… noreply 110 // Start connects to IRC and begins shepherding. Blocks until ctx is cancelled.
039edb2… noreply 111 func (b *Bot) Start(ctx context.Context) error {
039edb2… noreply 112 host, port, err := splitHostPort(b.cfg.IRCAddr)
039edb2… noreply 113 if err != nil {
039edb2… noreply 114 return fmt.Errorf("shepherd: %w", err)
039edb2… noreply 115 }
039edb2… noreply 116
039edb2… noreply 117 c := girc.New(girc.Config{
039edb2… noreply 118 Server: host,
039edb2… noreply 119 Port: port,
039edb2… noreply 120 Nick: b.cfg.Nick,
039edb2… noreply 121 User: b.cfg.Nick,
039edb2… noreply 122 Name: "scuttlebot shepherd",
039edb2… noreply 123 SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
039edb2… noreply 124 PingDelay: 30 * time.Second,
039edb2… noreply 125 PingTimeout: 30 * time.Second,
039edb2… noreply 126 })
039edb2… noreply 127
039edb2… noreply 128 router := cmdparse.NewRouter(b.cfg.Nick)
039edb2… noreply 129 router.Register(cmdparse.Command{
039edb2… noreply 130 Name: "goal",
039edb2… noreply 131 Usage: "GOAL <description>",
039edb2… noreply 132 Description: "set a goal for the current channel",
039edb2… noreply 133 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 134 return b.handleGoal(cmdCtx.Channel, cmdCtx.Nick, args)
039edb2… noreply 135 },
039edb2… noreply 136 })
039edb2… noreply 137 router.Register(cmdparse.Command{
039edb2… noreply 138 Name: "status",
039edb2… noreply 139 Usage: "STATUS",
039edb2… noreply 140 Description: "report progress on current goals",
039edb2… noreply 141 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 142 return b.handleStatus(ctx, cmdCtx.Channel)
039edb2… noreply 143 },
039edb2… noreply 144 })
039edb2… noreply 145 router.Register(cmdparse.Command{
039edb2… noreply 146 Name: "assign",
039edb2… noreply 147 Usage: "ASSIGN <nick> <task>",
039edb2… noreply 148 Description: "manually assign a task to an agent",
039edb2… noreply 149 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 150 return b.handleAssign(cmdCtx.Channel, args)
039edb2… noreply 151 },
039edb2… noreply 152 })
039edb2… noreply 153 router.Register(cmdparse.Command{
039edb2… noreply 154 Name: "checkin",
039edb2… noreply 155 Usage: "CHECKIN",
039edb2… noreply 156 Description: "trigger a check-in round with all assigned agents",
039edb2… noreply 157 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 158 b.runCheckin(c)
039edb2… noreply 159 return "check-in round started"
039edb2… noreply 160 },
039edb2… noreply 161 })
039edb2… noreply 162 router.Register(cmdparse.Command{
039edb2… noreply 163 Name: "plan",
039edb2… noreply 164 Usage: "PLAN",
039edb2… noreply 165 Description: "generate a work plan from current goals using LLM",
039edb2… noreply 166 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 167 return b.handlePlan(ctx, cmdCtx.Channel)
039edb2… noreply 168 },
039edb2… noreply 169 })
039edb2… noreply 170 router.Register(cmdparse.Command{
039edb2… noreply 171 Name: "goals",
039edb2… noreply 172 Usage: "GOALS",
039edb2… noreply 173 Description: "list all active goals for this channel",
039edb2… noreply 174 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 175 return b.handleListGoals(cmdCtx.Channel)
039edb2… noreply 176 },
039edb2… noreply 177 })
039edb2… noreply 178 router.Register(cmdparse.Command{
039edb2… noreply 179 Name: "done",
039edb2… noreply 180 Usage: "DONE <goal-id>",
039edb2… noreply 181 Description: "mark a goal as completed",
039edb2… noreply 182 Handler: func(cmdCtx *cmdparse.Context, args string) string {
039edb2… noreply 183 return b.handleDone(cmdCtx.Channel, args)
039edb2… noreply 184 },
039edb2… noreply 185 })
039edb2… noreply 186
039edb2… noreply 187 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
039edb2… noreply 188 cl.Cmd.Mode(cl.GetNick(), "+B")
039edb2… noreply 189 for _, ch := range b.cfg.Channels {
039edb2… noreply 190 cl.Cmd.Join(ch)
039edb2… noreply 191 }
7549691… lmata 192 // Request voice on all channels after a short delay (let JOINs complete).
7549691… lmata 193 go func() {
7549691… lmata 194 time.Sleep(3 * time.Second)
7549691… lmata 195 for _, ch := range b.cfg.Channels {
7549691… lmata 196 cl.Cmd.Message("ChanServ", "VOICE "+ch)
7549691… lmata 197 }
7549691… lmata 198 if b.cfg.ReportChannel != "" {
7549691… lmata 199 cl.Cmd.Message("ChanServ", "VOICE "+b.cfg.ReportChannel)
7549691… lmata 200 }
7549691… lmata 201 }()
039edb2… noreply 202 if b.cfg.ReportChannel != "" {
039edb2… noreply 203 cl.Cmd.Join(b.cfg.ReportChannel)
039edb2… noreply 204 }
039edb2… noreply 205 if b.log != nil {
039edb2… noreply 206 b.log.Info("shepherd connected", "channels", b.cfg.Channels)
039edb2… noreply 207 }
039edb2… noreply 208 })
039edb2… noreply 209
039edb2… noreply 210 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
039edb2… noreply 211 if ch := e.Last(); strings.HasPrefix(ch, "#") {
039edb2… noreply 212 cl.Cmd.Join(ch)
039edb2… noreply 213 }
039edb2… noreply 214 })
039edb2… noreply 215
039edb2… noreply 216 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
039edb2… noreply 217 if len(e.Params) < 1 || e.Source == nil {
039edb2… noreply 218 return
039edb2… noreply 219 }
039edb2… noreply 220 nick := e.Source.Name
039edb2… noreply 221 target := e.Params[0]
039edb2… noreply 222 text := strings.TrimSpace(e.Last())
039edb2… noreply 223
039edb2… noreply 224 // Track activity.
039edb2… noreply 225 b.mu.Lock()
039edb2… noreply 226 b.activity[nick] = time.Now()
039edb2… noreply 227 if strings.HasPrefix(target, "#") {
039edb2… noreply 228 hist := b.history[target]
039edb2… noreply 229 hist = append(hist, fmt.Sprintf("[%s] %s", nick, text))
039edb2… noreply 230 if len(hist) > 100 {
039edb2… noreply 231 hist = hist[len(hist)-100:]
039edb2… noreply 232 }
039edb2… noreply 233 b.history[target] = hist
039edb2… noreply 234 }
039edb2… noreply 235 b.mu.Unlock()
039edb2… noreply 236
039edb2… noreply 237 // Dispatch commands.
039edb2… noreply 238 if reply := router.Dispatch(nick, target, text); reply != nil {
039edb2… noreply 239 cl.Cmd.Message(reply.Target, reply.Text)
039edb2… noreply 240 }
039edb2… noreply 241 })
039edb2… noreply 242
039edb2… noreply 243 b.client = c
039edb2… noreply 244
039edb2… noreply 245 // Start periodic check-in if configured.
039edb2… noreply 246 if b.cfg.CheckinInterval > 0 {
039edb2… noreply 247 go b.checkinLoop(ctx, c)
039edb2… noreply 248 }
039edb2… noreply 249
039edb2… noreply 250 errCh := make(chan error, 1)
039edb2… noreply 251 go func() {
039edb2… noreply 252 if err := c.Connect(); err != nil && ctx.Err() == nil {
039edb2… noreply 253 errCh <- err
039edb2… noreply 254 }
039edb2… noreply 255 }()
039edb2… noreply 256
039edb2… noreply 257 select {
039edb2… noreply 258 case <-ctx.Done():
039edb2… noreply 259 c.Close()
039edb2… noreply 260 return nil
039edb2… noreply 261 case err := <-errCh:
039edb2… noreply 262 return fmt.Errorf("shepherd: irc: %w", err)
039edb2… noreply 263 }
039edb2… noreply 264 }
039edb2… noreply 265
039edb2… noreply 266 // Stop disconnects the bot.
039edb2… noreply 267 func (b *Bot) Stop() {
039edb2… noreply 268 if b.client != nil {
039edb2… noreply 269 b.client.Close()
039edb2… noreply 270 }
039edb2… noreply 271 }
039edb2… noreply 272
039edb2… noreply 273 // --- Command handlers ---
039edb2… noreply 274
039edb2… noreply 275 func (b *Bot) handleGoal(channel, nick, desc string) string {
039edb2… noreply 276 desc = strings.TrimSpace(desc)
039edb2… noreply 277 if desc == "" {
039edb2… noreply 278 return "usage: GOAL <description>"
039edb2… noreply 279 }
039edb2… noreply 280 b.mu.Lock()
039edb2… noreply 281 defer b.mu.Unlock()
039edb2… noreply 282 id := fmt.Sprintf("G%d", len(b.goals[channel])+1)
039edb2… noreply 283 b.goals[channel] = append(b.goals[channel], Goal{
039edb2… noreply 284 ID: id,
039edb2… noreply 285 Channel: channel,
039edb2… noreply 286 Description: desc,
039edb2… noreply 287 CreatedAt: time.Now(),
039edb2… noreply 288 CreatedBy: nick,
039edb2… noreply 289 Status: "active",
039edb2… noreply 290 })
039edb2… noreply 291 return fmt.Sprintf("goal %s set: %s", id, desc)
039edb2… noreply 292 }
039edb2… noreply 293
039edb2… noreply 294 func (b *Bot) handleListGoals(channel string) string {
039edb2… noreply 295 b.mu.Lock()
039edb2… noreply 296 defer b.mu.Unlock()
039edb2… noreply 297 goals := b.goals[channel]
039edb2… noreply 298 if len(goals) == 0 {
039edb2… noreply 299 return "no goals set for " + channel
039edb2… noreply 300 }
039edb2… noreply 301 var lines []string
039edb2… noreply 302 for _, g := range goals {
039edb2… noreply 303 lines = append(lines, fmt.Sprintf("[%s] %s (%s) — %s", g.ID, g.Description, g.Status, g.CreatedBy))
039edb2… noreply 304 }
039edb2… noreply 305 return strings.Join(lines, " | ")
039edb2… noreply 306 }
039edb2… noreply 307
039edb2… noreply 308 func (b *Bot) handleDone(channel, goalID string) string {
039edb2… noreply 309 goalID = strings.TrimSpace(goalID)
039edb2… noreply 310 b.mu.Lock()
039edb2… noreply 311 defer b.mu.Unlock()
039edb2… noreply 312 for i, g := range b.goals[channel] {
039edb2… noreply 313 if strings.EqualFold(g.ID, goalID) {
039edb2… noreply 314 b.goals[channel][i].Status = "done"
039edb2… noreply 315 return fmt.Sprintf("goal %s marked done: %s", g.ID, g.Description)
039edb2… noreply 316 }
039edb2… noreply 317 }
039edb2… noreply 318 return fmt.Sprintf("goal %q not found in %s", goalID, channel)
039edb2… noreply 319 }
039edb2… noreply 320
039edb2… noreply 321 func (b *Bot) handleAssign(channel, args string) string {
039edb2… noreply 322 parts := strings.SplitN(strings.TrimSpace(args), " ", 2)
039edb2… noreply 323 if len(parts) < 2 {
039edb2… noreply 324 return "usage: ASSIGN <nick> <task>"
039edb2… noreply 325 }
039edb2… noreply 326 nick, task := parts[0], parts[1]
039edb2… noreply 327 b.mu.Lock()
039edb2… noreply 328 b.assignments[nick] = &Assignment{
039edb2… noreply 329 Nick: nick,
039edb2… noreply 330 Task: task,
039edb2… noreply 331 Channel: channel,
039edb2… noreply 332 AssignedAt: time.Now(),
039edb2… noreply 333 LastUpdate: time.Now(),
039edb2… noreply 334 }
039edb2… noreply 335 b.mu.Unlock()
039edb2… noreply 336 return fmt.Sprintf("assigned %s to %s", nick, task)
039edb2… noreply 337 }
039edb2… noreply 338
039edb2… noreply 339 func (b *Bot) handleStatus(ctx context.Context, channel string) string {
039edb2… noreply 340 b.mu.Lock()
039edb2… noreply 341 goals := b.goals[channel]
039edb2… noreply 342 var active, done int
039edb2… noreply 343 for _, g := range goals {
039edb2… noreply 344 if g.Status == "done" {
039edb2… noreply 345 done++
039edb2… noreply 346 } else {
039edb2… noreply 347 active++
039edb2… noreply 348 }
039edb2… noreply 349 }
039edb2… noreply 350 hist := b.history[channel]
039edb2… noreply 351 var assignments []string
039edb2… noreply 352 for _, a := range b.assignments {
039edb2… noreply 353 if a.Channel == channel {
039edb2… noreply 354 assignments = append(assignments, fmt.Sprintf("%s: %s", a.Nick, a.Task))
039edb2… noreply 355 }
039edb2… noreply 356 }
039edb2… noreply 357 b.mu.Unlock()
039edb2… noreply 358
039edb2… noreply 359 summary := fmt.Sprintf("goals: %d active, %d done", active, done)
039edb2… noreply 360 if len(assignments) > 0 {
039edb2… noreply 361 summary += " | assignments: " + strings.Join(assignments, ", ")
039edb2… noreply 362 }
039edb2… noreply 363
039edb2… noreply 364 // Use LLM for richer summary if available and there's context.
039edb2… noreply 365 if b.llm != nil && len(hist) > 5 {
039edb2… noreply 366 prompt := fmt.Sprintf("Summarize the current status of work in %s. "+
039edb2… noreply 367 "Goals: %d active, %d done. Assignments: %s. "+
039edb2… noreply 368 "Recent conversation:\n%s\n\n"+
039edb2… noreply 369 "Give a brief status report (2-3 sentences).",
039edb2… noreply 370 channel, active, done, strings.Join(assignments, ", "),
039edb2… noreply 371 strings.Join(hist[max(0, len(hist)-30):], "\n"))
039edb2… noreply 372 if llmSummary, err := b.llm.Summarize(ctx, prompt); err == nil {
039edb2… noreply 373 return llmSummary
039edb2… noreply 374 }
039edb2… noreply 375 }
039edb2… noreply 376
039edb2… noreply 377 return summary
039edb2… noreply 378 }
039edb2… noreply 379
039edb2… noreply 380 func (b *Bot) handlePlan(ctx context.Context, channel string) string {
039edb2… noreply 381 b.mu.Lock()
039edb2… noreply 382 goals := b.goals[channel]
039edb2… noreply 383 var goalDescs []string
039edb2… noreply 384 for _, g := range goals {
039edb2… noreply 385 if g.Status == "active" {
039edb2… noreply 386 goalDescs = append(goalDescs, g.Description)
039edb2… noreply 387 }
039edb2… noreply 388 }
039edb2… noreply 389 hist := b.history[channel]
039edb2… noreply 390 b.mu.Unlock()
039edb2… noreply 391
039edb2… noreply 392 if len(goalDescs) == 0 {
039edb2… noreply 393 return "no active goals to plan from. Use GOAL <description> to set one."
039edb2… noreply 394 }
039edb2… noreply 395
039edb2… noreply 396 if b.llm == nil {
039edb2… noreply 397 return "LLM not configured — cannot generate plan"
039edb2… noreply 398 }
039edb2… noreply 399
039edb2… noreply 400 prompt := fmt.Sprintf("You are a project coordinator. Generate a brief work plan for these goals:\n\n"+
039edb2… noreply 401 "%s\n\nRecent context:\n%s\n\n"+
039edb2… noreply 402 "Output a numbered action list (max 5 items). Each item should be concrete and assignable.",
039edb2… noreply 403 strings.Join(goalDescs, "\n- "),
039edb2… noreply 404 strings.Join(hist[max(0, len(hist)-20):], "\n"))
039edb2… noreply 405
039edb2… noreply 406 plan, err := b.llm.Summarize(ctx, prompt)
039edb2… noreply 407 if err != nil {
039edb2… noreply 408 return "plan generation failed: " + err.Error()
039edb2… noreply 409 }
039edb2… noreply 410 return plan
039edb2… noreply 411 }
039edb2… noreply 412
039edb2… noreply 413 // --- Check-in loop ---
039edb2… noreply 414
039edb2… noreply 415 func (b *Bot) checkinLoop(ctx context.Context, c *girc.Client) {
039edb2… noreply 416 ticker := time.NewTicker(b.cfg.CheckinInterval)
039edb2… noreply 417 defer ticker.Stop()
039edb2… noreply 418 for {
039edb2… noreply 419 select {
039edb2… noreply 420 case <-ctx.Done():
039edb2… noreply 421 return
039edb2… noreply 422 case <-ticker.C:
039edb2… noreply 423 b.runCheckin(c)
039edb2… noreply 424 }
039edb2… noreply 425 }
039edb2… noreply 426 }
039edb2… noreply 427
039edb2… noreply 428 func (b *Bot) runCheckin(c *girc.Client) {
039edb2… noreply 429 b.mu.Lock()
039edb2… noreply 430 defer b.mu.Unlock()
039edb2… noreply 431
039edb2… noreply 432 now := time.Now()
039edb2… noreply 433 for nick, a := range b.assignments {
039edb2… noreply 434 // Check if agent has been active recently.
039edb2… noreply 435 lastActive, ok := b.activity[nick]
039edb2… noreply 436 if !ok || now.Sub(lastActive) > 10*time.Minute {
039edb2… noreply 437 // Agent appears idle — nudge them.
039edb2… noreply 438 msg := fmt.Sprintf("[shepherd] %s — checking in: how's progress on %q?", nick, a.Task)
039edb2… noreply 439 c.Cmd.Message(a.Channel, msg)
039edb2… noreply 440 }
039edb2… noreply 441 }
039edb2… noreply 442 }
039edb2… noreply 443
039edb2… noreply 444 func splitHostPort(addr string) (string, int, error) {
039edb2… noreply 445 host, portStr, err := net.SplitHostPort(addr)
039edb2… noreply 446 if err != nil {
039edb2… noreply 447 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
039edb2… noreply 448 }
039edb2… noreply 449 port, err := strconv.Atoi(portStr)
039edb2… noreply 450 if err != nil {
039edb2… noreply 451 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
039edb2… noreply 452 }
039edb2… noreply 453 return host, port, nil
039edb2… noreply 454 }

Keyboard Shortcuts

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