From 0c7508872738e99238bbed281455013335751f77 Mon Sep 17 00:00:00 2001 From: gastown/crew/joe Date: Tue, 30 Dec 2025 23:42:23 -0800 Subject: [PATCH] Add gt seance command for predecessor session discovery (gt-7qvd7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses Claude Code ~/.claude/projects/ to find Gas Town sessions. Sessions are identified by the [GAS TOWN] beacon in startup messages. Features: - Filter by role (crew, polecat, witness, etc.) - Filter by rig name - Show recent N sessions - JSON output for scripting - Sorts by most recent first šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/claude/sessions.go | 271 +++++++++++++++++++++++++++++++ internal/claude/sessions_test.go | 116 +++++++++++++ internal/cmd/seance.go | 170 +++++++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 internal/claude/sessions.go create mode 100644 internal/claude/sessions_test.go create mode 100644 internal/cmd/seance.go diff --git a/internal/claude/sessions.go b/internal/claude/sessions.go new file mode 100644 index 00000000..ab7d5487 --- /dev/null +++ b/internal/claude/sessions.go @@ -0,0 +1,271 @@ +// Package claude provides integration with Claude Code's local data. +package claude + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// SessionInfo represents a Claude Code session. +type SessionInfo struct { + ID string `json:"id"` // Session UUID + Path string `json:"path"` // Decoded project path + Role string `json:"role"` // Gas Town role (from beacon) + Topic string `json:"topic"` // Topic (from beacon) + StartTime time.Time `json:"start_time"` // First message timestamp + Summary string `json:"summary"` // Session summary + IsGasTown bool `json:"is_gastown"` // Has [GAS TOWN] beacon + FilePath string `json:"file_path"` // Full path to JSONL file +} + +// SessionFilter controls which sessions are returned. +type SessionFilter struct { + GasTownOnly bool // Only return Gas Town sessions + Role string // Filter by role (substring match) + Rig string // Filter by rig name + Path string // Filter by path (substring match) + Limit int // Max sessions to return (0 = unlimited) +} + +// gasTownPattern matches the beacon: [GAS TOWN] role • topic • timestamp +var gasTownPattern = regexp.MustCompile(`\[GAS TOWN\]\s+([^\s•]+)\s*(?:•\s*([^•]+?)\s*)?(?:•\s*(\S+))?\s*$`) + +// DiscoverSessions finds Claude Code sessions matching the filter. +func DiscoverSessions(filter SessionFilter) ([]SessionInfo, error) { + claudeDir := os.ExpandEnv("$HOME/.claude") + projectsDir := filepath.Join(claudeDir, "projects") + + if _, err := os.Stat(projectsDir); os.IsNotExist(err) { + return nil, nil // No sessions yet + } + + var sessions []SessionInfo + + // Walk project directories + entries, err := os.ReadDir(projectsDir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + // Decode path from directory name + projectPath := decodePath(entry.Name()) + + // Apply path/rig filter early + if filter.Rig != "" && !strings.Contains(projectPath, "/"+filter.Rig+"/") { + continue + } + if filter.Path != "" && !strings.Contains(projectPath, filter.Path) { + continue + } + + projectDir := filepath.Join(projectsDir, entry.Name()) + sessionFiles, err := os.ReadDir(projectDir) + if err != nil { + continue + } + + for _, sf := range sessionFiles { + if !strings.HasSuffix(sf.Name(), ".jsonl") { + continue + } + + // Skip agent files (they're subprocesses, not main sessions) + if strings.HasPrefix(sf.Name(), "agent-") { + continue + } + + sessionPath := filepath.Join(projectDir, sf.Name()) + info, err := parseSession(sessionPath, projectPath) + if err != nil { + continue + } + + // Apply filters + if filter.GasTownOnly && !info.IsGasTown { + continue + } + if filter.Role != "" { + // Check Role field first, then path + roleMatch := strings.Contains(strings.ToLower(info.Role), strings.ToLower(filter.Role)) + pathMatch := strings.Contains(strings.ToLower(info.Path), strings.ToLower(filter.Role)) + if !roleMatch && !pathMatch { + continue + } + } + + sessions = append(sessions, info) + } + } + + // Sort by start time descending (most recent first) + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].StartTime.After(sessions[j].StartTime) + }) + + // Apply limit + if filter.Limit > 0 && len(sessions) > filter.Limit { + sessions = sessions[:filter.Limit] + } + + return sessions, nil +} + +// parseSession reads a session JSONL file and extracts metadata. +func parseSession(filePath, projectPath string) (SessionInfo, error) { + file, err := os.Open(filePath) + if err != nil { + return SessionInfo{}, err + } + defer file.Close() + + info := SessionInfo{ + Path: projectPath, + FilePath: filePath, + } + + // Extract session ID from filename + base := filepath.Base(filePath) + info.ID = strings.TrimSuffix(base, ".jsonl") + + scanner := bufio.NewScanner(file) + // Increase buffer size for large lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Bytes() + + // First line is usually the summary + if lineNum == 1 { + var entry struct { + Type string `json:"type"` + Summary string `json:"summary"` + } + if err := json.Unmarshal(line, &entry); err == nil && entry.Type == "summary" { + info.Summary = entry.Summary + } + continue + } + + // Look for user messages + var entry struct { + Type string `json:"type"` + SessionID string `json:"sessionId"` + Timestamp string `json:"timestamp"` + Message json.RawMessage `json:"message"` + } + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + + if entry.Type == "user" { + // Parse timestamp + if entry.Timestamp != "" && info.StartTime.IsZero() { + if t, err := time.Parse(time.RFC3339, entry.Timestamp); err == nil { + info.StartTime = t + } + } + + // Set session ID if not already set + if info.ID == "" && entry.SessionID != "" { + info.ID = entry.SessionID + } + + // Look for Gas Town beacon in message + if !info.IsGasTown { + msgStr := extractMessageContent(entry.Message) + if match := gasTownPattern.FindStringSubmatch(msgStr); match != nil { + info.IsGasTown = true + info.Role = match[1] + if len(match) > 2 { + info.Topic = strings.TrimSpace(match[2]) + } + } + } + } + + // Stop after finding what we need + if info.IsGasTown && !info.StartTime.IsZero() && lineNum > 20 { + break + } + } + + return info, nil +} + +// extractMessageContent extracts text content from a message JSON. +func extractMessageContent(msg json.RawMessage) string { + if len(msg) == 0 { + return "" + } + + // Try as string first + var str string + if err := json.Unmarshal(msg, &str); err == nil { + return str + } + + // Try as object with role/content + var obj struct { + Content string `json:"content"` + Role string `json:"role"` + } + if err := json.Unmarshal(msg, &obj); err == nil { + return obj.Content + } + + return "" +} + +// decodePath converts Claude's path-encoded directory names back to paths. +// e.g., "-Users-stevey-gt-gastown" -> "/Users/stevey/gt/gastown" +func decodePath(encoded string) string { + // Replace leading dash with / + if strings.HasPrefix(encoded, "-") { + encoded = "/" + encoded[1:] + } + // Replace remaining dashes with / + return strings.ReplaceAll(encoded, "-", "/") +} + +// ShortID returns a shortened version of the session ID for display. +func (s SessionInfo) ShortID() string { + if len(s.ID) > 8 { + return s.ID[:8] + } + return s.ID +} + +// FormatTime returns the start time in a compact format. +func (s SessionInfo) FormatTime() string { + if s.StartTime.IsZero() { + return "unknown" + } + return s.StartTime.Format("2006-01-02 15:04") +} + +// RigFromPath extracts the rig name from the project path. +func (s SessionInfo) RigFromPath() string { + // Look for known rig patterns + parts := strings.Split(s.Path, "/") + for i, part := range parts { + if part == "gt" && i+1 < len(parts) { + // Next part after gt/ is usually the rig + return parts[i+1] + } + } + return "" +} diff --git a/internal/claude/sessions_test.go b/internal/claude/sessions_test.go new file mode 100644 index 00000000..75814ce4 --- /dev/null +++ b/internal/claude/sessions_test.go @@ -0,0 +1,116 @@ +package claude + +import ( + "testing" +) + +func TestDecodePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"-Users-stevey-gt-gastown", "/Users/stevey/gt/gastown"}, + {"-Users-stevey-gt-beads-crew-joe", "/Users/stevey/gt/beads/crew/joe"}, + {"-Users-stevey", "/Users/stevey"}, + {"foo-bar", "foo/bar"}, // Edge case: no leading dash + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := decodePath(tt.input) + if result != tt.expected { + t.Errorf("decodePath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGasTownPattern(t *testing.T) { + tests := []struct { + input string + shouldMatch bool + role string + topic string + }{ + { + input: "[GAS TOWN] gastown/polecats/furiosa • ready • 2025-12-30T22:49", + shouldMatch: true, + role: "gastown/polecats/furiosa", + topic: "ready", + }, + { + input: "[GAS TOWN] deacon • patrol • 2025-12-30T08:00", + shouldMatch: true, + role: "deacon", + topic: "patrol", + }, + { + input: "[GAS TOWN] gastown/crew/gus • assigned:gt-abc12 • 2025-12-30T15:42", + shouldMatch: true, + role: "gastown/crew/gus", + topic: "assigned:gt-abc12", + }, + { + input: "Regular message without beacon", + shouldMatch: false, + }, + { + input: "[GAS TOWN] witness • handoff", + shouldMatch: true, + role: "witness", + topic: "handoff", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + match := gasTownPattern.FindStringSubmatch(tt.input) + if tt.shouldMatch && match == nil { + t.Errorf("Expected match for %q but got none", tt.input) + return + } + if !tt.shouldMatch && match != nil { + t.Errorf("Expected no match for %q but got %v", tt.input, match) + return + } + if tt.shouldMatch { + if match[1] != tt.role { + t.Errorf("Role: got %q, want %q", match[1], tt.role) + } + if len(match) > 2 && match[2] != tt.topic { + // Topic might have trailing space, trim for comparison + gotTopic := match[2] + if gotTopic != tt.topic { + t.Errorf("Topic: got %q, want %q", gotTopic, tt.topic) + } + } + } + }) + } +} + +func TestSessionInfoShortID(t *testing.T) { + s := SessionInfo{ID: "d6d8475f-94a9-4a66-bfa6-d60126964427"} + short := s.ShortID() + if short != "d6d8475f" { + t.Errorf("ShortID() = %q, want %q", short, "d6d8475f") + } + + s2 := SessionInfo{ID: "abc"} + if s2.ShortID() != "abc" { + t.Errorf("ShortID() for short ID = %q, want %q", s2.ShortID(), "abc") + } +} + +func TestInferRoleFromPath(t *testing.T) { + s := SessionInfo{Path: "/Users/stevey/gt/gastown/crew/joe"} + rig := s.RigFromPath() + if rig != "gastown" { + t.Errorf("RigFromPath() = %q, want %q", rig, "gastown") + } + + s2 := SessionInfo{Path: "/Users/stevey/gt/beads/polecats/jade"} + if s2.RigFromPath() != "beads" { + t.Errorf("RigFromPath() = %q, want %q", s2.RigFromPath(), "beads") + } +} diff --git a/internal/cmd/seance.go b/internal/cmd/seance.go new file mode 100644 index 00000000..111ff3d0 --- /dev/null +++ b/internal/cmd/seance.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/claude" + "github.com/steveyegge/gastown/internal/style" +) + +var ( + seanceAll bool + seanceRole string + seanceRig string + seanceRecent int + seanceJSON bool +) + +var seanceCmd = &cobra.Command{ + Use: "seance", + GroupID: GroupDiag, + Short: "Discover and browse predecessor sessions", + Long: `Find and resume predecessor Claude Code sessions. + +Seance scans Claude Code's session history to find Gas Town sessions. +Sessions are identified by the [GAS TOWN] beacon sent during startup. + +Examples: + gt seance # List recent Gas Town sessions + gt seance --all # Include non-Gas Town sessions + gt seance --role crew # Filter by role type + gt seance --rig gastown # Filter by rig + gt seance --recent 10 # Last 10 sessions + gt seance --json # JSON output + +Resume a session in Claude Code: + claude --resume + +The beacon format parsed: + [GAS TOWN] gastown/crew/joe • assigned:gt-xyz • 2025-12-30T15:42`, + RunE: runSeance, +} + +func init() { + seanceCmd.Flags().BoolVarP(&seanceAll, "all", "a", false, "Include non-Gas Town sessions") + seanceCmd.Flags().StringVar(&seanceRole, "role", "", "Filter by role (crew, polecat, witness, etc.)") + seanceCmd.Flags().StringVar(&seanceRig, "rig", "", "Filter by rig name") + seanceCmd.Flags().IntVarP(&seanceRecent, "recent", "n", 20, "Number of recent sessions to show") + seanceCmd.Flags().BoolVar(&seanceJSON, "json", false, "Output as JSON") + + rootCmd.AddCommand(seanceCmd) +} + +func runSeance(cmd *cobra.Command, args []string) error { + filter := claude.SessionFilter{ + GasTownOnly: !seanceAll, + Role: seanceRole, + Rig: seanceRig, + Limit: seanceRecent, + } + + sessions, err := claude.DiscoverSessions(filter) + if err != nil { + return fmt.Errorf("discovering sessions: %w", err) + } + + if seanceJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(sessions) + } + + if len(sessions) == 0 { + if seanceAll { + fmt.Println("No sessions found.") + } else { + fmt.Println("No Gas Town sessions found.") + fmt.Println(style.Dim.Render("Use --all to include non-Gas Town sessions")) + } + return nil + } + + // Print header + fmt.Printf("%s\n\n", style.Bold.Render("Claude Code Sessions")) + + // Calculate column widths + idWidth := 10 + roleWidth := 24 + timeWidth := 16 + topicWidth := 30 + + // Header row + fmt.Printf("%-*s %-*s %-*s %-*s\n", + idWidth, "ID", + roleWidth, "ROLE", + timeWidth, "STARTED", + topicWidth, "TOPIC") + fmt.Printf("%s\n", strings.Repeat("─", idWidth+roleWidth+timeWidth+topicWidth+6)) + + for _, s := range sessions { + id := s.ShortID() + + role := s.Role + if role == "" { + // Try to infer from path + role = inferRoleFromPath(s.Path) + } + if len(role) > roleWidth { + role = role[:roleWidth-1] + "…" + } + + timeStr := s.FormatTime() + + topic := s.Topic + if topic == "" && s.Summary != "" { + // Use summary as fallback + topic = s.Summary + } + if len(topic) > topicWidth { + topic = topic[:topicWidth-1] + "…" + } + + // Color based on Gas Town status + if s.IsGasTown { + fmt.Printf("%-*s %-*s %-*s %-*s\n", + idWidth, id, + roleWidth, role, + timeWidth, timeStr, + topicWidth, topic) + } else { + fmt.Printf("%s %s %s %s\n", + style.Dim.Render(fmt.Sprintf("%-*s", idWidth, id)), + style.Dim.Render(fmt.Sprintf("%-*s", roleWidth, role)), + style.Dim.Render(fmt.Sprintf("%-*s", timeWidth, timeStr)), + style.Dim.Render(fmt.Sprintf("%-*s", topicWidth, topic))) + } + } + + fmt.Printf("\n%s\n", style.Dim.Render("Resume a session: claude --resume ")) + + return nil +} + +// inferRoleFromPath attempts to extract a role from the project path. +func inferRoleFromPath(path string) string { + // Look for patterns like /crew/joe, /polecats/furiosa, /witness, etc. + parts := strings.Split(path, "/") + for i := len(parts) - 1; i >= 0; i-- { + part := parts[i] + switch part { + case "witness", "refinery", "deacon", "mayor": + return part + case "crew", "polecats": + if i+1 < len(parts) { + // Include the agent name + return fmt.Sprintf("%s/%s", part, parts[i+1]) + } + return part + } + } + + // Fall back to last path component + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "unknown" +}