ScuttleBot

fix: deterministic session discovery via --session-id for claude-relay claude-relay now generates a UUID and passes --session-id to Claude Code, then looks for {uuid}.jsonl by name — no scanning or heuristics needed. codex-relay already has thread ID matching when --resume is used; for new sessions it falls back to first-entry timestamp matching (previous fix).

lmata 2026-04-04 13:24 trunk
Commit a053aa67a2c750456af7465cad6662c7ebcc773c232d349db9e9c738d5f0dc5f
1 file changed +12 -82
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -20,10 +20,11 @@
2020
"time"
2121
2222
"github.com/conflicthq/scuttlebot/pkg/ircagent"
2323
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
2424
"github.com/creack/pty"
25
+ "github.com/google/uuid"
2526
"golang.org/x/term"
2627
"gopkg.in/yaml.v3"
2728
)
2829
2930
const (
@@ -75,10 +76,11 @@
7576
IRCDeleteOnClose bool
7677
Channel string
7778
Channels []string
7879
ChannelStateFile string
7980
SessionID string
81
+ ClaudeSessionID string // UUID passed to Claude Code via --session-id
8082
Nick string
8183
HooksEnabled bool
8284
InterruptOnMessage bool
8385
MirrorReasoning bool
8486
PollInterval time.Duration
@@ -186,11 +188,12 @@
186188
_ = relay.Close(closeCtx)
187189
}()
188190
}
189191
190192
startedAt := time.Now()
191
- cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
193
+ args := append(cfg.Args, "--session-id", cfg.ClaudeSessionID)
194
+ cmd := exec.Command(cfg.ClaudeBin, args...)
192195
cmd.Env = append(os.Environ(),
193196
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
194197
"SCUTTLEBOT_URL="+cfg.URL,
195198
"SCUTTLEBOT_TOKEN="+cfg.Token,
196199
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
@@ -305,30 +308,32 @@
305308
}
306309
return
307310
}
308311
}
309312
310
-func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
313
+func discoverSessionPath(ctx context.Context, cfg config, _ time.Time) (string, error) {
311314
root, err := claudeSessionsRoot(cfg.TargetCWD)
312315
if err != nil {
313316
return "", err
314317
}
318
+
319
+ // We passed --session-id to Claude Code, so the file name is deterministic.
320
+ target := filepath.Join(root, cfg.ClaudeSessionID+".jsonl")
315321
316322
ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
317323
defer cancel()
318324
319325
ticker := time.NewTicker(defaultScanInterval)
320326
defer ticker.Stop()
321327
322328
for {
323
- path, err := findLatestSessionPath(root, cfg.TargetCWD, startedAt.Add(-2*time.Second))
324
- if err == nil && path != "" {
325
- return path, nil
329
+ if _, err := os.Stat(target); err == nil {
330
+ return target, nil
326331
}
327332
select {
328333
case <-ctx.Done():
329
- return "", ctx.Err()
334
+ return "", fmt.Errorf("session file %s not found", target)
330335
case <-ticker.C:
331336
}
332337
}
333338
}
334339
@@ -341,86 +346,10 @@
341346
sanitized := strings.ReplaceAll(cwd, "/", "-")
342347
sanitized = strings.TrimLeft(sanitized, "-")
343348
return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
344349
}
345350
346
-// findLatestSessionPath finds the .jsonl file in root whose first entry
347
-// timestamp is closest to (but after) since — this ensures each relay
348
-// latches onto its own subprocess's session rather than whichever file
349
-// happens to be most actively written to.
350
-func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) {
351
- entries, err := os.ReadDir(root)
352
- if err != nil {
353
- return "", err
354
- }
355
-
356
- type candidate struct {
357
- path string
358
- firstEntry time.Time
359
- }
360
- var candidates []candidate
361
- for _, e := range entries {
362
- if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
363
- continue
364
- }
365
- info, err := e.Info()
366
- if err != nil {
367
- continue
368
- }
369
- if info.ModTime().Before(since) {
370
- continue
371
- }
372
- p := filepath.Join(root, e.Name())
373
- if first, ok := sessionFirstEntryTime(p, targetCWD, since); ok {
374
- candidates = append(candidates, candidate{path: p, firstEntry: first})
375
- }
376
- }
377
- if len(candidates) == 0 {
378
- return "", errors.New("no session files found")
379
- }
380
- // Sort by first entry time, newest first — the session that started
381
- // most recently (closest to our startedAt) is most likely ours.
382
- sort.Slice(candidates, func(i, j int) bool {
383
- return candidates[i].firstEntry.After(candidates[j].firstEntry)
384
- })
385
- return candidates[0].path, nil
386
-}
387
-
388
-// sessionFirstEntryTime reads the first entry in a JSONL session file,
389
-// verifies it matches targetCWD and is after since, and returns its timestamp.
390
-func sessionFirstEntryTime(path, targetCWD string, since time.Time) (time.Time, bool) {
391
- f, err := os.Open(path)
392
- if err != nil {
393
- return time.Time{}, false
394
- }
395
- defer f.Close()
396
-
397
- scanner := bufio.NewScanner(f)
398
- checked := 0
399
- for scanner.Scan() && checked < 5 {
400
- checked++
401
- var entry claudeSessionEntry
402
- if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
403
- continue
404
- }
405
- if entry.CWD != "" && entry.CWD != targetCWD {
406
- return time.Time{}, false
407
- }
408
- if entry.Timestamp != "" {
409
- t, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
410
- if err == nil && t.After(since) {
411
- return t, true
412
- }
413
- t2, err := time.Parse(time.RFC3339, entry.Timestamp)
414
- if err == nil && t2.After(since) {
415
- return t2, true
416
- }
417
- }
418
- }
419
- return time.Time{}, false
420
-}
421
-
422351
func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
423352
file, err := os.Open(path)
424353
if err != nil {
425354
return err
426355
}
@@ -1020,10 +949,11 @@
1020949
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
1021950
if sessionID == "" {
1022951
sessionID = defaultSessionID(target)
1023952
}
1024953
cfg.SessionID = sanitize(sessionID)
954
+ cfg.ClaudeSessionID = uuid.New().String()
1025955
1026956
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
1027957
if nick == "" {
1028958
nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
1029959
}
1030960
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -20,10 +20,11 @@
20 "time"
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
 
25 "golang.org/x/term"
26 "gopkg.in/yaml.v3"
27 )
28
29 const (
@@ -75,10 +76,11 @@
75 IRCDeleteOnClose bool
76 Channel string
77 Channels []string
78 ChannelStateFile string
79 SessionID string
 
80 Nick string
81 HooksEnabled bool
82 InterruptOnMessage bool
83 MirrorReasoning bool
84 PollInterval time.Duration
@@ -186,11 +188,12 @@
186 _ = relay.Close(closeCtx)
187 }()
188 }
189
190 startedAt := time.Now()
191 cmd := exec.Command(cfg.ClaudeBin, cfg.Args...)
 
192 cmd.Env = append(os.Environ(),
193 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
194 "SCUTTLEBOT_URL="+cfg.URL,
195 "SCUTTLEBOT_TOKEN="+cfg.Token,
196 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
@@ -305,30 +308,32 @@
305 }
306 return
307 }
308 }
309
310 func discoverSessionPath(ctx context.Context, cfg config, startedAt time.Time) (string, error) {
311 root, err := claudeSessionsRoot(cfg.TargetCWD)
312 if err != nil {
313 return "", err
314 }
 
 
 
315
316 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
317 defer cancel()
318
319 ticker := time.NewTicker(defaultScanInterval)
320 defer ticker.Stop()
321
322 for {
323 path, err := findLatestSessionPath(root, cfg.TargetCWD, startedAt.Add(-2*time.Second))
324 if err == nil && path != "" {
325 return path, nil
326 }
327 select {
328 case <-ctx.Done():
329 return "", ctx.Err()
330 case <-ticker.C:
331 }
332 }
333 }
334
@@ -341,86 +346,10 @@
341 sanitized := strings.ReplaceAll(cwd, "/", "-")
342 sanitized = strings.TrimLeft(sanitized, "-")
343 return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
344 }
345
346 // findLatestSessionPath finds the .jsonl file in root whose first entry
347 // timestamp is closest to (but after) since — this ensures each relay
348 // latches onto its own subprocess's session rather than whichever file
349 // happens to be most actively written to.
350 func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) {
351 entries, err := os.ReadDir(root)
352 if err != nil {
353 return "", err
354 }
355
356 type candidate struct {
357 path string
358 firstEntry time.Time
359 }
360 var candidates []candidate
361 for _, e := range entries {
362 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
363 continue
364 }
365 info, err := e.Info()
366 if err != nil {
367 continue
368 }
369 if info.ModTime().Before(since) {
370 continue
371 }
372 p := filepath.Join(root, e.Name())
373 if first, ok := sessionFirstEntryTime(p, targetCWD, since); ok {
374 candidates = append(candidates, candidate{path: p, firstEntry: first})
375 }
376 }
377 if len(candidates) == 0 {
378 return "", errors.New("no session files found")
379 }
380 // Sort by first entry time, newest first — the session that started
381 // most recently (closest to our startedAt) is most likely ours.
382 sort.Slice(candidates, func(i, j int) bool {
383 return candidates[i].firstEntry.After(candidates[j].firstEntry)
384 })
385 return candidates[0].path, nil
386 }
387
388 // sessionFirstEntryTime reads the first entry in a JSONL session file,
389 // verifies it matches targetCWD and is after since, and returns its timestamp.
390 func sessionFirstEntryTime(path, targetCWD string, since time.Time) (time.Time, bool) {
391 f, err := os.Open(path)
392 if err != nil {
393 return time.Time{}, false
394 }
395 defer f.Close()
396
397 scanner := bufio.NewScanner(f)
398 checked := 0
399 for scanner.Scan() && checked < 5 {
400 checked++
401 var entry claudeSessionEntry
402 if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
403 continue
404 }
405 if entry.CWD != "" && entry.CWD != targetCWD {
406 return time.Time{}, false
407 }
408 if entry.Timestamp != "" {
409 t, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
410 if err == nil && t.After(since) {
411 return t, true
412 }
413 t2, err := time.Parse(time.RFC3339, entry.Timestamp)
414 if err == nil && t2.After(since) {
415 return t2, true
416 }
417 }
418 }
419 return time.Time{}, false
420 }
421
422 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
423 file, err := os.Open(path)
424 if err != nil {
425 return err
426 }
@@ -1020,10 +949,11 @@
1020 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
1021 if sessionID == "" {
1022 sessionID = defaultSessionID(target)
1023 }
1024 cfg.SessionID = sanitize(sessionID)
 
1025
1026 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
1027 if nick == "" {
1028 nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
1029 }
1030
--- cmd/claude-relay/main.go
+++ cmd/claude-relay/main.go
@@ -20,10 +20,11 @@
20 "time"
21
22 "github.com/conflicthq/scuttlebot/pkg/ircagent"
23 "github.com/conflicthq/scuttlebot/pkg/sessionrelay"
24 "github.com/creack/pty"
25 "github.com/google/uuid"
26 "golang.org/x/term"
27 "gopkg.in/yaml.v3"
28 )
29
30 const (
@@ -75,10 +76,11 @@
76 IRCDeleteOnClose bool
77 Channel string
78 Channels []string
79 ChannelStateFile string
80 SessionID string
81 ClaudeSessionID string // UUID passed to Claude Code via --session-id
82 Nick string
83 HooksEnabled bool
84 InterruptOnMessage bool
85 MirrorReasoning bool
86 PollInterval time.Duration
@@ -186,11 +188,12 @@
188 _ = relay.Close(closeCtx)
189 }()
190 }
191
192 startedAt := time.Now()
193 args := append(cfg.Args, "--session-id", cfg.ClaudeSessionID)
194 cmd := exec.Command(cfg.ClaudeBin, args...)
195 cmd.Env = append(os.Environ(),
196 "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
197 "SCUTTLEBOT_URL="+cfg.URL,
198 "SCUTTLEBOT_TOKEN="+cfg.Token,
199 "SCUTTLEBOT_CHANNEL="+cfg.Channel,
@@ -305,30 +308,32 @@
308 }
309 return
310 }
311 }
312
313 func discoverSessionPath(ctx context.Context, cfg config, _ time.Time) (string, error) {
314 root, err := claudeSessionsRoot(cfg.TargetCWD)
315 if err != nil {
316 return "", err
317 }
318
319 // We passed --session-id to Claude Code, so the file name is deterministic.
320 target := filepath.Join(root, cfg.ClaudeSessionID+".jsonl")
321
322 ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait)
323 defer cancel()
324
325 ticker := time.NewTicker(defaultScanInterval)
326 defer ticker.Stop()
327
328 for {
329 if _, err := os.Stat(target); err == nil {
330 return target, nil
 
331 }
332 select {
333 case <-ctx.Done():
334 return "", fmt.Errorf("session file %s not found", target)
335 case <-ticker.C:
336 }
337 }
338 }
339
@@ -341,86 +346,10 @@
346 sanitized := strings.ReplaceAll(cwd, "/", "-")
347 sanitized = strings.TrimLeft(sanitized, "-")
348 return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil
349 }
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351 func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error {
352 file, err := os.Open(path)
353 if err != nil {
354 return err
355 }
@@ -1020,10 +949,11 @@
949 sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
950 if sessionID == "" {
951 sessionID = defaultSessionID(target)
952 }
953 cfg.SessionID = sanitize(sessionID)
954 cfg.ClaudeSessionID = uuid.New().String()
955
956 nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
957 if nick == "" {
958 nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
959 }
960

Keyboard Shortcuts

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