11
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user