111 lines
2.9 KiB
Go
111 lines
2.9 KiB
Go
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:
|
|
// <homeDir>/.claude/projects/<encoded-cwd>.
|
|
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
|
|
}
|