ScuttleBot
fix: relay session discovery — match by first-entry timestamp, not modTime When multiple Claude/Codex sessions run in the same CWD, all relays were latching onto the same (most recently modified) session file. Now each relay picks the session whose first entry timestamp is newest (closest to its own startedAt), so each relay tails its own subprocess.
Commit
ae413d9334d5084c7e299791134c4caca165f51ef33592b035b55d6c58b9a720
Parent
d60d7a6a8ef98d2…
2 files changed
+31
-24
+17
-10
+31
-24
| --- cmd/claude-relay/main.go | ||
| +++ cmd/claude-relay/main.go | ||
| @@ -341,21 +341,23 @@ | ||
| 341 | 341 | sanitized := strings.ReplaceAll(cwd, "/", "-") |
| 342 | 342 | sanitized = strings.TrimLeft(sanitized, "-") |
| 343 | 343 | return filepath.Join(home, ".claude", "projects", "-"+sanitized), nil |
| 344 | 344 | } |
| 345 | 345 | |
| 346 | -// findLatestSessionPath finds the most recently modified .jsonl file in root | |
| 347 | -// that contains an entry with cwd matching targetCWD and timestamp after since. | |
| 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. | |
| 348 | 350 | func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) { |
| 349 | 351 | entries, err := os.ReadDir(root) |
| 350 | 352 | if err != nil { |
| 351 | 353 | return "", err |
| 352 | 354 | } |
| 353 | 355 | |
| 354 | 356 | type candidate struct { |
| 355 | - path string | |
| 356 | - modTime time.Time | |
| 357 | + path string | |
| 358 | + firstEntry time.Time | |
| 357 | 359 | } |
| 358 | 360 | var candidates []candidate |
| 359 | 361 | for _, e := range entries { |
| 360 | 362 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { |
| 361 | 363 | continue |
| @@ -365,36 +367,32 @@ | ||
| 365 | 367 | continue |
| 366 | 368 | } |
| 367 | 369 | if info.ModTime().Before(since) { |
| 368 | 370 | continue |
| 369 | 371 | } |
| 370 | - candidates = append(candidates, candidate{ | |
| 371 | - path: filepath.Join(root, e.Name()), | |
| 372 | - modTime: info.ModTime(), | |
| 373 | - }) | |
| 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 | + } | |
| 374 | 376 | } |
| 375 | 377 | if len(candidates) == 0 { |
| 376 | 378 | return "", errors.New("no session files found") |
| 377 | 379 | } |
| 378 | - // Sort newest first. | |
| 380 | + // Sort by first entry time, newest first — the session that started | |
| 381 | + // most recently (closest to our startedAt) is most likely ours. | |
| 379 | 382 | sort.Slice(candidates, func(i, j int) bool { |
| 380 | - return candidates[i].modTime.After(candidates[j].modTime) | |
| 383 | + return candidates[i].firstEntry.After(candidates[j].firstEntry) | |
| 381 | 384 | }) |
| 382 | - // Return the first file that has an entry matching our cwd. | |
| 383 | - for _, c := range candidates { | |
| 384 | - if matchesSession(c.path, targetCWD, since) { | |
| 385 | - return c.path, nil | |
| 386 | - } | |
| 387 | - } | |
| 388 | - return "", errors.New("no matching session found") | |
| 385 | + return candidates[0].path, nil | |
| 389 | 386 | } |
| 390 | 387 | |
| 391 | -// matchesSession peeks at the first few lines of a JSONL file to verify cwd. | |
| 392 | -func matchesSession(path, targetCWD string, since time.Time) bool { | |
| 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) { | |
| 393 | 391 | f, err := os.Open(path) |
| 394 | 392 | if err != nil { |
| 395 | - return false | |
| 393 | + return time.Time{}, false | |
| 396 | 394 | } |
| 397 | 395 | defer f.Close() |
| 398 | 396 | |
| 399 | 397 | scanner := bufio.NewScanner(f) |
| 400 | 398 | checked := 0 |
| @@ -402,16 +400,25 @@ | ||
| 402 | 400 | checked++ |
| 403 | 401 | var entry claudeSessionEntry |
| 404 | 402 | if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { |
| 405 | 403 | continue |
| 406 | 404 | } |
| 407 | - if entry.CWD == "" { | |
| 408 | - continue | |
| 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 | + } | |
| 409 | 417 | } |
| 410 | - return entry.CWD == targetCWD | |
| 411 | 418 | } |
| 412 | - return false | |
| 419 | + return time.Time{}, false | |
| 413 | 420 | } |
| 414 | 421 | |
| 415 | 422 | func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error { |
| 416 | 423 | file, err := os.Open(path) |
| 417 | 424 | if err != nil { |
| 418 | 425 |
| --- cmd/claude-relay/main.go | |
| +++ cmd/claude-relay/main.go | |
| @@ -341,21 +341,23 @@ | |
| 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 most recently modified .jsonl file in root |
| 347 | // that contains an entry with cwd matching targetCWD and timestamp after since. |
| 348 | func findLatestSessionPath(root, targetCWD string, since time.Time) (string, error) { |
| 349 | entries, err := os.ReadDir(root) |
| 350 | if err != nil { |
| 351 | return "", err |
| 352 | } |
| 353 | |
| 354 | type candidate struct { |
| 355 | path string |
| 356 | modTime time.Time |
| 357 | } |
| 358 | var candidates []candidate |
| 359 | for _, e := range entries { |
| 360 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { |
| 361 | continue |
| @@ -365,36 +367,32 @@ | |
| 365 | continue |
| 366 | } |
| 367 | if info.ModTime().Before(since) { |
| 368 | continue |
| 369 | } |
| 370 | candidates = append(candidates, candidate{ |
| 371 | path: filepath.Join(root, e.Name()), |
| 372 | modTime: info.ModTime(), |
| 373 | }) |
| 374 | } |
| 375 | if len(candidates) == 0 { |
| 376 | return "", errors.New("no session files found") |
| 377 | } |
| 378 | // Sort newest first. |
| 379 | sort.Slice(candidates, func(i, j int) bool { |
| 380 | return candidates[i].modTime.After(candidates[j].modTime) |
| 381 | }) |
| 382 | // Return the first file that has an entry matching our cwd. |
| 383 | for _, c := range candidates { |
| 384 | if matchesSession(c.path, targetCWD, since) { |
| 385 | return c.path, nil |
| 386 | } |
| 387 | } |
| 388 | return "", errors.New("no matching session found") |
| 389 | } |
| 390 | |
| 391 | // matchesSession peeks at the first few lines of a JSONL file to verify cwd. |
| 392 | func matchesSession(path, targetCWD string, since time.Time) bool { |
| 393 | f, err := os.Open(path) |
| 394 | if err != nil { |
| 395 | return false |
| 396 | } |
| 397 | defer f.Close() |
| 398 | |
| 399 | scanner := bufio.NewScanner(f) |
| 400 | checked := 0 |
| @@ -402,16 +400,25 @@ | |
| 402 | checked++ |
| 403 | var entry claudeSessionEntry |
| 404 | if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { |
| 405 | continue |
| 406 | } |
| 407 | if entry.CWD == "" { |
| 408 | continue |
| 409 | } |
| 410 | return entry.CWD == targetCWD |
| 411 | } |
| 412 | return false |
| 413 | } |
| 414 | |
| 415 | func tailSessionFile(ctx context.Context, path string, mirrorReasoning bool, emit func(mirrorLine)) error { |
| 416 | file, err := os.Open(path) |
| 417 | if err != nil { |
| 418 |
| --- cmd/claude-relay/main.go | |
| +++ cmd/claude-relay/main.go | |
| @@ -341,21 +341,23 @@ | |
| 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 |
| @@ -365,36 +367,32 @@ | |
| 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 |
| @@ -402,16 +400,25 @@ | |
| 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 |
+17
-10
| --- cmd/codex-relay/main.go | ||
| +++ cmd/codex-relay/main.go | ||
| @@ -1135,15 +1135,20 @@ | ||
| 1135 | 1135 | return "", os.ErrNotExist |
| 1136 | 1136 | } |
| 1137 | 1137 | return match, nil |
| 1138 | 1138 | } |
| 1139 | 1139 | |
| 1140 | +// findLatestSessionPath finds the .jsonl file in root whose first entry | |
| 1141 | +// timestamp is closest to (but after) notBefore — this ensures each relay | |
| 1142 | +// latches onto its own subprocess's session rather than whichever session | |
| 1143 | +// happens to have the latest timestamp when multiple sessions share a CWD. | |
| 1140 | 1144 | func findLatestSessionPath(root, target string, notBefore time.Time) (string, error) { |
| 1141 | - var ( | |
| 1142 | - bestPath string | |
| 1143 | - bestTime time.Time | |
| 1144 | - ) | |
| 1145 | + type candidate struct { | |
| 1146 | + path string | |
| 1147 | + ts time.Time | |
| 1148 | + } | |
| 1149 | + var candidates []candidate | |
| 1145 | 1150 | |
| 1146 | 1151 | err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { |
| 1147 | 1152 | if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") { |
| 1148 | 1153 | return nil |
| 1149 | 1154 | } |
| @@ -1155,23 +1160,25 @@ | ||
| 1155 | 1160 | return nil |
| 1156 | 1161 | } |
| 1157 | 1162 | if ts.Before(notBefore) { |
| 1158 | 1163 | return nil |
| 1159 | 1164 | } |
| 1160 | - if bestPath == "" || ts.After(bestTime) { | |
| 1161 | - bestPath = path | |
| 1162 | - bestTime = ts | |
| 1163 | - } | |
| 1165 | + candidates = append(candidates, candidate{path: path, ts: ts}) | |
| 1164 | 1166 | return nil |
| 1165 | 1167 | }) |
| 1166 | 1168 | if err != nil { |
| 1167 | 1169 | return "", err |
| 1168 | 1170 | } |
| 1169 | - if bestPath == "" { | |
| 1171 | + if len(candidates) == 0 { | |
| 1170 | 1172 | return "", os.ErrNotExist |
| 1171 | 1173 | } |
| 1172 | - return bestPath, nil | |
| 1174 | + // Sort newest first — the session that started most recently | |
| 1175 | + // (closest to our relay's startedAt) is most likely ours. | |
| 1176 | + sort.Slice(candidates, func(i, j int) bool { | |
| 1177 | + return candidates[i].ts.After(candidates[j].ts) | |
| 1178 | + }) | |
| 1179 | + return candidates[0].path, nil | |
| 1173 | 1180 | } |
| 1174 | 1181 | |
| 1175 | 1182 | func readSessionMeta(path string) (sessionMetaPayload, time.Time, error) { |
| 1176 | 1183 | file, err := os.Open(path) |
| 1177 | 1184 | if err != nil { |
| 1178 | 1185 |
| --- cmd/codex-relay/main.go | |
| +++ cmd/codex-relay/main.go | |
| @@ -1135,15 +1135,20 @@ | |
| 1135 | return "", os.ErrNotExist |
| 1136 | } |
| 1137 | return match, nil |
| 1138 | } |
| 1139 | |
| 1140 | func findLatestSessionPath(root, target string, notBefore time.Time) (string, error) { |
| 1141 | var ( |
| 1142 | bestPath string |
| 1143 | bestTime time.Time |
| 1144 | ) |
| 1145 | |
| 1146 | err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { |
| 1147 | if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") { |
| 1148 | return nil |
| 1149 | } |
| @@ -1155,23 +1160,25 @@ | |
| 1155 | return nil |
| 1156 | } |
| 1157 | if ts.Before(notBefore) { |
| 1158 | return nil |
| 1159 | } |
| 1160 | if bestPath == "" || ts.After(bestTime) { |
| 1161 | bestPath = path |
| 1162 | bestTime = ts |
| 1163 | } |
| 1164 | return nil |
| 1165 | }) |
| 1166 | if err != nil { |
| 1167 | return "", err |
| 1168 | } |
| 1169 | if bestPath == "" { |
| 1170 | return "", os.ErrNotExist |
| 1171 | } |
| 1172 | return bestPath, nil |
| 1173 | } |
| 1174 | |
| 1175 | func readSessionMeta(path string) (sessionMetaPayload, time.Time, error) { |
| 1176 | file, err := os.Open(path) |
| 1177 | if err != nil { |
| 1178 |
| --- cmd/codex-relay/main.go | |
| +++ cmd/codex-relay/main.go | |
| @@ -1135,15 +1135,20 @@ | |
| 1135 | return "", os.ErrNotExist |
| 1136 | } |
| 1137 | return match, nil |
| 1138 | } |
| 1139 | |
| 1140 | // findLatestSessionPath finds the .jsonl file in root whose first entry |
| 1141 | // timestamp is closest to (but after) notBefore — this ensures each relay |
| 1142 | // latches onto its own subprocess's session rather than whichever session |
| 1143 | // happens to have the latest timestamp when multiple sessions share a CWD. |
| 1144 | func findLatestSessionPath(root, target string, notBefore time.Time) (string, error) { |
| 1145 | type candidate struct { |
| 1146 | path string |
| 1147 | ts time.Time |
| 1148 | } |
| 1149 | var candidates []candidate |
| 1150 | |
| 1151 | err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { |
| 1152 | if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".jsonl") { |
| 1153 | return nil |
| 1154 | } |
| @@ -1155,23 +1160,25 @@ | |
| 1160 | return nil |
| 1161 | } |
| 1162 | if ts.Before(notBefore) { |
| 1163 | return nil |
| 1164 | } |
| 1165 | candidates = append(candidates, candidate{path: path, ts: ts}) |
| 1166 | return nil |
| 1167 | }) |
| 1168 | if err != nil { |
| 1169 | return "", err |
| 1170 | } |
| 1171 | if len(candidates) == 0 { |
| 1172 | return "", os.ErrNotExist |
| 1173 | } |
| 1174 | // Sort newest first — the session that started most recently |
| 1175 | // (closest to our relay's startedAt) is most likely ours. |
| 1176 | sort.Slice(candidates, func(i, j int) bool { |
| 1177 | return candidates[i].ts.After(candidates[j].ts) |
| 1178 | }) |
| 1179 | return candidates[0].path, nil |
| 1180 | } |
| 1181 | |
| 1182 | func readSessionMeta(path string) (sessionMetaPayload, time.Time, error) { |
| 1183 | file, err := os.Open(path) |
| 1184 | if err != nil { |
| 1185 |