package cc import ( "context" "os" "path/filepath" "strings" "time" "github.com/fsnotify/fsnotify" ) // EncodeCwdForProjectsDir converts an absolute path to claude's projects-dir // encoding: both '/' and '.' are replaced with '-'. func EncodeCwdForProjectsDir(absPath string) string { return strings.NewReplacer("/", "-", ".", "-").Replace(absPath) } // ClaudeProjectsDir returns the claude transcript directory for a given cwd: // /.claude/projects/. func ClaudeProjectsDir(homeDir, cwd string) string { return filepath.Join(homeDir, ".claude", "projects", EncodeCwdForProjectsDir(cwd)) } // SessionWatchOpts configures WatchForNewSession. type SessionWatchOpts struct { ProjectsDir string Timeout time.Duration } // WatchForNewSession watches ProjectsDir for the first new .jsonl file that // appears after the call, returning its basename without the .jsonl suffix // (the claude session id). Returns ("", nil) on timeout or context cancellation, // mirroring the TypeScript null-on-timeout semantics. MkdirAll ensures the // target dir exists so a missing claude projects dir is handled symmetrically // with the codex watcher — the first session is captured even if the dir was // absent at call time. func WatchForNewSession(ctx context.Context, opts SessionWatchOpts) (string, error) { // Ensure the target dir exists before snapshotting and watching. if err := os.MkdirAll(opts.ProjectsDir, 0o755); err != nil { return "", nil } // Snapshot existing .jsonl files so we only react to truly new ones. snapshot, err := snapshotJSONL(opts.ProjectsDir) if err != nil { return "", nil } watcher, err := fsnotify.NewWatcher() if err != nil { return "", nil } defer watcher.Close() if err := watcher.Add(opts.ProjectsDir); err != nil { return "", nil } timer := time.NewTimer(opts.Timeout) defer timer.Stop() for { select { case <-ctx.Done(): return "", nil case <-timer.C: return "", nil case event, ok := <-watcher.Events: if !ok { return "", nil } if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 { continue } name := filepath.Base(event.Name) if !strings.HasSuffix(name, ".jsonl") { continue } if snapshot[name] { continue } // Confirm the file actually exists (rename fires for deletes too). if _, err := os.Stat(event.Name); err != nil { continue } return strings.TrimSuffix(name, ".jsonl"), nil case _, ok := <-watcher.Errors: if !ok { return "", nil } // Non-fatal watcher error; keep watching. } } } // snapshotJSONL returns the set of .jsonl filenames currently in dir. func snapshotJSONL(dir string) (map[string]bool, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } snap := make(map[string]bool, len(entries)) for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") { snap[e.Name()] = true } } return snap, nil }