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).
Commit
a053aa67a2c750456af7465cad6662c7ebcc773c232d349db9e9c738d5f0dc5f
Parent
ae413d9334d5084…
1 file changed
+12
-82
+12
-82
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -20,10 +20,11 @@ | ||
| 20 | 20 | "time" |
| 21 | 21 | |
| 22 | 22 | "github.com/conflicthq/scuttlebot/pkg/ircagent" |
| 23 | 23 | "github.com/conflicthq/scuttlebot/pkg/sessionrelay" |
| 24 | 24 | "github.com/creack/pty" |
| 25 | + "github.com/google/uuid" | |
| 25 | 26 | "golang.org/x/term" |
| 26 | 27 | "gopkg.in/yaml.v3" |
| 27 | 28 | ) |
| 28 | 29 | |
| 29 | 30 | const ( |
| @@ -75,10 +76,11 @@ | ||
| 75 | 76 | IRCDeleteOnClose bool |
| 76 | 77 | Channel string |
| 77 | 78 | Channels []string |
| 78 | 79 | ChannelStateFile string |
| 79 | 80 | SessionID string |
| 81 | + ClaudeSessionID string // UUID passed to Claude Code via --session-id | |
| 80 | 82 | Nick string |
| 81 | 83 | HooksEnabled bool |
| 82 | 84 | InterruptOnMessage bool |
| 83 | 85 | MirrorReasoning bool |
| 84 | 86 | PollInterval time.Duration |
| @@ -186,11 +188,12 @@ | ||
| 186 | 188 | _ = relay.Close(closeCtx) |
| 187 | 189 | }() |
| 188 | 190 | } |
| 189 | 191 | |
| 190 | 192 | 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...) | |
| 192 | 195 | cmd.Env = append(os.Environ(), |
| 193 | 196 | "SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile, |
| 194 | 197 | "SCUTTLEBOT_URL="+cfg.URL, |
| 195 | 198 | "SCUTTLEBOT_TOKEN="+cfg.Token, |
| 196 | 199 | "SCUTTLEBOT_CHANNEL="+cfg.Channel, |
| @@ -305,30 +308,32 @@ | ||
| 305 | 308 | } |
| 306 | 309 | return |
| 307 | 310 | } |
| 308 | 311 | } |
| 309 | 312 | |
| 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) { | |
| 311 | 314 | root, err := claudeSessionsRoot(cfg.TargetCWD) |
| 312 | 315 | if err != nil { |
| 313 | 316 | return "", err |
| 314 | 317 | } |
| 318 | + | |
| 319 | + // We passed --session-id to Claude Code, so the file name is deterministic. | |
| 320 | + target := filepath.Join(root, cfg.ClaudeSessionID+".jsonl") | |
| 315 | 321 | |
| 316 | 322 | ctx, cancel := context.WithTimeout(ctx, defaultDiscoverWait) |
| 317 | 323 | defer cancel() |
| 318 | 324 | |
| 319 | 325 | ticker := time.NewTicker(defaultScanInterval) |
| 320 | 326 | defer ticker.Stop() |
| 321 | 327 | |
| 322 | 328 | 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 | |
| 326 | 331 | } |
| 327 | 332 | select { |
| 328 | 333 | case <-ctx.Done(): |
| 329 | - return "", ctx.Err() | |
| 334 | + return "", fmt.Errorf("session file %s not found", target) | |
| 330 | 335 | case <-ticker.C: |
| 331 | 336 | } |
| 332 | 337 | } |
| 333 | 338 | } |
| 334 | 339 | |
| @@ -341,86 +346,10 @@ | ||
| 341 | 346 | sanitized := strings.ReplaceAll(cwd, "/", "-") |
| 342 | 347 | sanitized = strings.TrimLeft(sanitized, "-") |
| 343 | 348 | return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil |
| 344 | 349 | } |
| 345 | 350 | |
| 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 | 351 | func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error { |
| 423 | 352 | file, err := os.Open(path) |
| 424 | 353 | if err != nil { |
| 425 | 354 | return err |
| 426 | 355 | } |
| @@ -1020,10 +949,11 @@ | ||
| 1020 | 949 | sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "") |
| 1021 | 950 | if sessionID == "" { |
| 1022 | 951 | sessionID = defaultSessionID(target) |
| 1023 | 952 | } |
| 1024 | 953 | cfg.SessionID = sanitize(sessionID) |
| 954 | + cfg.ClaudeSessionID = uuid.New().String() | |
| 1025 | 955 | |
| 1026 | 956 | nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "") |
| 1027 | 957 | if nick == "" { |
| 1028 | 958 | nick = fmt.Sprintf("claude-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID) |
| 1029 | 959 | } |
| 1030 | 960 |
| --- 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 |