diff --git a/.agents/skills/ccsession/SKILL.md b/.agents/skills/ccsession/SKILL.md index 1603167..12a1e22 100644 --- a/.agents/skills/ccsession/SKILL.md +++ b/.agents/skills/ccsession/SKILL.md @@ -1,6 +1,6 @@ --- name: ccsession -description: Recover, inspect, and hand off to local agent sessions with the ccsession CLI. Use when the user wants to find prior context, locate a historical Claude Code/OpenCode/Grok/Codex session, compare candidate sessions, preview a past conversation, or resume work that happened in another agent session. +description: Recover, inspect, and hand off to local agent sessions with the ccsession CLI. Use when the user wants to find prior context, locate a historical Claude Code/OpenCode/Grok/Codex/Pi session, compare candidate sessions, preview a past conversation, or resume work that happened in another agent session. --- # ccsession diff --git a/README.md b/README.md index 7c8ee0e..ac710e5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![ccsession demo](./docs/assets/ccsession_demo.gif) `ccsession` lists local agent sessions (Claude Code by default, with optional -OpenCode, Grok, and Codex backends), lets you fuzzy-find across all of your +OpenCode, Grok, Codex, and Pi backends), lets you fuzzy-find across all of your projects with a live preview pane, and resumes the one you pick in its original working directory. @@ -34,6 +34,7 @@ working directory. | [`opencode`](https://opencode.ai) | listing & resuming OpenCode sessions (only with `--source=opencode`) | | `grok` (Grok Build TUI) | listing & resuming Grok sessions (only with `--source=grok`) | | `codex` (Codex CLI) | listing & resuming Codex sessions (only with `--source=codex`) | +| [`pi`](https://pi.dev) (pi coding agent) | listing & resuming Pi sessions (only with `--source=pi`) | `ccsession` depends on newer `fzf` actions such as `transform`, `rebind`, `unbind`, `disable-search`, and `change-nth`. The newest of those, @@ -88,7 +89,7 @@ The formula lives in [`sorafujitani/homebrew-tap`](https://github.com/sorafujitani/homebrew-tap) and GoReleaser refreshes it on every tagged release. `fzf` is installed as a dependency; the `claude` CLI must be installed separately. `opencode`, `grok`, -and `codex` are needed only with their matching `--source` backends — they back +`codex`, and `pi` are needed only with their matching `--source` backends — they back optional features (unlike `fzf`, which is always required), so they are intentionally left out of the formula's `depends_on`. @@ -98,6 +99,7 @@ intentionally left out of the formula's `depends_on`. ccsession # list -> fzf -> resume ccsession --grok # use Grok sessions from ~/.grok/sessions ccsession --codex # use Codex sessions from ~/.codex/sessions +ccsession --pi # use Pi sessions from ~/.pi/agent/sessions ccsession list [--grep Q] [--regex] # emit TSV rows to stdout ccsession list --json --grep Q --limit 5 # emit structured rows for agents ccsession preview [--query Q] [--regex] # render the preview pane (Q highlighted) @@ -242,7 +244,8 @@ ccsession exits with an error instead of starting the picker. ## How it works 1. `ccsession list` reads the selected backend (`~/.claude/projects/*/` by - default, or `--source=opencode` / `--source=grok` / `--source=codex`) and prints one TSV row + default, or `--source=opencode` / `--source=grok` / `--source=codex` / + `--source=pi`) and prints one TSV row per session (`id`, `locator`, `epoch`, relative time, cwd basename, label). `ccsession list --json --limit N` emits the same candidates as a JSON array for agent integrations. @@ -257,9 +260,11 @@ ccsession exits with an error instead of starting the picker. `cwd`, `chdir`s into it, and `execve`s the selected agent's resume command so the resumed process fully replaces the picker. -Backend-specific homes can be overridden with `GROK_HOME` for Grok and -`CODEX_HOME` for Codex. Codex defaults to `~/.codex`, reading sessions from -its `sessions` subdirectory. +Backend-specific homes can be overridden with `GROK_HOME` for Grok, +`CODEX_HOME` for Codex, and `PI_CODING_AGENT_SESSION_DIR` for Pi. Codex +defaults to `~/.codex`, reading sessions from its `sessions` subdirectory; Pi +reads sessions from `~/.pi/agent/sessions`, and `PI_CODING_AGENT_SESSION_DIR` +points directly at that sessions directory. ## Development diff --git a/cmd/ccsession/main.go b/cmd/ccsession/main.go index 1413232..524b213 100644 --- a/cmd/ccsession/main.go +++ b/cmd/ccsession/main.go @@ -77,13 +77,14 @@ USAGE: non-flag argument). GLOBAL FLAGS: - --source session backend: claude (default) | all | opencode | grok | codex. Inherited + --source session backend: claude (default) | all | opencode | grok | codex | pi. Inherited by the picker's reload/preview/resume re-invocations via CCSESSION_SOURCE. --all shorthand for --source=all --opencode shorthand for --source=opencode --grok shorthand for --source=grok --codex shorthand for --source=codex + --pi shorthand for --source=pi --exclude-dir hide sessions whose cwd contains (case-insensitive). Applied to every list call, including grep/dir/fuzzy reloads, so the matching directories never appear in @@ -216,6 +217,7 @@ type globalFlags struct { opencode bool grok bool codex bool + pi bool binds config.Keybindings } @@ -250,6 +252,12 @@ func applySource(gf globalFlags) error { } name = "codex" } + if gf.pi { + if name != "" && name != "pi" { + return fmt.Errorf("--pi conflicts with --source=%s", name) + } + name = "pi" + } if name == "" { name = os.Getenv(source.EnvVar) } @@ -301,6 +309,12 @@ next: i++ continue next } + // --pi is sugar for --source=pi and takes no value. + if a == "--pi" { + gf.pi = true + i++ + continue next + } for name, p := range dst { if a == name { if i+1 >= len(args) { diff --git a/cmd/ccsession/main_test.go b/cmd/ccsession/main_test.go index 2f59ded..b0a85f1 100644 --- a/cmd/ccsession/main_test.go +++ b/cmd/ccsession/main_test.go @@ -146,6 +146,12 @@ func TestParseGlobalFlags(t *testing.T) { wantGF: globalFlags{codex: true}, wantRest: []string{"list"}, }, + { + name: "pi sugar takes no value", + args: []string{"--pi", "list"}, + wantGF: globalFlags{pi: true}, + wantRest: []string{"list"}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -173,26 +179,32 @@ func TestApplySource(t *testing.T) { {name: "opencode sugar", gf: globalFlags{opencode: true}, wantEnv: "opencode"}, {name: "grok sugar", gf: globalFlags{grok: true}, wantEnv: "grok"}, {name: "codex sugar", gf: globalFlags{codex: true}, wantEnv: "codex"}, + {name: "pi sugar", gf: globalFlags{pi: true}, wantEnv: "pi"}, {name: "source flag", gf: globalFlags{source: "opencode"}, wantEnv: "opencode"}, {name: "source flag all", gf: globalFlags{source: "all"}, wantEnv: "all"}, {name: "source flag grok", gf: globalFlags{source: "grok"}, wantEnv: "grok"}, {name: "source flag codex", gf: globalFlags{source: "codex"}, wantEnv: "codex"}, + {name: "source flag pi", gf: globalFlags{source: "pi"}, wantEnv: "pi"}, {name: "all sugar agrees with source", gf: globalFlags{all: true, source: "all"}, wantEnv: "all"}, {name: "sugar agrees with source", gf: globalFlags{opencode: true, source: "opencode"}, wantEnv: "opencode"}, {name: "codex sugar agrees with source", gf: globalFlags{codex: true, source: "codex"}, wantEnv: "codex"}, + {name: "pi sugar agrees with source", gf: globalFlags{pi: true, source: "pi"}, wantEnv: "pi"}, {name: "all sugar contradicts source", gf: globalFlags{all: true, source: "claude"}, wantErr: true}, {name: "all sugar conflicts with backend sugar", gf: globalFlags{all: true, codex: true}, wantErr: true}, {name: "sugar contradicts source", gf: globalFlags{opencode: true, source: "claude"}, wantErr: true}, {name: "grok sugar contradicts source", gf: globalFlags{grok: true, source: "opencode"}, wantErr: true}, {name: "codex sugar contradicts source", gf: globalFlags{codex: true, source: "grok"}, wantErr: true}, + {name: "pi sugar contradicts source", gf: globalFlags{pi: true, source: "codex"}, wantErr: true}, {name: "backend sugars conflict", gf: globalFlags{opencode: true, grok: true}, wantErr: true}, {name: "codex backend sugar conflicts", gf: globalFlags{grok: true, codex: true}, wantErr: true}, + {name: "pi backend sugar conflicts", gf: globalFlags{codex: true, pi: true}, wantErr: true}, {name: "unknown source flag", gf: globalFlags{source: "bogus"}, wantErr: true}, {name: "inherited env is validated", env: "bogus", wantErr: true}, {name: "inherited valid env survives", env: "opencode", wantEnv: "opencode"}, {name: "inherited all env survives", env: "all", wantEnv: "all"}, {name: "inherited grok env survives", env: "grok", wantEnv: "grok"}, {name: "inherited codex env survives", env: "codex", wantEnv: "codex"}, + {name: "inherited pi env survives", env: "pi", wantEnv: "pi"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/pi/pi.go b/internal/pi/pi.go new file mode 100644 index 0000000..a7127f7 --- /dev/null +++ b/internal/pi/pi.go @@ -0,0 +1,453 @@ +// Package pi reads sessions from the pi coding agent's on-disk JSONL store. +package pi + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/sorafujitani/ccsession/internal/filescan" + "github.com/sorafujitani/ccsession/internal/grep" + "github.com/sorafujitani/ccsession/internal/session" + "github.com/sorafujitani/ccsession/internal/timefmt" +) + +// EnvSessionsDir is pi's own override for its sessions directory, honored so +// ccsession and pi always agree on where sessions live. +const EnvSessionsDir = "PI_CODING_AGENT_SESSION_DIR" + +const jsonlLineCap = 64 * 1024 * 1024 + +var uuidInName = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + +type Store struct { + dir string +} + +type entry struct { + Type string `json:"type"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + CWD string `json:"cwd"` + Name string `json:"name"` + Message json.RawMessage `json:"message"` +} + +type messagePayload struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Timestamp int64 `json:"timestamp"` +} + +var parseSessionFile = parseFile + +func Open() (*Store, error) { + dir, err := ResolveSessionsDir() + if err != nil { + return nil, err + } + return &Store{dir: dir}, nil +} + +func OpenAt(dir string) *Store { + return &Store{dir: dir} +} + +func ResolveSessionsDir() (string, error) { + if dir := os.Getenv(EnvSessionsDir); dir != "" { + return filepath.Abs(dir) + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".pi", "agent", "sessions"), nil +} + +func (s *Store) Scan() ([]*session.Session, error) { + return s.scanFiltered(nil) +} + +func (s *Store) ScanFiltered(allow map[string]struct{}) ([]*session.Session, error) { + return s.scanFiltered(allow) +} + +func (s *Store) FindByID(id string) (*session.Session, error) { + sessions, err := s.representativeSessions() + if err != nil { + return nil, err + } + for _, sess := range sessions { + if sess.ID == id { + return sess, nil + } + } + return nil, session.ErrSessionFileMissing +} + +func (s *Store) FindByLocator(id, path string) (*session.Session, error) { + sess, _, _, _, err := parseSessionFile(path, false, 0) + if err != nil { + return nil, err + } + if sess == nil || sess.ID != id { + return nil, session.ErrSessionFileMissing + } + return sess, nil +} + +func (s *Store) GrepKeys(query string, regex bool) (map[string]struct{}, error) { + if strings.TrimSpace(query) == "" { + return nil, nil + } + match, err := grep.BuildMatcher(query, grep.Options{Regex: regex}) + if err != nil { + return nil, err + } + sessions, err := s.representativeSessions() + if err != nil { + return nil, err + } + set := make(map[string]struct{}) + for _, sess := range sessions { + if match(sess.Label) { + set[sess.ID] = struct{}{} + continue + } + ok, err := fileMessagesMatch(sess.JSONLPath, match) + if err != nil { + return nil, err + } + if ok { + set[sess.ID] = struct{}{} + } + } + return set, nil +} + +func (s *Store) Messages(sessionID string, limit int) ([]session.Message, time.Time, int, error) { + sess, err := s.FindByID(sessionID) + if err != nil { + return nil, time.Time{}, 0, err + } + return s.MessagesForSession(sess, limit) +} + +func (s *Store) MessagesForSession(sess *session.Session, limit int) ([]session.Message, time.Time, int, error) { + if sess == nil || sess.JSONLPath == "" { + return nil, time.Time{}, 0, session.ErrSessionFileMissing + } + _, msgs, startedAt, total, err := parseFile(sess.JSONLPath, true, limit) + if err != nil { + return nil, time.Time{}, 0, err + } + return msgs, startedAt, total, nil +} + +func (s *Store) scanFiltered(allow map[string]struct{}) ([]*session.Session, error) { + sessions, err := s.representativeSessions() + if err != nil { + return nil, err + } + out := make([]*session.Session, 0, len(sessions)) + for _, sess := range sessions { + if !allowed(allow, sess.ID) { + continue + } + out = append(out, sess) + } + nowEpoch := time.Now().Unix() + sort.SliceStable(out, func(i, j int) bool { + ki, kj := sortEpoch(out[i].LastEpoch, nowEpoch), sortEpoch(out[j].LastEpoch, nowEpoch) + if ki != kj { + return ki > kj + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +func (s *Store) sessionPaths() ([]string, error) { + var paths []string + err := filepath.WalkDir(s.dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(d.Name(), ".jsonl") { + paths = append(paths, path) + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + sort.Strings(paths) + return paths, err +} + +func (s *Store) representativeSessions() ([]*session.Session, error) { + paths, err := s.sessionPaths() + if err != nil { + return nil, err + } + seen := make(map[string]struct{}) + out := make([]*session.Session, 0, len(paths)) + candidates := filescan.Parallel(paths, func(path string) (*session.Session, bool) { + sess, _, _, _, err := parseSessionFile(path, false, 0) + if err != nil || sess == nil { + return nil, false + } + return sess, true + }) + for _, sess := range candidates { + if _, ok := seen[sess.ID]; ok { + continue + } + seen[sess.ID] = struct{}{} + out = append(out, sess) + } + return out, nil +} + +func parseFile(path string, includeMessages bool, messageLimit int) (*session.Session, []session.Message, time.Time, int, error) { + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil, time.Time{}, 0, session.ErrSessionFileMissing + } + return nil, nil, time.Time{}, 0, err + } + defer f.Close() + + sess := &session.Session{ + ID: idFromPath(path), + ProjectDir: filepath.Dir(path), + JSONLPath: path, + } + var ( + firstUser string + infoName string + lastTS time.Time + startedAt time.Time + msgs []session.Message + total int + ) + err = scanJSONLLines(f, func(line []byte) { + var e entry + if err := json.Unmarshal(line, &e); err != nil { + return + } + lineTS := timefmt.Parse(e.Timestamp) + if !lineTS.IsZero() && lineTS.After(lastTS) { + lastTS = lineTS + } + switch e.Type { + case "session": + if e.ID != "" { + sess.ID = e.ID + } + if e.CWD != "" { + sess.CWD = e.CWD + } + if !lineTS.IsZero() && startedAt.IsZero() { + startedAt = lineTS + } + case "session_info": + // The latest entry always wins: pi treats an empty name as an + // explicit clear, reverting the label to the first user message. + infoName = e.Name + case "message": + msg, ok := parseMessagePayload(e.Message, lineTS) + if !ok { + return + } + if startedAt.IsZero() && !msg.Timestamp.IsZero() { + startedAt = msg.Timestamp + } + if msg.Role == "user" && firstUser == "" { + firstUser = msg.Body + } + if includeMessages { + msgs = appendMessage(msgs, msg, total, messageLimit) + } + total++ + } + }) + if err != nil { + return nil, nil, time.Time{}, 0, err + } + if sess.ID == "" || idCorruptsRow(sess.ID) { + return nil, nil, time.Time{}, 0, nil + } + label := session.SanitizeLabel(infoName) + if label == "" { + label = session.SanitizeLabel(firstUser) + } + if label == "" { + return nil, nil, time.Time{}, 0, session.ErrSessionEmpty + } + sess.Label = label + if sess.CWD == "" { + sess.CWDUnknown = true + } else { + sess.CWDBasename = filepath.Base(sess.CWD) + sess.CWDExists = pathIsDir(sess.CWD) + } + if lastTS.IsZero() { + if fi, err := os.Stat(path); err == nil { + lastTS = fi.ModTime() + } + } + sess.LastTime = lastTS + sess.LastEpoch = lastTS.Unix() + return sess, collectMessages(msgs, total, messageLimit), startedAt, total, nil +} + +func parseMessagePayload(raw json.RawMessage, ts time.Time) (session.Message, bool) { + var p messagePayload + if err := json.Unmarshal(raw, &p); err != nil { + return session.Message{}, false + } + if p.Role != "user" && p.Role != "assistant" { + return session.Message{}, false + } + body := strings.TrimSpace(session.ExtractText(p.Content, "\n")) + if body == "" { + return session.Message{}, false + } + if p.Timestamp > 0 { + ts = time.UnixMilli(p.Timestamp) + } + return session.Message{Role: p.Role, Timestamp: ts, Body: body}, true +} + +func fileMessagesMatch(path string, match func(string) bool) (bool, error) { + return grep.FileContains(path, match, fileMessageTexts) +} + +func fileMessageTexts(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var texts []string + err = scanJSONLLines(f, func(line []byte) { + var e entry + if err := json.Unmarshal(line, &e); err != nil || e.Type != "message" { + return + } + msg, ok := parseMessagePayload(e.Message, time.Time{}) + if ok { + texts = append(texts, msg.Body) + } + }) + return texts, err +} + +func appendMessage(msgs []session.Message, msg session.Message, total, limit int) []session.Message { + if limit <= 0 { + return append(msgs, msg) + } + if len(msgs) < limit { + return append(msgs, msg) + } + msgs[total%limit] = msg + return msgs +} + +func collectMessages(msgs []session.Message, total, limit int) []session.Message { + if limit <= 0 || total <= limit || len(msgs) == 0 { + return msgs + } + out := make([]session.Message, 0, len(msgs)) + start := total % limit + for i := range len(msgs) { + out = append(out, msgs[(start+i)%limit]) + } + return out +} + +func scanJSONLLines(r io.Reader, visit func([]byte)) error { + br := bufio.NewReaderSize(r, 64*1024) + for { + line, err := readJSONLLine(br, jsonlLineCap) + if len(line) > 0 { + visit(line) + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } +} + +func readJSONLLine(r *bufio.Reader, max int) ([]byte, error) { + var ( + buf bytes.Buffer + truncated bool + ) + for { + chunk, err := r.ReadSlice('\n') + if len(chunk) > 0 && !truncated { + if buf.Len()+len(chunk) > max { + truncated = true + } else { + buf.Write(chunk) + } + } + if err == bufio.ErrBufferFull { + continue + } + if truncated { + return nil, err + } + return bytes.TrimSpace(buf.Bytes()), err + } +} + +func idFromPath(path string) string { + name := strings.TrimSuffix(filepath.Base(path), ".jsonl") + matches := uuidInName.FindAllString(name, -1) + if len(matches) == 0 { + return name + } + return matches[len(matches)-1] +} + +func sortEpoch(epoch, nowEpoch int64) int64 { + if epoch > nowEpoch { + return 0 + } + return epoch +} + +func allowed(allow map[string]struct{}, id string) bool { + if allow == nil { + return true + } + _, ok := allow[id] + return ok +} + +func idCorruptsRow(id string) bool { + return strings.ContainsAny(id, "\t\n\r") +} + +func pathIsDir(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} diff --git a/internal/pi/pi_test.go b/internal/pi/pi_test.go new file mode 100644 index 0000000..617d055 --- /dev/null +++ b/internal/pi/pi_test.go @@ -0,0 +1,453 @@ +package pi + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/sorafujitani/ccsession/internal/grep" + "github.com/sorafujitani/ccsession/internal/session" +) + +func TestScanReadsPiSessionLayout(t *testing.T) { + dir, cwd, id := fixture(t) + store := OpenAt(dir) + + ss, err := store.Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 { + t.Fatalf("Scan returned %d sessions, want 1", len(ss)) + } + got := ss[0] + if got.ID != id { + t.Errorf("ID = %q, want %q", got.ID, id) + } + if got.CWD != cwd || !got.CWDExists { + t.Errorf("cwd = %q exists=%v, want %q exists=true", got.CWD, got.CWDExists, cwd) + } + if got.Label != "first user prompt" { + t.Errorf("Label = %q, want first user prompt", got.Label) + } + if got.LastEpoch == 0 { + t.Error("LastEpoch = 0, want parsed timestamp") + } +} + +func TestSessionInfoNameOverridesLabelLatestWins(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + body := header(id, cwd, "2026-07-06T00:00:00.000Z") + + userLine("2026-07-06T00:00:01Z", "first user prompt") + + `{"type":"session_info","id":"aa","parentId":null,"timestamp":"2026-07-06T00:00:02Z","name":"old name"}` + "\n" + + `{"type":"session_info","id":"bb","parentId":"aa","timestamp":"2026-07-06T00:00:03Z","name":"renamed session"}` + "\n" + writeSession(t, dir, id, body) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].Label != "renamed session" { + t.Fatalf("Label = %#v, want latest session_info name", ss) + } +} + +func TestSessionInfoNameClearedRevertsToFirstUser(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + body := header(id, cwd, "2026-07-06T00:00:00.000Z") + + userLine("2026-07-06T00:00:01Z", "first user prompt") + + `{"type":"session_info","id":"aa","parentId":null,"timestamp":"2026-07-06T00:00:02Z","name":"renamed session"}` + "\n" + + `{"type":"session_info","id":"bb","parentId":"aa","timestamp":"2026-07-06T00:00:03Z","name":""}` + "\n" + writeSession(t, dir, id, body) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].Label != "first user prompt" { + t.Fatalf("Label = %#v, want first user prompt after cleared name", ss) + } +} + +func TestUserStringContentAndBlockContent(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + body := header(id, cwd, "2026-07-06T00:00:00.000Z") + + `{"type":"message","id":"m1","parentId":null,"timestamp":"2026-07-06T00:00:01Z","message":{"role":"user","content":"plain string prompt","timestamp":1783358814215}}` + "\n" + + `{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-07-06T00:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hidden"},{"type":"text","text":"assistant answer"},{"type":"toolCall","id":"c1","name":"read"}],"timestamp":1783358815000}}` + "\n" + writeSession(t, dir, id, body) + store := OpenAt(dir) + + ss, err := store.Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].Label != "plain string prompt" { + t.Fatalf("Label = %#v, want plain string prompt", ss) + } + msgs, _, total, err := store.Messages(id, 30) + if err != nil { + t.Fatalf("Messages: %v", err) + } + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + if msgs[1].Role != "assistant" || msgs[1].Body != "assistant answer" { + t.Fatalf("assistant message = %#v, want text blocks only", msgs[1]) + } + if strings.Contains(msgs[1].Body, "hidden") { + t.Fatalf("thinking block leaked into body: %q", msgs[1].Body) + } + want := time.UnixMilli(1783358814215) + if !msgs[0].Timestamp.Equal(want) { + t.Fatalf("user timestamp = %v, want message unix ms %v", msgs[0].Timestamp, want) + } +} + +func TestFindByIDAndMessages(t *testing.T) { + dir, _, id := fixture(t) + store := OpenAt(dir) + + s, err := store.FindByID(id) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if s.ID != id { + t.Fatalf("FindByID returned %q, want %q", s.ID, id) + } + msgs, startedAt, total, err := store.Messages(id, 1) + if err != nil { + t.Fatalf("Messages: %v", err) + } + if startedAt.IsZero() { + t.Fatal("startedAt is zero, want header timestamp") + } + if total != 3 { + t.Fatalf("total = %d, want 3", total) + } + if len(msgs) != 1 || msgs[0].Role != "user" || msgs[0].Body != "last user asks about unique needle" { + t.Fatalf("limited messages = %#v, want last user message", msgs) + } +} + +func TestScanToleratesTreeEntries(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + body := header(id, cwd, "2026-07-06T00:00:00.000Z") + + userLine("2026-07-06T00:00:01Z", "first user prompt") + + `{"type":"compaction","id":"aa","parentId":null,"timestamp":"2026-07-06T00:00:02Z","summary":"earlier work","firstKeptEntryId":"x","tokensBefore":50000}` + "\n" + + `{"type":"branch_summary","id":"bb","parentId":"aa","timestamp":"2026-07-06T00:00:03Z","fromId":"x","summary":"abandoned branch"}` + "\n" + + `{"type":"custom_message","id":"cc","parentId":"bb","timestamp":"2026-07-06T00:00:04Z","customType":"ext","content":"injected","display":true}` + "\n" + + `{"type":"label","id":"dd","parentId":"cc","timestamp":"2026-07-06T00:00:05Z","targetId":"aa","label":"checkpoint"}` + "\n" + writeSession(t, dir, id, body) + + store := OpenAt(dir) + ss, err := store.Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].Label != "first user prompt" { + t.Fatalf("sessions = %#v, want one labeled by first user prompt", ss) + } + _, _, total, err := store.Messages(id, 0) + if err != nil { + t.Fatalf("Messages: %v", err) + } + if total != 1 { + t.Fatalf("total = %d, want only user/assistant messages counted", total) + } +} + +func TestFindByLocator(t *testing.T) { + dir, _, id := fixture(t) + store := OpenAt(dir) + + path := filepath.Join(dir, "--cwd--", "2026-07-06T00-00-00-000Z_"+id+".jsonl") + s, err := store.FindByLocator(id, path) + if err != nil { + t.Fatalf("FindByLocator: %v", err) + } + if s.ID != id || s.JSONLPath != path { + t.Fatalf("FindByLocator = %#v, want session at %s", s, path) + } + if _, err := store.FindByLocator("wrong-id", path); err == nil { + t.Fatal("FindByLocator with mismatched id succeeded, want error") + } +} + +func TestGrepKeysFeedScanFiltered(t *testing.T) { + t.Setenv(grep.EnvCacheDir, t.TempDir()) + dir, _, id := fixture(t) + store := OpenAt(dir) + + keys, err := store.GrepKeys("assistant answer", false) + if err != nil { + t.Fatalf("GrepKeys: %v", err) + } + if _, ok := keys[id]; !ok { + t.Fatalf("GrepKeys did not include %s: %#v", id, keys) + } + ss, err := store.ScanFiltered(keys) + if err != nil { + t.Fatalf("ScanFiltered: %v", err) + } + if len(ss) != 1 || ss[0].ID != id { + t.Fatalf("ScanFiltered = %#v, want only %s", ss, id) + } +} + +func TestGrepKeysRegex(t *testing.T) { + t.Setenv(grep.EnvCacheDir, t.TempDir()) + dir, _, id := fixture(t) + store := OpenAt(dir) + + keys, err := store.GrepKeys("unique\\s+needle", true) + if err != nil { + t.Fatalf("GrepKeys regex: %v", err) + } + if _, ok := keys[id]; !ok { + t.Fatalf("regex GrepKeys did not include %s: %#v", id, keys) + } + keys, err = store.GrepKeys("no-such-token-anywhere", false) + if err != nil { + t.Fatalf("GrepKeys miss: %v", err) + } + if len(keys) != 0 { + t.Fatalf("GrepKeys matched nothing-sessions: %#v", keys) + } +} + +func TestScanReusesRepresentativeSessionMetadata(t *testing.T) { + dir, _, _ := fixture(t) + store := OpenAt(dir) + calls := countParseCalls(t) + + ss, err := store.Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 { + t.Fatalf("Scan returned %d sessions, want 1", len(ss)) + } + if calls.Load() != 1 { + t.Fatalf("parse calls = %d, want 1", calls.Load()) + } +} + +func TestScanSkipsBadSessions(t *testing.T) { + dir, _, id := fixture(t) + bad := `{"type":"session","version":3,"id":"bad\tid","timestamp":"2026-07-06T00:00:00Z","cwd":"/tmp"}` + "\n" + + userLine("2026-07-06T00:00:01Z", "bad") + "\n" + writeSessionNamed(t, dir, "--cwd--/bad.jsonl", bad) + empty := header("22222222-2222-2222-2222-222222222222", "/tmp", "2026-07-06T00:00:00Z") + writeSessionNamed(t, dir, "--cwd--/2026-07-06T00-00-00-000Z_22222222-2222-2222-2222-222222222222.jsonl", empty) + garbage := "{not json}\nplain text\n" + writeSessionNamed(t, dir, "--cwd--/garbage.jsonl", garbage) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].ID != id { + t.Fatalf("Scan = %#v, want only valid session %s", ss, id) + } +} + +func TestScanToleratesCorruptLinesWithinSession(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + body := header(id, cwd, "2026-07-06T00:00:00.000Z") + + "{corrupt line\n" + + userLine("2026-07-06T00:00:01Z", "still parsed") + writeSession(t, dir, id, body) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].Label != "still parsed" { + t.Fatalf("Scan = %#v, want session despite corrupt line", ss) + } +} + +func TestIDFallsBackToFilenameUUID(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "33333333-3333-3333-3333-333333333333" + body := `{"type":"session","version":3,"timestamp":"2026-07-06T00:00:00Z","cwd":"` + cwd + `"}` + "\n" + + userLine("2026-07-06T00:00:01Z", "no header id") + writeSessionNamed(t, dir, "--cwd--/2026-07-06T00-00-00-000Z_"+id+".jsonl", body) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 || ss[0].ID != id { + t.Fatalf("Scan = %#v, want filename uuid %s", ss, id) + } +} + +func TestScanMarksMissingCWDUnknown(t *testing.T) { + dir := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + body := `{"type":"session","version":3,"id":"` + id + `","timestamp":"2026-07-06T00:00:00Z"}` + "\n" + + userLine("2026-07-06T00:00:01Z", "missing cwd") + writeSession(t, dir, id, body) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 1 { + t.Fatalf("Scan returned %d sessions, want 1", len(ss)) + } + if !ss[0].CWDUnknown || ss[0].CWDExists { + t.Fatalf("cwd flags = unknown:%v exists:%v, want unknown true and exists false", ss[0].CWDUnknown, ss[0].CWDExists) + } +} + +func TestScanOrdersByLastActivityDescending(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + older := "11111111-1111-1111-1111-111111111111" + newer := "22222222-2222-2222-2222-222222222222" + writeSession(t, dir, older, header(older, cwd, "2026-07-01T00:00:00Z")+userLine("2026-07-01T00:00:01Z", "older")) + writeSession(t, dir, newer, header(newer, cwd, "2026-07-05T00:00:00Z")+userLine("2026-07-05T00:00:01Z", "newer")) + + ss, err := OpenAt(dir).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 2 || ss[0].ID != newer || ss[1].ID != older { + t.Fatalf("Scan order = %#v, want newest first", ss) + } +} + +func TestScanMissingDirReturnsEmpty(t *testing.T) { + ss, err := OpenAt(filepath.Join(t.TempDir(), "does-not-exist")).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) != 0 { + t.Fatalf("Scan = %#v, want empty", ss) + } +} + +func TestResolveSessionsDirHonorsEnv(t *testing.T) { + want := t.TempDir() + t.Setenv(EnvSessionsDir, want) + got, err := ResolveSessionsDir() + if err != nil { + t.Fatalf("ResolveSessionsDir: %v", err) + } + if got != want { + t.Errorf("ResolveSessionsDir = %q, want %q", got, want) + } +} + +func TestReadJSONLLineSkipsOversizeLine(t *testing.T) { + r := bufio.NewReader(strings.NewReader(strings.Repeat("x", 10) + "\n" + `{"ok":true}` + "\n")) + + line, err := readJSONLLine(r, 4) + if err != nil { + t.Fatalf("first read err: %v", err) + } + if line != nil { + t.Fatalf("oversize line = %q, want nil", line) + } + line, err = readJSONLLine(r, 4*1024) + if err != nil { + t.Fatalf("second read err: %v", err) + } + if string(line) != `{"ok":true}` { + t.Fatalf("second line = %q, want JSON line", line) + } +} + +func fixture(t testing.TB) (dir, cwd, id string) { + t.Helper() + dir = t.TempDir() + cwd = t.TempDir() + id = "019f3876-219b-7070-a3d0-ef577213d9ad" + body := header(id, cwd, "2026-07-06T00:00:00.000Z") + + "{not json}\n" + + `{"type":"thinking_level_change","id":"aa","parentId":null,"timestamp":"2026-07-06T00:00:01Z","thinkingLevel":"off"}` + "\n" + + userLine("2026-07-06T00:00:02Z", "first user prompt") + + `{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-07-06T00:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"assistant answer"}],"timestamp":1783358815000}}` + "\n" + + `{"type":"message","id":"m3","parentId":"m2","timestamp":"2026-07-06T00:00:04Z","message":{"role":"toolResult","toolCallId":"c1","toolName":"read","content":[{"type":"text","text":"ignore tool result"}]}}` + "\n" + + `{"type":"model_change","id":"m4","parentId":"m3","timestamp":"2026-07-06T00:00:05Z","provider":"anthropic","modelId":"model-x"}` + "\n" + + userLine("2026-07-06T00:00:06Z", "last user asks about unique needle") + writeSession(t, dir, id, body) + return dir, cwd, id +} + +func header(id, cwd, ts string) string { + return `{"type":"session","version":3,"id":"` + id + `","timestamp":"` + ts + `","cwd":"` + cwd + `"}` + "\n" +} + +func userLine(ts, text string) string { + return `{"type":"message","id":"m1","parentId":null,"timestamp":"` + ts + `","message":{"role":"user","content":[{"type":"text","text":"` + text + `"}],"timestamp":1783358814215}}` + "\n" +} + +func writeSession(t testing.TB, dir, id, body string) { + t.Helper() + writeSessionNamed(t, dir, "--cwd--/2026-07-06T00-00-00-000Z_"+id+".jsonl", body) +} + +func writeSessionNamed(t testing.TB, dir, rel, body string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write session: %v", err) + } +} + +func countParseCalls(t *testing.T) *atomic.Int32 { + t.Helper() + orig := parseSessionFile + var calls atomic.Int32 + parseSessionFile = func(path string, includeMessages bool, messageLimit int) (*session.Session, []session.Message, time.Time, int, error) { + calls.Add(1) + return parseFile(path, includeMessages, messageLimit) + } + t.Cleanup(func() { + parseSessionFile = orig + }) + return &calls +} + +func BenchmarkScanManySessions(b *testing.B) { + dir := b.TempDir() + cwd := b.TempDir() + for i := range 512 { + id := fmt.Sprintf("bench-%04d", i) + body := header(id, cwd, "2026-07-06T00:00:00Z") + userLine("2026-07-06T00:00:01Z", id) + writeSessionNamed(b, dir, fmt.Sprintf("--cwd--/%s.jsonl", id), body) + } + store := OpenAt(dir) + b.ResetTimer() + for range b.N { + ss, err := store.Scan() + if err != nil { + b.Fatalf("Scan: %v", err) + } + if len(ss) != 512 { + b.Fatalf("Scan returned %d sessions, want 512", len(ss)) + } + } +} diff --git a/internal/preview/preview_test.go b/internal/preview/preview_test.go index e25b2c3..a82f054 100644 --- a/internal/preview/preview_test.go +++ b/internal/preview/preview_test.go @@ -13,6 +13,7 @@ import ( "github.com/sorafujitani/ccsession/internal/ansi" "github.com/sorafujitani/ccsession/internal/codex" "github.com/sorafujitani/ccsession/internal/opencode" + "github.com/sorafujitani/ccsession/internal/pi" "github.com/sorafujitani/ccsession/internal/session" "github.com/sorafujitani/ccsession/internal/source" ) @@ -711,6 +712,44 @@ func TestCodexSourceRendersThroughMessageSeam(t *testing.T) { } } +func TestPiSourceRendersThroughMessageSeam(t *testing.T) { + dir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + sub := filepath.Join(dir, "--proj--") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := `{"type":"session","version":3,"id":"` + id + `","timestamp":"2026-07-06T00:00:00Z","cwd":"` + cwd + `"}` + "\n" + + `{"type":"message","id":"m1","parentId":null,"timestamp":"2026-07-06T00:00:01Z","message":{"role":"user","content":[{"type":"text","text":"pi preview body"}],"timestamp":1783358814215}}` + "\n" + + `{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-07-06T00:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"assistant reply"}],"timestamp":1783358815000}}` + "\n" + if err := os.WriteFile(filepath.Join(sub, "2026-07-06T00-00-00-000Z_"+id+".jsonl"), []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + t.Setenv(pi.EnvSessionsDir, dir) + t.Setenv(source.EnvVar, "pi") + + src, err := source.FromEnv() + if err != nil { + t.Fatalf("FromEnv: %v", err) + } + if _, ok := src.(messageSource); !ok { + t.Fatal("pi source no longer satisfies messageSource; preview would fall back to the Claude JSONL parser") + } + s, err := src.FindByID(id) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + var buf bytes.Buffer + if err := renderFrom(src, s, &buf, Options{}); err != nil { + t.Fatalf("renderFrom: %v", err) + } + out := buf.String() + if !strings.Contains(out, "pi preview body") || !strings.Contains(out, "assistant reply") { + t.Fatalf("rendered preview missing pi messages: %q", out) + } +} + func BenchmarkRenderJSONLargeTranscript(b *testing.B) { s := benchmarkPreviewSession(b, 1000) b.ResetTimer() diff --git a/internal/resume/resume_test.go b/internal/resume/resume_test.go index 1f3b779..1d54240 100644 --- a/internal/resume/resume_test.go +++ b/internal/resume/resume_test.go @@ -11,6 +11,7 @@ import ( "github.com/sorafujitani/ccsession/internal/codex" "github.com/sorafujitani/ccsession/internal/list" + "github.com/sorafujitani/ccsession/internal/pi" "github.com/sorafujitani/ccsession/internal/source" ) @@ -130,6 +131,49 @@ func TestRunSpec_SourceAllKeepsCompositeIDAndLocator(t *testing.T) { } } +func TestRunSpec_PiResumesByJSONLPath(t *testing.T) { + home := t.TempDir() + piDir := t.TempDir() + cwd := t.TempDir() + id := "019f3876-219b-7070-a3d0-ef577213d9ad" + writeResumeSession(t, home, cwd, id) + path := writePiResumeSession(t, piDir, cwd, id) + t.Setenv("HOME", home) + t.Setenv(pi.EnvSessionsDir, piDir) + t.Setenv(source.EnvVar, "all") + + var listBuf bytes.Buffer + if err := list.Run(list.Options{JSON: true, Out: &listBuf}); err != nil { + t.Fatalf("list.Run: %v", err) + } + var rows []list.JSONSession + if err := json.Unmarshal(listBuf.Bytes(), &rows); err != nil { + t.Fatalf("json.Unmarshal list: %v\n%s", err, listBuf.String()) + } + var piRow list.JSONSession + for _, row := range rows { + if strings.HasPrefix(row.ID, "pi:") { + piRow = row + break + } + } + if piRow.ID == "" || !strings.HasPrefix(piRow.Locator, "pi:") { + t.Fatalf("missing composite pi row: %#v", rows) + } + + var specBuf bytes.Buffer + if err := RunSpec(piRow.ID, Options{Locator: piRow.Locator, Out: &specBuf}); err != nil { + t.Fatalf("RunSpec: %v", err) + } + var spec Spec + if err := json.Unmarshal(specBuf.Bytes(), &spec); err != nil { + t.Fatalf("json.Unmarshal spec: %v\n%s", err, specBuf.String()) + } + if spec.Bin != "pi" || len(spec.Args) != 3 || spec.Args[1] != "--session" || spec.Args[2] != path { + t.Errorf("resume target = %q %v, want pi --session %s", spec.Bin, spec.Args, path) + } +} + func writeResumeSession(t *testing.T, home, cwd, id string) string { t.Helper() dir := filepath.Join(home, ".claude", "projects", "-proj") @@ -144,6 +188,20 @@ func writeResumeSession(t *testing.T, home, cwd, id string) string { return path } +func writePiResumeSession(t *testing.T, dir, cwd, id string) string { + t.Helper() + body := `{"type":"session","version":3,"id":"` + id + `","timestamp":"2026-07-06T00:00:00Z","cwd":"` + cwd + `"}` + "\n" + + `{"type":"message","id":"m1","parentId":null,"timestamp":"2026-07-06T00:00:01Z","message":{"role":"user","content":"pi resume me","timestamp":1783358814215}}` + "\n" + path := filepath.Join(dir, "--proj--", "2026-07-06T00-00-00-000Z_"+id+".jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir pi: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write pi session: %v", err) + } + return path +} + func writeCodexResumeSession(t *testing.T, home, cwd, id string) { t.Helper() body := `{"timestamp":"2026-06-14T00:00:00Z","type":"session_meta","payload":{"id":"` + id + `","timestamp":"2026-06-14T00:00:00Z","cwd":"` + cwd + `"}}` + "\n" + diff --git a/internal/source/all.go b/internal/source/all.go index f0d11e3..1fc6cd4 100644 --- a/internal/source/all.go +++ b/internal/source/all.go @@ -24,7 +24,7 @@ func newAllSource() (Source, error) { } else if ok { sources = append(sources, src) } - for _, open := range []func() (Source, error){newGrokSource, newCodexSource} { + for _, open := range []func() (Source, error){newGrokSource, newCodexSource, newPiSource} { src, err := open() if err != nil { return nil, err diff --git a/internal/source/pi.go b/internal/source/pi.go new file mode 100644 index 0000000..2e378dc --- /dev/null +++ b/internal/source/pi.go @@ -0,0 +1,70 @@ +package source + +import ( + "time" + + "github.com/sorafujitani/ccsession/internal/pi" + "github.com/sorafujitani/ccsession/internal/session" +) + +const namePi = "pi" + +type piSource struct{ store *pi.Store } + +func newPiSource() (Source, error) { + store, err := pi.Open() + if err != nil { + return nil, err + } + return piSource{store: store}, nil +} + +func (p piSource) Name() string { return namePi } + +func (p piSource) Scan() ([]*session.Session, error) { + ss, err := p.store.Scan() + return stamp(ss, namePi), err +} + +func (p piSource) ScanFiltered(allow map[string]struct{}) ([]*session.Session, error) { + ss, err := p.store.ScanFiltered(allow) + return stamp(ss, namePi), err +} + +func (p piSource) FindByID(id string) (*session.Session, error) { + s, err := p.store.FindByID(id) + if s != nil { + s.Source = namePi + } + return s, err +} + +func (p piSource) FindByLocator(id, locator string) (*session.Session, error) { + path, ok := decodeLocator(locator) + if !ok { + return nil, session.ErrSessionFileMissing + } + s, err := p.store.FindByLocator(id, path) + if s != nil { + s.Source = namePi + } + return s, err +} + +func (p piSource) GrepKeys(query string, regex bool) (map[string]struct{}, error) { + return p.store.GrepKeys(query, regex) +} + +func (p piSource) ResumeSpec(s *session.Session) (string, []string, error) { + // The JSONL path is the unambiguous resume target; the id also works but + // only the path pins the exact file. + target := s.JSONLPath + if target == "" { + target = s.ID + } + return namePi, []string{namePi, "--session", target}, nil +} + +func (p piSource) Messages(s *session.Session, limit int) ([]session.Message, time.Time, int, error) { + return p.store.MessagesForSession(s, limit) +} diff --git a/internal/source/source.go b/internal/source/source.go index ce6e6ad..2b6fdf7 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -48,13 +48,21 @@ func FromEnv() (Source, error) { // Names lists the valid backend names (excluding the empty default). func Names() []string { - return []string{nameAll, nameClaude, nameOpencode, nameGrok, nameCodex} + return []string{nameAll, nameClaude, nameOpencode, nameGrok, nameCodex, namePi} } // ValidName reports whether name selects a known backend; the empty string is // valid and means the claude default. func ValidName(name string) bool { - return name == "" || name == nameAll || name == nameClaude || name == nameOpencode || name == nameGrok || name == nameCodex + if name == "" { + return true + } + for _, n := range Names() { + if name == n { + return true + } + } + return false } func forName(name string) (Source, error) { @@ -69,6 +77,8 @@ func forName(name string) (Source, error) { return newGrokSource() case nameCodex: return newCodexSource() + case namePi: + return newPiSource() default: return nil, fmt.Errorf("unknown source %q (valid: %s)", name, strings.Join(Names(), ", ")) } diff --git a/internal/source/source_test.go b/internal/source/source_test.go index bcf3b21..9c973ed 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -11,6 +11,7 @@ import ( "github.com/sorafujitani/ccsession/internal/codex" "github.com/sorafujitani/ccsession/internal/grok" "github.com/sorafujitani/ccsession/internal/opencode" + "github.com/sorafujitani/ccsession/internal/pi" "github.com/sorafujitani/ccsession/internal/session" ) @@ -24,6 +25,7 @@ func TestFromEnv_SelectsBackend(t *testing.T) { t.Setenv(opencode.EnvDBPath, db) t.Setenv(grok.EnvHome, t.TempDir()) t.Setenv(codex.EnvHome, t.TempDir()) + t.Setenv(pi.EnvSessionsDir, t.TempDir()) cases := []struct { env string @@ -36,6 +38,7 @@ func TestFromEnv_SelectsBackend(t *testing.T) { {"opencode", "opencode", false}, {"grok", "grok", false}, {"codex", "codex", false}, + {"pi", "pi", false}, // An unknown value is an error, not a silent fall back to claude: // a typo must surface, not quietly show the wrong agent's sessions. {"clauded", "", true}, @@ -77,6 +80,35 @@ func TestCodex_ResumeSpec(t *testing.T) { } } +func TestPi_ResumeSpec(t *testing.T) { + bin, args, err := piSource{}.ResumeSpec(&session.Session{ID: "abc123", JSONLPath: "/pi/sessions/s.jsonl"}) + if err != nil { + t.Fatalf("ResumeSpec: %v", err) + } + if bin != "pi" { + t.Errorf("bin = %q, want pi", bin) + } + want := []string{"pi", "--session", "/pi/sessions/s.jsonl"} + if len(args) != len(want) { + t.Fatalf("args = %v, want %v", args, want) + } + for i := range want { + if args[i] != want[i] { + t.Errorf("args[%d] = %q, want %q", i, args[i], want[i]) + } + } +} + +func TestPi_ResumeSpecFallsBackToIDWithoutPath(t *testing.T) { + _, args, err := piSource{}.ResumeSpec(&session.Session{ID: "abc123"}) + if err != nil { + t.Fatalf("ResumeSpec: %v", err) + } + if len(args) != 3 || args[2] != "abc123" { + t.Fatalf("args = %v, want id fallback", args) + } +} + func TestGrok_ResumeSpec(t *testing.T) { bin, args, err := grokSource{}.ResumeSpec(&session.Session{ID: "abc123"}) if err != nil { @@ -211,6 +243,33 @@ func TestCodex_FindByIDStampsSource(t *testing.T) { } } +func TestPi_ScanStampsSource(t *testing.T) { + dir, _ := fixturePiDir(t, "hello pi") + ss, err := (piSource{store: pi.OpenAt(dir)}).Scan() + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(ss) == 0 { + t.Fatal("Scan returned no sessions") + } + for _, s := range ss { + if s.Source != "pi" { + t.Errorf("session %s Source = %q, want pi", s.ID, s.Source) + } + } +} + +func TestPi_FindByIDStampsSource(t *testing.T) { + dir, id := fixturePiDir(t, "hello pi") + s, err := (piSource{store: pi.OpenAt(dir)}).FindByID(id) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if s == nil || s.Source != "pi" { + t.Errorf("FindByID Source = %v, want pi", s) + } +} + func TestAllSource_ScanReturnsCompositeIDsSorted(t *testing.T) { src := allSource{sources: []Source{ fakeSource{name: "claude", sessions: []*session.Session{ @@ -597,6 +656,23 @@ func fixtureCodexHome(t *testing.T, content string) (home, id string) { return home, id } +func fixturePiDir(t *testing.T, content string) (dir, id string) { + t.Helper() + dir = t.TempDir() + cwd := t.TempDir() + sub := filepath.Join(dir, "--proj--") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + id = "019f3876-219b-7070-a3d0-ef577213d9ad" + body := `{"type":"session","version":3,"id":"` + id + `","timestamp":"2026-07-06T00:00:00Z","cwd":"` + cwd + `"}` + "\n" + + `{"type":"message","id":"m1","parentId":null,"timestamp":"2026-07-06T00:00:01Z","message":{"role":"user","content":[{"type":"text","text":"` + content + `"}],"timestamp":1783358814215}}` + "\n" + if err := os.WriteFile(filepath.Join(sub, "2026-07-06T00-00-00-000Z_"+id+".jsonl"), []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + return dir, id +} + func BenchmarkAllSourceScan(b *testing.B) { src := allSource{sources: []Source{ benchmarkFakeSource("claude", 500, 10_000),