ScuttleBot

feat: add SIGUSR1 reconnect handler to codex-relay and gemini-relay All three relay binaries now handle SIGUSR1 for IRC reconnection, matching the claude-relay implementation. The relay-watchdog sidecar signals all relay processes regardless of runtime.

lmata 2026-04-03 21:33 trunk
Commit 12ca93f7a2d9a592cdaf7d672952609f9979123c4f5c11d46cad038b0d515326
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -216,11 +216,12 @@
216216
"SCUTTLEBOT_NICK="+cfg.Nick,
217217
"SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
218218
)
219219
if relayActive {
220220
go mirrorSessionLoop(ctx, relay, cfg, startedAt)
221
- go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
221
+ go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
222
+ go handleReconnectSignal(ctx, &relay, cfg)
222223
}
223224
224225
if !isInteractiveTTY() {
225226
cmd.Stdin = os.Stdin
226227
cmd.Stdout = os.Stdout
@@ -328,10 +329,93 @@
328329
return
329330
}
330331
}
331332
}
332333
}
334
+
335
+func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) {
336
+ sigCh := make(chan os.Signal, 1)
337
+ signal.Notify(sigCh, syscall.SIGUSR1)
338
+ defer signal.Stop(sigCh)
339
+
340
+ for {
341
+ select {
342
+ case <-ctx.Done():
343
+ return
344
+ case <-sigCh:
345
+ }
346
+
347
+ fmt.Fprintf(os.Stderr, "codex-relay: received SIGUSR1, reconnecting IRC...\n")
348
+ old := *relayPtr
349
+ if old != nil {
350
+ _ = old.Close(context.Background())
351
+ }
352
+
353
+ // Retry with backoff.
354
+ wait := 2 * time.Second
355
+ for attempt := 0; attempt < 10; attempt++ {
356
+ if ctx.Err() != nil {
357
+ return
358
+ }
359
+ time.Sleep(wait)
360
+
361
+ conn, err := sessionrelay.New(sessionrelay.Config{
362
+ Transport: cfg.Transport,
363
+ URL: cfg.URL,
364
+ Token: cfg.Token,
365
+ Channel: cfg.Channel,
366
+ Channels: cfg.Channels,
367
+ Nick: cfg.Nick,
368
+ IRC: sessionrelay.IRCConfig{
369
+ Addr: cfg.IRCAddr,
370
+ Pass: "", // force re-registration
371
+ AgentType: cfg.IRCAgentType,
372
+ DeleteOnClose: cfg.IRCDeleteOnClose,
373
+ },
374
+ })
375
+ if err != nil {
376
+ wait = min(wait*2, 30*time.Second)
377
+ continue
378
+ }
379
+
380
+ connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
381
+ if err := conn.Connect(connectCtx); err != nil {
382
+ _ = conn.Close(context.Background())
383
+ cancel()
384
+ wait = min(wait*2, 30*time.Second)
385
+ continue
386
+ }
387
+ cancel()
388
+
389
+ *relayPtr = conn
390
+ _ = conn.Post(context.Background(), fmt.Sprintf(
391
+ "reconnected in %s; mention %s to interrupt",
392
+ filepath.Base(cfg.TargetCWD), cfg.Nick,
393
+ ))
394
+ fmt.Fprintf(os.Stderr, "codex-relay: reconnected successfully\n")
395
+ break
396
+ }
397
+ }
398
+}
399
+
400
+func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) {
401
+ if interval <= 0 {
402
+ return
403
+ }
404
+ ticker := time.NewTicker(interval)
405
+ defer ticker.Stop()
406
+ for {
407
+ select {
408
+ case <-ctx.Done():
409
+ return
410
+ case <-ticker.C:
411
+ if r := *relayPtr; r != nil {
412
+ _ = r.Touch(ctx)
413
+ }
414
+ }
415
+ }
416
+}
333417
334418
func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) {
335419
if interval <= 0 {
336420
return
337421
}
338422
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -216,11 +216,12 @@
216 "SCUTTLEBOT_NICK="+cfg.Nick,
217 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
218 )
219 if relayActive {
220 go mirrorSessionLoop(ctx, relay, cfg, startedAt)
221 go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
 
222 }
223
224 if !isInteractiveTTY() {
225 cmd.Stdin = os.Stdin
226 cmd.Stdout = os.Stdout
@@ -328,10 +329,93 @@
328 return
329 }
330 }
331 }
332 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
334 func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) {
335 if interval <= 0 {
336 return
337 }
338
--- cmd/codex-relay/main.go
+++ cmd/codex-relay/main.go
@@ -216,11 +216,12 @@
216 "SCUTTLEBOT_NICK="+cfg.Nick,
217 "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive),
218 )
219 if relayActive {
220 go mirrorSessionLoop(ctx, relay, cfg, startedAt)
221 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
222 go handleReconnectSignal(ctx, &relay, cfg)
223 }
224
225 if !isInteractiveTTY() {
226 cmd.Stdin = os.Stdin
227 cmd.Stdout = os.Stdout
@@ -328,10 +329,93 @@
329 return
330 }
331 }
332 }
333 }
334
335 func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) {
336 sigCh := make(chan os.Signal, 1)
337 signal.Notify(sigCh, syscall.SIGUSR1)
338 defer signal.Stop(sigCh)
339
340 for {
341 select {
342 case <-ctx.Done():
343 return
344 case <-sigCh:
345 }
346
347 fmt.Fprintf(os.Stderr, "codex-relay: received SIGUSR1, reconnecting IRC...\n")
348 old := *relayPtr
349 if old != nil {
350 _ = old.Close(context.Background())
351 }
352
353 // Retry with backoff.
354 wait := 2 * time.Second
355 for attempt := 0; attempt < 10; attempt++ {
356 if ctx.Err() != nil {
357 return
358 }
359 time.Sleep(wait)
360
361 conn, err := sessionrelay.New(sessionrelay.Config{
362 Transport: cfg.Transport,
363 URL: cfg.URL,
364 Token: cfg.Token,
365 Channel: cfg.Channel,
366 Channels: cfg.Channels,
367 Nick: cfg.Nick,
368 IRC: sessionrelay.IRCConfig{
369 Addr: cfg.IRCAddr,
370 Pass: "", // force re-registration
371 AgentType: cfg.IRCAgentType,
372 DeleteOnClose: cfg.IRCDeleteOnClose,
373 },
374 })
375 if err != nil {
376 wait = min(wait*2, 30*time.Second)
377 continue
378 }
379
380 connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
381 if err := conn.Connect(connectCtx); err != nil {
382 _ = conn.Close(context.Background())
383 cancel()
384 wait = min(wait*2, 30*time.Second)
385 continue
386 }
387 cancel()
388
389 *relayPtr = conn
390 _ = conn.Post(context.Background(), fmt.Sprintf(
391 "reconnected in %s; mention %s to interrupt",
392 filepath.Base(cfg.TargetCWD), cfg.Nick,
393 ))
394 fmt.Fprintf(os.Stderr, "codex-relay: reconnected successfully\n")
395 break
396 }
397 }
398 }
399
400 func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) {
401 if interval <= 0 {
402 return
403 }
404 ticker := time.NewTicker(interval)
405 defer ticker.Stop()
406 for {
407 select {
408 case <-ctx.Done():
409 return
410 case <-ticker.C:
411 if r := *relayPtr; r != nil {
412 _ = r.Touch(ctx)
413 }
414 }
415 }
416 }
417
418 func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) {
419 if interval <= 0 {
420 return
421 }
422
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -164,11 +164,12 @@
164164
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
165165
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
166166
"SCUTTLEBOT_NICK="+cfg.Nick,
167167
)
168168
if relayActive {
169
- go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
169
+ go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
170
+ go handleReconnectSignal(ctx, &relay, cfg)
170171
}
171172
172173
if !isInteractiveTTY() {
173174
cmd.Stdin = os.Stdin
174175
cmd.Stdout = os.Stdout
@@ -276,10 +277,93 @@
276277
return
277278
}
278279
}
279280
}
280281
}
282
+
283
+func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) {
284
+ sigCh := make(chan os.Signal, 1)
285
+ signal.Notify(sigCh, syscall.SIGUSR1)
286
+ defer signal.Stop(sigCh)
287
+
288
+ for {
289
+ select {
290
+ case <-ctx.Done():
291
+ return
292
+ case <-sigCh:
293
+ }
294
+
295
+ fmt.Fprintf(os.Stderr, "gemini-relay: received SIGUSR1, reconnecting IRC...\n")
296
+ old := *relayPtr
297
+ if old != nil {
298
+ _ = old.Close(context.Background())
299
+ }
300
+
301
+ // Retry with backoff.
302
+ wait := 2 * time.Second
303
+ for attempt := 0; attempt < 10; attempt++ {
304
+ if ctx.Err() != nil {
305
+ return
306
+ }
307
+ time.Sleep(wait)
308
+
309
+ conn, err := sessionrelay.New(sessionrelay.Config{
310
+ Transport: cfg.Transport,
311
+ URL: cfg.URL,
312
+ Token: cfg.Token,
313
+ Channel: cfg.Channel,
314
+ Channels: cfg.Channels,
315
+ Nick: cfg.Nick,
316
+ IRC: sessionrelay.IRCConfig{
317
+ Addr: cfg.IRCAddr,
318
+ Pass: "", // force re-registration
319
+ AgentType: cfg.IRCAgentType,
320
+ DeleteOnClose: cfg.IRCDeleteOnClose,
321
+ },
322
+ })
323
+ if err != nil {
324
+ wait = min(wait*2, 30*time.Second)
325
+ continue
326
+ }
327
+
328
+ connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
329
+ if err := conn.Connect(connectCtx); err != nil {
330
+ _ = conn.Close(context.Background())
331
+ cancel()
332
+ wait = min(wait*2, 30*time.Second)
333
+ continue
334
+ }
335
+ cancel()
336
+
337
+ *relayPtr = conn
338
+ _ = conn.Post(context.Background(), fmt.Sprintf(
339
+ "reconnected in %s; mention %s to interrupt",
340
+ filepath.Base(cfg.TargetCWD), cfg.Nick,
341
+ ))
342
+ fmt.Fprintf(os.Stderr, "gemini-relay: reconnected successfully\n")
343
+ break
344
+ }
345
+ }
346
+}
347
+
348
+func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) {
349
+ if interval <= 0 {
350
+ return
351
+ }
352
+ ticker := time.NewTicker(interval)
353
+ defer ticker.Stop()
354
+ for {
355
+ select {
356
+ case <-ctx.Done():
357
+ return
358
+ case <-ticker.C:
359
+ if r := *relayPtr; r != nil {
360
+ _ = r.Touch(ctx)
361
+ }
362
+ }
363
+ }
364
+}
281365
282366
func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) {
283367
if interval <= 0 {
284368
return
285369
}
286370
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -164,11 +164,12 @@
164 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
165 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
166 "SCUTTLEBOT_NICK="+cfg.Nick,
167 )
168 if relayActive {
169 go presenceLoop(ctx, relay, cfg.HeartbeatInterval)
 
170 }
171
172 if !isInteractiveTTY() {
173 cmd.Stdin = os.Stdin
174 cmd.Stdout = os.Stdout
@@ -276,10 +277,93 @@
276 return
277 }
278 }
279 }
280 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
282 func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) {
283 if interval <= 0 {
284 return
285 }
286
--- cmd/gemini-relay/main.go
+++ cmd/gemini-relay/main.go
@@ -164,11 +164,12 @@
164 "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
165 "SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
166 "SCUTTLEBOT_NICK="+cfg.Nick,
167 )
168 if relayActive {
169 go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
170 go handleReconnectSignal(ctx, &relay, cfg)
171 }
172
173 if !isInteractiveTTY() {
174 cmd.Stdin = os.Stdin
175 cmd.Stdout = os.Stdout
@@ -276,10 +277,93 @@
277 return
278 }
279 }
280 }
281 }
282
283 func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) {
284 sigCh := make(chan os.Signal, 1)
285 signal.Notify(sigCh, syscall.SIGUSR1)
286 defer signal.Stop(sigCh)
287
288 for {
289 select {
290 case <-ctx.Done():
291 return
292 case <-sigCh:
293 }
294
295 fmt.Fprintf(os.Stderr, "gemini-relay: received SIGUSR1, reconnecting IRC...\n")
296 old := *relayPtr
297 if old != nil {
298 _ = old.Close(context.Background())
299 }
300
301 // Retry with backoff.
302 wait := 2 * time.Second
303 for attempt := 0; attempt < 10; attempt++ {
304 if ctx.Err() != nil {
305 return
306 }
307 time.Sleep(wait)
308
309 conn, err := sessionrelay.New(sessionrelay.Config{
310 Transport: cfg.Transport,
311 URL: cfg.URL,
312 Token: cfg.Token,
313 Channel: cfg.Channel,
314 Channels: cfg.Channels,
315 Nick: cfg.Nick,
316 IRC: sessionrelay.IRCConfig{
317 Addr: cfg.IRCAddr,
318 Pass: "", // force re-registration
319 AgentType: cfg.IRCAgentType,
320 DeleteOnClose: cfg.IRCDeleteOnClose,
321 },
322 })
323 if err != nil {
324 wait = min(wait*2, 30*time.Second)
325 continue
326 }
327
328 connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
329 if err := conn.Connect(connectCtx); err != nil {
330 _ = conn.Close(context.Background())
331 cancel()
332 wait = min(wait*2, 30*time.Second)
333 continue
334 }
335 cancel()
336
337 *relayPtr = conn
338 _ = conn.Post(context.Background(), fmt.Sprintf(
339 "reconnected in %s; mention %s to interrupt",
340 filepath.Base(cfg.TargetCWD), cfg.Nick,
341 ))
342 fmt.Fprintf(os.Stderr, "gemini-relay: reconnected successfully\n")
343 break
344 }
345 }
346 }
347
348 func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) {
349 if interval <= 0 {
350 return
351 }
352 ticker := time.NewTicker(interval)
353 defer ticker.Stop()
354 for {
355 select {
356 case <-ctx.Done():
357 return
358 case <-ticker.C:
359 if r := *relayPtr; r != nil {
360 _ = r.Touch(ctx)
361 }
362 }
363 }
364 }
365
366 func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) {
367 if interval <= 0 {
368 return
369 }
370

Keyboard Shortcuts

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