From 2b5ed662ec64e04ff0ae5cc7cde25955d57f64bb Mon Sep 17 00:00:00 2001 From: mgreau Date: Wed, 11 Mar 2026 21:53:06 -0400 Subject: [PATCH 1/7] Add SessionReconciler for cached session state and harden worktree creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background daemon now scans sessions every 10s and writes cached state to ~/.zen/state/sessions.json, making dashboard and agent status near-instant. Sessions are classified as running/waiting/stopped, where "waiting" means the process is alive but idle ≥10s (needs user input). Also adds CleanupFailedAdd to recover from partial git worktree add failures that leave orphaned branches behind, preventing the need for manual cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/agent.go | 162 +++++++++++++++++---------- cmd/status.go | 55 +++++++-- cmd/watch.go | 15 ++- cmd/work.go | 2 + internal/config/config.go | 31 ++++- internal/mcp/tools.go | 91 +++++++++------ internal/reconciler/session.go | 73 ++++++++++++ internal/reconciler/session_state.go | 96 ++++++++++++++++ internal/reconciler/setup.go | 5 +- internal/worktree/lock.go | 28 +++++ 10 files changed, 446 insertions(+), 112 deletions(-) create mode 100644 internal/reconciler/session.go create mode 100644 internal/reconciler/session_state.go diff --git a/cmd/agent.go b/cmd/agent.go index bcd20cc..9f6f07d 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -8,6 +8,7 @@ import ( "text/tabwriter" "time" + "github.com/mgreau/zen/internal/reconciler" "github.com/mgreau/zen/internal/session" "github.com/mgreau/zen/internal/ui" "github.com/mgreau/zen/internal/worktree" @@ -56,60 +57,92 @@ type agentStatusEntry struct { func runAgentStatus(cmd *cobra.Command, args []string) error { home := homeDir() - wts, err := worktree.ListAll(cfg) - if err != nil { - return fmt.Errorf("listing worktrees: %w", err) - } - var entries []agentStatusEntry - var totalRunning, totalStopped int - - for _, wt := range wts { - sessions, _ := session.FindSessions(wt.Path) - if len(sessions) == 0 { - continue - } - - // Only show the most recent session per worktree - s := sessions[0] - filePath := session.SessionFilePath(wt.Path, s.ID) - - var model string - var tokens session.TokenUsage - if agentFull { - model, tokens, _ = session.ParseSessionDetailFull(filePath) - } else { - model, tokens, _ = session.ParseSessionDetailTail(filePath) + var totalRunning, totalWaiting, totalStopped int + + // Try cached snapshot first (unless --full requests accurate totals) + snapshot, _ := reconciler.ReadSessionSnapshot() + usedCache := !agentFull && reconciler.IsSnapshotFresh(snapshot, 60*time.Second) + + if usedCache { + for _, s := range snapshot.Sessions { + if agentRunning && s.Status == "stopped" { + continue + } + + switch s.Status { + case "running": + totalRunning++ + case "waiting": + totalWaiting++ + default: + totalStopped++ + } + + entries = append(entries, agentStatusEntry{ + Worktree: ui.ShortenHome(s.WorktreePath, home), + SessionID: s.SessionID, + Status: s.Status, + Size: s.Size, + Model: s.Model, + InputTokens: s.InputTokens, + OutputTokens: s.OutputTokens, + LastActive: session.FormatAge(time.Unix(s.LastModified, 0)), + lastActiveEpoch: s.LastModified, + }) } - - running := session.IsProcessRunning(s.ID) - - if agentRunning && !running { - continue + } else { + // Fall back to real-time scanning + wts, err := worktree.ListAll(cfg) + if err != nil { + return fmt.Errorf("listing worktrees: %w", err) } - status := "stopped" - if running { - status = "running" - totalRunning++ - } else { - totalStopped++ + for _, wt := range wts { + sessions, _ := session.FindSessions(wt.Path) + if len(sessions) == 0 { + continue + } + + s := sessions[0] + filePath := session.SessionFilePath(wt.Path, s.ID) + + var model string + var tokens session.TokenUsage + if agentFull { + model, tokens, _ = session.ParseSessionDetailFull(filePath) + } else { + model, tokens, _ = session.ParseSessionDetailTail(filePath) + } + + running := session.IsProcessRunning(s.ID) + + if agentRunning && !running { + continue + } + + status := "stopped" + if running { + status = "running" + totalRunning++ + } else { + totalStopped++ + } + + lastActive := time.Unix(s.Modified, 0) + + entries = append(entries, agentStatusEntry{ + Worktree: ui.ShortenHome(wt.Path, home), + SessionID: s.ID, + Status: status, + Size: s.SizeStr, + Model: session.ShortenModel(model), + InputTokens: session.FormatTokenCount(tokens.InputTokens), + OutputTokens: session.FormatTokenCount(tokens.OutputTokens), + LastActive: session.FormatAge(lastActive), + lastActiveEpoch: s.Modified, + }) } - - lastActive := time.Unix(s.Modified, 0) - - entry := agentStatusEntry{ - Worktree: ui.ShortenHome(wt.Path, home), - SessionID: s.ID, - Status: status, - Size: s.SizeStr, - Model: session.ShortenModel(model), - InputTokens: session.FormatTokenCount(tokens.InputTokens), - OutputTokens: session.FormatTokenCount(tokens.OutputTokens), - LastActive: session.FormatAge(lastActive), - lastActiveEpoch: s.Modified, - } - entries = append(entries, entry) } // Sort by last active (most recent first) @@ -151,9 +184,12 @@ func runAgentStatus(cmd *cobra.Command, args []string) error { for _, e := range entries { statusStr := fmt.Sprintf("%-7s", e.Status) - if e.Status == "running" { + switch e.Status { + case "running": statusStr = ui.GreenText(statusStr) - } else { + case "waiting": + statusStr = ui.YellowText(statusStr) + default: statusStr = ui.DimText(statusStr) } @@ -166,13 +202,23 @@ func runAgentStatus(cmd *cobra.Command, args []string) error { w.Flush() fmt.Println() - total := totalRunning + totalStopped - fmt.Printf("%s %d sessions (%s running, %s stopped)\n", - ui.DimText("Total:"), - total, - ui.GreenText(fmt.Sprintf("%d", totalRunning)), - ui.DimText(fmt.Sprintf("%d", totalStopped)), - ) + total := totalRunning + totalWaiting + totalStopped + if totalWaiting > 0 { + fmt.Printf("%s %d sessions (%s running, %s waiting, %s stopped)\n", + ui.DimText("Total:"), + total, + ui.GreenText(fmt.Sprintf("%d", totalRunning)), + ui.YellowText(fmt.Sprintf("%d", totalWaiting)), + ui.DimText(fmt.Sprintf("%d", totalStopped)), + ) + } else { + fmt.Printf("%s %d sessions (%s running, %s stopped)\n", + ui.DimText("Total:"), + total, + ui.GreenText(fmt.Sprintf("%d", totalRunning)), + ui.DimText(fmt.Sprintf("%d", totalStopped)), + ) + } fmt.Println() return nil diff --git a/cmd/status.go b/cmd/status.go index 81d7915..90d7133 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -9,10 +9,12 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/mgreau/zen/internal/config" "github.com/mgreau/zen/internal/github" "github.com/mgreau/zen/internal/prcache" + "github.com/mgreau/zen/internal/reconciler" "github.com/mgreau/zen/internal/session" "github.com/mgreau/zen/internal/ui" "github.com/mgreau/zen/internal/worktree" @@ -51,10 +53,11 @@ type StatusPRReview struct { // StatusFeature enriches a feature worktree with session and age info. type StatusFeature struct { worktree.Worktree - AgeDays int `json:"age_days"` - AgeStr string `json:"age_str"` - HasSession bool `json:"has_session"` - Running bool `json:"running"` + AgeDays int `json:"age_days"` + AgeStr string `json:"age_str"` + HasSession bool `json:"has_session"` + Running bool `json:"running"` + SessionStatus string `json:"session_status,omitempty"` // "running", "waiting", "stopped", or "" } func runStatus(cmd *cobra.Command, args []string) error { @@ -152,10 +155,15 @@ func runStatus(cmd *cobra.Command, args []string) error { break } sessionIcon := " " - if f.Running { + switch f.SessionStatus { + case "running": sessionIcon = ui.GreenText(" ● ") - } else if f.HasSession { - sessionIcon = ui.DimText(" ○ ") + case "waiting": + sessionIcon = ui.YellowText(" ● ") + default: + if f.HasSession { + sessionIcon = ui.DimText(" ○ ") + } } branch := ui.Truncate(f.Branch, 22) name := ui.Truncate(f.Name, 34) @@ -167,7 +175,7 @@ func runStatus(cmd *cobra.Command, args []string) error { ui.DimText(ui.ShortenHome(f.Path, home))) } } - ui.Hint("'zen work resume ' to continue | 'zen work new ' to start | ● = active session") + ui.Hint("'zen work resume ' to continue | 'zen work new ' to start | " + ui.GreenText("●") + " running " + ui.YellowText("●") + " waiting") fmt.Println() // Watch daemon @@ -187,7 +195,18 @@ func runStatus(cmd *cobra.Command, args []string) error { } // enrichFeatures builds StatusFeature entries with age and session info. +// Uses cached session snapshot when available and fresh (< 60s), falls back +// to real-time scanning otherwise. func enrichFeatures(wts []worktree.Worktree) []StatusFeature { + // Try to use cached session data + sessionMap := make(map[string]reconciler.SessionState) + snapshot, _ := reconciler.ReadSessionSnapshot() + if reconciler.IsSnapshotFresh(snapshot, 60*time.Second) { + for _, s := range snapshot.Sessions { + sessionMap[s.WorktreePath] = s + } + } + features := make([]StatusFeature, 0, len(wts)) for _, wt := range wts { f := StatusFeature{Worktree: wt} @@ -204,11 +223,23 @@ func enrichFeatures(wts []worktree.Worktree) []StatusFeature { } } - // Session status - sessions, _ := session.FindSessions(wt.Path) - if len(sessions) > 0 { + // Session status — prefer cache, fall back to real-time + if cached, ok := sessionMap[wt.Path]; ok { f.HasSession = true - f.Running = session.IsProcessRunning(sessions[0].ID) + f.Running = cached.Status == "running" || cached.Status == "waiting" + f.SessionStatus = cached.Status + } else if len(sessionMap) == 0 { + // No cache available — fall back to real-time scanning + sessions, _ := session.FindSessions(wt.Path) + if len(sessions) > 0 { + f.HasSession = true + f.Running = session.IsProcessRunning(sessions[0].ID) + if f.Running { + f.SessionStatus = "running" + } else { + f.SessionStatus = "stopped" + } + } } features = append(features, f) diff --git a/cmd/watch.go b/cmd/watch.go index 3f8346a..7d1de00 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -239,11 +239,12 @@ func watchDaemon() error { watchCfg := cfg.Watch dispatchInterval := watchCfg.DispatchIntervalDuration() cleanupInterval := watchCfg.CleanupIntervalDuration() + sessionScanInterval := watchCfg.SessionScanIntervalDuration() concurrency := watchCfg.GetConcurrency() maxRetries := watchCfg.GetMaxRetries() - fmt.Printf("[%s] Watch daemon started (poll=%s, dispatch=%s, cleanup=%s, concurrency=%d, maxRetries=%d)\n", - time.Now().Format(time.RFC3339), pollInterval, dispatchInterval, cleanupInterval, concurrency, maxRetries) + fmt.Printf("[%s] Watch daemon started (poll=%s, dispatch=%s, cleanup=%s, session_scan=%s, concurrency=%d, maxRetries=%d)\n", + time.Now().Format(time.RFC3339), pollInterval, dispatchInterval, cleanupInterval, sessionScanInterval, concurrency, maxRetries) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -275,12 +276,17 @@ func watchDaemon() error { cleanupTicker := time.NewTicker(cleanupInterval) defer cleanupTicker.Stop() + // Session scan ticker + sessionTicker := time.NewTicker(sessionScanInterval) + defer sessionTicker.Stop() + // Log rotation ticker — check once per hour rotateTicker := time.NewTicker(1 * time.Hour) defer rotateTicker.Stop() - // Initial poll + // Initial poll and session scan pollOnce(ctx, seenPRs, setupQueue, setupRec) + reconciler.ScanSessions(cfg, 10*time.Second) for { select { @@ -304,6 +310,9 @@ func watchDaemon() error { fmt.Printf("[%s] Cleanup dispatch error: %v\n", time.Now().Format(time.RFC3339), err) } + case <-sessionTicker.C: + reconciler.ScanSessions(cfg, 10*time.Second) + case <-cleanupTicker.C: reconciler.ScanMergedPRs(ctx, cfg, cleanupQueue, cfg.Watch.GetCleanupAfterDays()) } diff --git a/cmd/work.go b/cmd/work.go index 8932833..1c71fe5 100644 --- a/cmd/work.go +++ b/cmd/work.go @@ -167,6 +167,8 @@ func runWorkNew(cmd *cobra.Command, args []string) error { wtCmd := exec.Command("git", "worktree", "add", worktreePath, "-b", gitBranch, "origin/main") wtCmd.Dir = originPath if out, err := wtCmd.CombinedOutput(); err != nil { + // Clean up orphaned branch and partial worktree directory + wt.CleanupFailedAdd(originPath, worktreePath, gitBranch) wt.GitMu.Unlock() return fmt.Errorf("git worktree add: %w: %s", err, string(out)) } diff --git a/internal/config/config.go b/internal/config/config.go index 268f338..b5a6929 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,11 +23,12 @@ type Config struct { // WatchConfig holds configuration for the watch daemon's workqueue behavior. type WatchConfig struct { - DispatchInterval string `yaml:"dispatch_interval"` // default "10s" - CleanupInterval string `yaml:"cleanup_interval"` // default "1h" - CleanupAfterDays int `yaml:"cleanup_after_days"` // default 5 - Concurrency int `yaml:"concurrency"` // default 2 - MaxRetries int `yaml:"max_retries"` // default 5 + DispatchInterval string `yaml:"dispatch_interval"` // default "10s" + CleanupInterval string `yaml:"cleanup_interval"` // default "1h" + SessionScanInterval string `yaml:"session_scan_interval"` // default "10s" + CleanupAfterDays int `yaml:"cleanup_after_days"` // default 5 + Concurrency int `yaml:"concurrency"` // default 2 + MaxRetries int `yaml:"max_retries"` // default 5 } // DispatchIntervalDuration returns the dispatch interval as a time.Duration, @@ -76,6 +77,17 @@ func (w WatchConfig) GetMaxRetries() int { return 5 } +// SessionScanIntervalDuration returns the session scan interval as a time.Duration, +// falling back to the default of 10 seconds. +func (w WatchConfig) SessionScanIntervalDuration() time.Duration { + if w.SessionScanInterval != "" { + if d, err := time.ParseDuration(w.SessionScanInterval); err == nil { + return d + } + } + return 10 * time.Second +} + // RepoConfig holds per-repository configuration. type RepoConfig struct { FullName string `yaml:"full_name"` @@ -176,6 +188,15 @@ func (c *Config) RepoBasePath(short string) string { return "" } +// AllBasePaths returns all configured repo base paths. +func (c *Config) AllBasePaths() []string { + paths := make([]string, 0, len(c.Repos)) + for _, repo := range c.Repos { + paths = append(paths, repo.BasePath) + } + return paths +} + // IsAuthor returns true if the given login is in the authors list. func (c *Config) IsAuthor(login string) bool { for _, a := range c.Authors { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index a973dff..9949b49 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -8,6 +8,7 @@ import ( mcpgo "github.com/mark3labs/mcp-go/mcp" ghpkg "github.com/mgreau/zen/internal/github" + "github.com/mgreau/zen/internal/reconciler" "github.com/mgreau/zen/internal/session" "github.com/mgreau/zen/internal/worktree" ) @@ -123,47 +124,71 @@ type agentStatusEntry struct { } // handleAgentStatus lists Claude sessions across worktrees. +// Uses cached session snapshot when available, falls back to real-time scanning. func (s *Server) handleAgentStatus(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { runningOnly := req.GetBool("running_only", false) - wts, err := worktree.ListAll(s.cfg) - if err != nil { - return mcpgo.NewToolResultError("failed to list worktrees: " + err.Error()), nil - } - var entries []agentStatusEntry - for _, wt := range wts { - sessions, _ := session.FindSessions(wt.Path) - if len(sessions) == 0 { - continue - } - sess := sessions[0] - filePath := session.SessionFilePath(wt.Path, sess.ID) - model, tokens, _ := session.ParseSessionDetailTail(filePath) - running := session.IsProcessRunning(sess.ID) - - if runningOnly && !running { - continue + // Try cached snapshot first — only use if it contains paths matching our config + snapshot, _ := reconciler.ReadSessionSnapshot() + basePaths := s.cfg.AllBasePaths() + if reconciler.IsSnapshotFresh(snapshot, 60*time.Second) && reconciler.SnapshotMatchesConfig(snapshot, basePaths) { + for _, ss := range snapshot.Sessions { + if runningOnly && ss.Status == "stopped" { + continue + } + entries = append(entries, agentStatusEntry{ + Worktree: ss.WorktreePath, + SessionID: ss.SessionID, + Status: ss.Status, + Size: ss.Size, + Model: ss.Model, + InputTokens: ss.InputTokens, + OutputTokens: ss.OutputTokens, + LastActive: session.FormatAge(time.Unix(ss.LastModified, 0)), + }) } - - status := "stopped" - if running { - status = "running" + } else { + // Fall back to real-time scanning + wts, err := worktree.ListAll(s.cfg) + if err != nil { + return mcpgo.NewToolResultError("failed to list worktrees: " + err.Error()), nil } - lastActive := time.Unix(sess.Modified, 0) - - entries = append(entries, agentStatusEntry{ - Worktree: wt.Path, - SessionID: sess.ID, - Status: status, - Size: sess.SizeStr, - Model: session.ShortenModel(model), - InputTokens: session.FormatTokenCount(tokens.InputTokens), - OutputTokens: session.FormatTokenCount(tokens.OutputTokens), - LastActive: session.FormatAge(lastActive), - }) + for _, wt := range wts { + sessions, _ := session.FindSessions(wt.Path) + if len(sessions) == 0 { + continue + } + + sess := sessions[0] + filePath := session.SessionFilePath(wt.Path, sess.ID) + model, tokens, _ := session.ParseSessionDetailTail(filePath) + running := session.IsProcessRunning(sess.ID) + + if runningOnly && !running { + continue + } + + status := "stopped" + if running { + status = "running" + } + + lastActive := time.Unix(sess.Modified, 0) + + entries = append(entries, agentStatusEntry{ + Worktree: wt.Path, + SessionID: sess.ID, + Status: status, + Size: sess.SizeStr, + Model: session.ShortenModel(model), + InputTokens: session.FormatTokenCount(tokens.InputTokens), + OutputTokens: session.FormatTokenCount(tokens.OutputTokens), + LastActive: session.FormatAge(lastActive), + }) + } } if entries == nil { entries = []agentStatusEntry{} diff --git a/internal/reconciler/session.go b/internal/reconciler/session.go new file mode 100644 index 0000000..dca1e02 --- /dev/null +++ b/internal/reconciler/session.go @@ -0,0 +1,73 @@ +package reconciler + +import ( + "fmt" + "time" + + "github.com/mgreau/zen/internal/config" + "github.com/mgreau/zen/internal/session" + "github.com/mgreau/zen/internal/worktree" +) + +// ScanSessions scans all worktrees for Claude sessions and writes +// a cached snapshot to ~/.zen/state/sessions.json. +// +// A session is classified as: +// - "stopped" — process not alive +// - "running" — process alive, file recently modified +// - "waiting" — process alive, file idle ≥ idleThreshold (needs user input) +func ScanSessions(cfg *config.Config, idleThreshold time.Duration) { + wts, err := worktree.ListAll(cfg) + if err != nil { + fmt.Printf("[%s] Session scan: error listing worktrees: %v\n", + time.Now().Format(time.RFC3339), err) + return + } + + now := time.Now() + var states []SessionState + + for _, wt := range wts { + sessions, _ := session.FindSessions(wt.Path) + if len(sessions) == 0 { + continue + } + + // Only track the most recent session per worktree + s := sessions[0] + filePath := session.SessionFilePath(wt.Path, s.ID) + + running := session.IsProcessRunning(s.ID) + + var status string + switch { + case !running: + status = "stopped" + case now.Sub(time.Unix(s.Modified, 0)) >= idleThreshold: + status = "waiting" + default: + status = "running" + } + + // Parse model and tokens from the tail of the session file + model, tokens, _ := session.ParseSessionDetailTail(filePath) + + states = append(states, SessionState{ + WorktreePath: wt.Path, + WorktreeName: wt.Name, + SessionID: s.ID, + Status: status, + Model: session.ShortenModel(model), + Size: s.SizeStr, + InputTokens: session.FormatTokenCount(tokens.InputTokens), + OutputTokens: session.FormatTokenCount(tokens.OutputTokens), + LastModified: s.Modified, + UpdatedAt: now.Unix(), + }) + } + + if err := WriteSessionSnapshot(states); err != nil { + fmt.Printf("[%s] Session scan: error writing snapshot: %v\n", + time.Now().Format(time.RFC3339), err) + } +} diff --git a/internal/reconciler/session_state.go b/internal/reconciler/session_state.go new file mode 100644 index 0000000..e1c68c8 --- /dev/null +++ b/internal/reconciler/session_state.go @@ -0,0 +1,96 @@ +package reconciler + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mgreau/zen/internal/config" +) + +// SessionState holds the cached state of a single Claude session. +type SessionState struct { + WorktreePath string `json:"worktree_path"` + WorktreeName string `json:"worktree_name"` + SessionID string `json:"session_id"` + Status string `json:"status"` // "running", "waiting", "stopped" + Model string `json:"model"` + Size string `json:"size"` + InputTokens string `json:"input_tokens"` + OutputTokens string `json:"output_tokens"` + LastModified int64 `json:"last_modified_epoch"` + UpdatedAt int64 `json:"updated_at"` +} + +// SessionSnapshot is the top-level structure written to sessions.json. +type SessionSnapshot struct { + Timestamp string `json:"timestamp"` + Sessions []SessionState `json:"sessions"` +} + +// sessionSnapshotPath returns the path to ~/.zen/state/sessions.json. +func sessionSnapshotPath() string { + return filepath.Join(config.StateDir(), "sessions.json") +} + +// WriteSessionSnapshot writes session states to ~/.zen/state/sessions.json. +func WriteSessionSnapshot(states []SessionState) error { + snapshot := SessionSnapshot{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Sessions: states, + } + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + return os.WriteFile(sessionSnapshotPath(), data, 0o644) +} + +// ReadSessionSnapshot reads the cached session snapshot from disk. +// Returns nil if the file doesn't exist or can't be parsed. +func ReadSessionSnapshot() (*SessionSnapshot, error) { + data, err := os.ReadFile(sessionSnapshotPath()) + if err != nil { + return nil, err + } + var snapshot SessionSnapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return nil, err + } + return &snapshot, nil +} + +// IsSnapshotFresh returns true if the snapshot was updated within maxAge. +func IsSnapshotFresh(snapshot *SessionSnapshot, maxAge time.Duration) bool { + if snapshot == nil { + return false + } + ts, err := time.Parse(time.RFC3339, snapshot.Timestamp) + if err != nil { + return false + } + return time.Since(ts) < maxAge +} + +// SnapshotMatchesConfig returns true if the snapshot contains sessions +// under at least one of the given base paths, or if both are empty. +// This prevents using a stale cache generated with a different config. +func SnapshotMatchesConfig(snapshot *SessionSnapshot, basePaths []string) bool { + if snapshot == nil || len(snapshot.Sessions) == 0 { + // Empty snapshot is valid for any config + return true + } + if len(basePaths) == 0 { + return false + } + for _, s := range snapshot.Sessions { + for _, bp := range basePaths { + if strings.HasPrefix(s.WorktreePath, bp) { + return true + } + } + } + return false +} diff --git a/internal/reconciler/setup.go b/internal/reconciler/setup.go index ba437b1..766004d 100644 --- a/internal/reconciler/setup.go +++ b/internal/reconciler/setup.go @@ -126,9 +126,12 @@ func (r *SetupReconciler) ensureWorktree(originPath, worktreePath, worktreeName return fmt.Errorf("git fetch: %w: %s", err, string(out)) } - wtCmd := exec.Command("git", "worktree", "add", worktreePath, fmt.Sprintf("pr-%d", prNumber)) + branch := fmt.Sprintf("pr-%d", prNumber) + wtCmd := exec.Command("git", "worktree", "add", worktreePath, branch) wtCmd.Dir = originPath if out, err := wtCmd.CombinedOutput(); err != nil { + // Clean up orphaned branch and partial worktree directory + wt.CleanupFailedAdd(originPath, worktreePath, branch) return fmt.Errorf("git worktree add: %w: %s", err, string(out)) } diff --git a/internal/worktree/lock.go b/internal/worktree/lock.go index a94d852..123124e 100644 --- a/internal/worktree/lock.go +++ b/internal/worktree/lock.go @@ -3,6 +3,7 @@ package worktree import ( "fmt" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -55,6 +56,33 @@ func CleanAllStaleLocks(cfg *config.Config) { } } +// CleanupFailedAdd cleans up after a failed "git worktree add" that may have +// created the branch and/or a partial worktree directory but failed to complete +// (e.g., "Could not write new index file"). It removes the partial worktree +// directory, prunes git's worktree metadata, and deletes the orphaned branch. +// +// originPath is the main repo directory, worktreePath is the target worktree +// directory, and branch is the git branch that was being created. +func CleanupFailedAdd(originPath, worktreePath, branch string) { + // Remove partial worktree directory if it exists + if _, err := os.Stat(worktreePath); err == nil { + os.RemoveAll(worktreePath) + } + + // Prune stale worktree metadata + pruneCmd := execCommand("git", "worktree", "prune") + pruneCmd.Dir = originPath + pruneCmd.CombinedOutput() + + // Delete the orphaned branch + delCmd := execCommand("git", "branch", "-D", branch) + delCmd.Dir = originPath + delCmd.CombinedOutput() +} + +// execCommand is a variable for testing. +var execCommand = exec.Command + func removeStaleLock(lockFile, name string) { data, err := os.ReadFile(lockFile) if err != nil { From dcaf5638d4278478b553cbd86e71f9c205695be5 Mon Sep 17 00:00:00 2001 From: mgreau Date: Thu, 12 Mar 2026 20:40:48 -0400 Subject: [PATCH 2/7] Add who-am-i command, MCP tool, and fix worktree creation for large repos - zen who-am-i: shows merged PRs, in-progress branches, and PR reviews for a configurable time period. --merged flag shows full descriptions. - zen_who_am_i MCP tool: same data available to Claude sessions. - Fix "Could not write new index file" on large repos by using --no-checkout + separate git checkout (two-step worktree creation). - Update README with who-am-i docs, session_scan_interval config, sessions.json state file, and zen_who_am_i MCP tool. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 25 ++ cmd/whoami.go | 515 +++++++++++++++++++++++++++++++++++ cmd/work.go | 13 +- internal/mcp/server.go | 13 + internal/mcp/tools.go | 248 +++++++++++++++++ internal/reconciler/setup.go | 12 +- 6 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 cmd/whoami.go diff --git a/README.md b/README.md index ee25050..3d30f7e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Zen manages this silently: a background daemon watches GitHub, creates worktrees - [Review](#review) - [Reviews](#reviews) - [Feature Work](#feature-work) +- [Who Am I](#who-am-i) - [Dashboard](#dashboard) - [Status](#status) - [Search](#search) @@ -160,6 +161,27 @@ zen work resume # Resume a feature session in new iTerm tab zen work delete # Delete a feature worktree ``` +## Who Am I + +Summary of your work across worktrees — what you merged, what's in progress, and what you reviewed. + +``` +zen who-am-i # All repos, last 7 days +zen who-am-i -r app -p 30d # Specific repo, last 30 days +zen who-am-i --merged # Only merged & deployed PRs with descriptions +zen who-am-i --merged -r app -p 7d # Merged PRs in app, last 7 days +``` + +Shows three sections: + +- **Merged & Deployed** — PRs merged to `origin/main` by you, with PR numbers. Use `--merged` for full commit descriptions. +- **In Progress** — feature branches with uncommitted work or active Claude sessions +- **PR Reviews** — review worktrees with commit counts and session indicators + +Period formats: `1d`, `7d`, `30d` (days), `2w` (weeks), `1m` (months). + +Also available as an MCP tool (`zen_who_am_i`) so Claude can query and summarize your work directly. + ## Dashboard ### Status @@ -220,6 +242,7 @@ Starts a Model Context Protocol server on stdio, exposing zen tools for Claude s - `zen_worktree_list` — list worktrees - `zen_pr_details` / `zen_pr_files` — PR metadata - `zen_agent_status` — session info +- `zen_who_am_i` — work summary (merged PRs, in-progress, reviews) - `zen_config_repos` — configured repositories To register with Claude Code: @@ -278,6 +301,7 @@ terminal: iterm # or "ghostty" for Ghostty support watch: dispatch_interval: "10s" # How often to process queued work cleanup_interval: "1h" # How often to scan for merged PRs + session_scan_interval: "10s" # How often to scan Claude session states cleanup_after_days: 5 # Days after merge before removing worktree concurrency: 2 # Parallel worktree setups max_retries: 5 # Max retry attempts for git failures @@ -309,6 +333,7 @@ All state lives in `~/.zen/state/`: | `watch.log` | Daemon logs | | `last_check.json` | Timestamp of last GitHub poll | | `pr_cache.json` | PR titles/authors for display | +| `sessions.json` | Cached Claude session states (updated every 10s by daemon) | ## Design diff --git a/cmd/whoami.go b/cmd/whoami.go new file mode 100644 index 0000000..eae6b26 --- /dev/null +++ b/cmd/whoami.go @@ -0,0 +1,515 @@ +package cmd + +import ( + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/mgreau/zen/internal/session" + "github.com/mgreau/zen/internal/ui" + wt "github.com/mgreau/zen/internal/worktree" + "github.com/spf13/cobra" +) + +var ( + whoamiPeriod string + whoamiRepo string + whoamiMerged bool +) + +var whoamiCmd = &cobra.Command{ + Use: "who-am-i", + Short: "Summary of your work across worktrees", + Long: `Shows a summary of work done for a given time period. + +Includes merged PRs (deployed to main), in-progress feature branches, +PR reviews, and Claude session activity. Defaults to the last 7 days.`, + RunE: runWhoami, +} + +func init() { + whoamiCmd.Flags().StringVarP(&whoamiPeriod, "period", "p", "7d", "Time period (e.g., 1d, 7d, 30d)") + whoamiCmd.Flags().StringVarP(&whoamiRepo, "repo", "r", "", "Filter by repo (short name)") + whoamiCmd.Flags().BoolVar(&whoamiMerged, "merged", false, "Show only merged & deployed PRs") + rootCmd.AddCommand(whoamiCmd) +} + +// mergedEntry represents a commit merged to origin/main. +type mergedEntry struct { + Repo string `json:"repo"` + Hash string `json:"hash"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + PRNumber string `json:"pr_number,omitempty"` + Date string `json:"date"` +} + +// whoamiEntry holds per-worktree activity data. +type whoamiEntry struct { + Name string `json:"name"` + Repo string `json:"repo"` + Type string `json:"type"` + Branch string `json:"branch"` + Commits int `json:"commits"` + LastCommit string `json:"last_commit"` + HasSession bool `json:"has_session"` + SessionAge string `json:"session_age,omitempty"` + PRNumber int `json:"pr_number,omitempty"` +} + +// whoamiSummary holds the overall summary. +type whoamiSummary struct { + Period string `json:"period"` + Since string `json:"since"` + Repos []string `json:"repos"` + Merged []mergedEntry `json:"merged"` + TotalMerged int `json:"total_merged"` + InProgress []whoamiEntry `json:"in_progress"` + PRReviews []whoamiEntry `json:"pr_reviews"` + TotalCommits int `json:"total_in_progress_commits"` +} + +func runWhoami(cmd *cobra.Command, args []string) error { + since, err := parsePeriod(whoamiPeriod) + if err != nil { + return err + } + + // Determine which repos to scan + repos := cfg.RepoNames() + if whoamiRepo != "" { + if cfg.RepoBasePath(whoamiRepo) == "" { + return fmt.Errorf("unknown repo %q", whoamiRepo) + } + repos = []string{whoamiRepo} + } + + // --- Merged work (commits on origin/main by the user) --- + var merged []mergedEntry + for _, repo := range repos { + basePath := cfg.RepoBasePath(repo) + originPath := filepath.Join(basePath, repo) + entries := mergedCommits(originPath, since, whoamiMerged) + for i := range entries { + entries[i].Repo = repo + } + merged = append(merged, entries...) + } + + // If --merged, skip worktree scanning and show only merged work + if whoamiMerged { + return renderMergedOnly(merged, repos, since) + } + + // --- In-progress worktrees --- + wts, err := wt.ListAll(cfg) + if err != nil { + return fmt.Errorf("listing worktrees: %w", err) + } + + var inProgress, prReviews []whoamiEntry + repoSet := make(map[string]bool) + totalBranchCommits := 0 + + for _, w := range wts { + if whoamiRepo != "" && w.Repo != whoamiRepo { + continue + } + + commits := countCommits(w.Path, since) + hasSession := session.HasActiveSession(w.Path) + + if commits == 0 && !hasRecentSession(w.Path, since) { + continue + } + + repoSet[w.Repo] = true + totalBranchCommits += commits + + entry := whoamiEntry{ + Name: w.Name, + Repo: w.Repo, + Type: string(w.Type), + Branch: w.Branch, + Commits: commits, + PRNumber: w.PRNumber, + } + + if commits > 0 { + entry.LastCommit = lastCommitMessage(w.Path) + } + + if hasSession { + entry.HasSession = true + if sessions, _ := session.FindSessions(w.Path); len(sessions) > 0 { + entry.SessionAge = session.FormatAge(time.Unix(sessions[0].Modified, 0)) + } + } + + if w.Type == wt.TypePRReview { + prReviews = append(prReviews, entry) + } else { + inProgress = append(inProgress, entry) + } + } + + // Sort in-progress by commits desc + sort.Slice(inProgress, func(i, j int) bool { + if inProgress[i].Commits != inProgress[j].Commits { + return inProgress[i].Commits > inProgress[j].Commits + } + return inProgress[i].Name < inProgress[j].Name + }) + sort.Slice(prReviews, func(i, j int) bool { + if prReviews[i].Commits != prReviews[j].Commits { + return prReviews[i].Commits > prReviews[j].Commits + } + return prReviews[i].Name < prReviews[j].Name + }) + + // Collect all repos that had activity + for _, m := range merged { + repoSet[m.Repo] = true + } + allRepos := make([]string, 0, len(repoSet)) + for r := range repoSet { + allRepos = append(allRepos, r) + } + sort.Strings(allRepos) + + summary := whoamiSummary{ + Period: whoamiPeriod, + Since: since.Format("2006-01-02"), + Repos: allRepos, + Merged: merged, + TotalMerged: len(merged), + InProgress: inProgress, + PRReviews: prReviews, + TotalCommits: totalBranchCommits, + } + + if jsonFlag { + if summary.Merged == nil { + summary.Merged = []mergedEntry{} + } + if summary.InProgress == nil { + summary.InProgress = []whoamiEntry{} + } + if summary.PRReviews == nil { + summary.PRReviews = []whoamiEntry{} + } + printJSON(summary) + return nil + } + + // --- Human-readable output --- + fmt.Println() + ui.SectionHeader("Who Am I") + fmt.Println() + + repoLabel := strings.Join(allRepos, ", ") + if repoLabel == "" { + repoLabel = "(none)" + } + + fmt.Printf(" Period: last %s (since %s)\n", whoamiPeriod, since.Format("Jan 2")) + fmt.Printf(" Repos: %s\n", repoLabel) + fmt.Printf(" Merged: %s to main | In-progress: %s branches\n", + ui.GreenText(fmt.Sprintf("%d PRs", len(merged))), + ui.CyanText(fmt.Sprintf("%d", len(inProgress)))) + fmt.Println() + + // Merged section + if len(merged) > 0 { + fmt.Printf(" %s\n", ui.BoldText("Merged & Deployed")) + fmt.Println() + for _, m := range merged { + prTag := "" + if m.PRNumber != "" { + prTag = ui.DimText(fmt.Sprintf("#%s", m.PRNumber)) + } + subject := ui.Truncate(m.Subject, 65) + fmt.Printf(" %s %s %s\n", ui.GreenText("✓"), subject, prTag) + } + fmt.Println() + } + + // In-progress features + if len(inProgress) > 0 { + fmt.Printf(" %s\n", ui.BoldText("In Progress")) + fmt.Println() + for _, e := range inProgress { + commitStr := ui.DimText(commitLabel(e.Commits)) + if e.Commits == 0 { + commitStr = ui.DimText("session only") + } + sessionIcon := "" + if e.HasSession { + sessionIcon = ui.GreenText(" ●") + } + + fmt.Printf(" %-45s %s%s\n", ui.Truncate(e.Name, 45), commitStr, sessionIcon) + if e.LastCommit != "" { + fmt.Printf(" %s\n", ui.DimText(" └ "+ui.Truncate(e.LastCommit, 60))) + } + } + fmt.Println() + } + + // PR reviews + if len(prReviews) > 0 { + fmt.Printf(" %s\n", ui.BoldText("PR Reviews")) + fmt.Println() + for _, e := range prReviews { + commitStr := ui.DimText(commitLabel(e.Commits)) + if e.Commits == 0 { + commitStr = ui.DimText("session only") + } + sessionIcon := "" + if e.HasSession { + sessionIcon = ui.GreenText(" ●") + } + + label := e.Name + if e.PRNumber > 0 { + label = fmt.Sprintf("#%d %s", e.PRNumber, e.Branch) + } + fmt.Printf(" %-45s %s%s\n", ui.Truncate(label, 45), commitStr, sessionIcon) + } + fmt.Println() + } + + if len(merged) == 0 && len(inProgress) == 0 && len(prReviews) == 0 { + fmt.Println(" No activity found in this period.") + fmt.Println() + } + + return nil +} + +// renderMergedOnly shows a detailed view of only merged PRs. +func renderMergedOnly(merged []mergedEntry, repos []string, since time.Time) error { + if jsonFlag { + if merged == nil { + merged = []mergedEntry{} + } + printJSON(merged) + return nil + } + + fmt.Println() + ui.SectionHeader("Merged & Deployed") + fmt.Println() + + repoLabel := strings.Join(repos, ", ") + fmt.Printf(" Period: last %s (since %s)\n", whoamiPeriod, since.Format("Jan 2")) + fmt.Printf(" Repos: %s\n", repoLabel) + fmt.Printf(" Total: %s\n", ui.GreenText(fmt.Sprintf("%d PRs", len(merged)))) + fmt.Println() + + if len(merged) == 0 { + fmt.Println(" No merged PRs found in this period.") + fmt.Println() + return nil + } + + for i, m := range merged { + prTag := "" + if m.PRNumber != "" { + prTag = ui.DimText(fmt.Sprintf(" #%s", m.PRNumber)) + } + dateTag := "" + if m.Date != "" { + dateTag = ui.DimText(fmt.Sprintf(" %s", m.Date)) + } + fmt.Printf(" %s %s%s%s\n", ui.GreenText("✓"), m.Subject, prTag, dateTag) + + if m.Body != "" { + // Show body lines indented, as a summary + for _, line := range strings.Split(m.Body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Skip common noise lines + if isNoiseBodyLine(line) { + continue + } + fmt.Printf(" %s\n", ui.DimText(ui.Truncate(line, 75))) + } + } + + if i < len(merged)-1 { + fmt.Println() + } + } + + fmt.Println() + return nil +} + +// isNoiseBodyLine returns true for lines that don't add value to the summary. +func isNoiseBodyLine(line string) bool { + lower := strings.ToLower(line) + noisePatterns := []string{ + "co-authored-by:", + "signed-off-by:", + "", + "**full changelog**", + } + for _, p := range noisePatterns { + if strings.Contains(lower, p) { + return true + } + } + return false +} + +// parsePeriod converts a period string like "7d" to a time.Time. +func parsePeriod(period string) (time.Time, error) { + now := time.Now() + if len(period) < 2 { + return time.Time{}, fmt.Errorf("invalid period %q (use e.g., 1d, 7d, 30d)", period) + } + + unit := period[len(period)-1] + valStr := period[:len(period)-1] + + var val int + if _, err := fmt.Sscanf(valStr, "%d", &val); err != nil || val <= 0 { + return time.Time{}, fmt.Errorf("invalid period %q (use e.g., 1d, 7d, 30d)", period) + } + + switch unit { + case 'd': + return now.AddDate(0, 0, -val), nil + case 'w': + return now.AddDate(0, 0, -val*7), nil + case 'm': + return now.AddDate(0, -val, 0), nil + default: + return time.Time{}, fmt.Errorf("invalid period unit %q (use d, w, or m)", string(unit)) + } +} + +// prNumberRe extracts PR numbers from commit subjects like "feat: something (#1234)" +var prNumberRe = regexp.MustCompile(`\(#(\d+)\)\s*$`) + +// mergedCommits returns commits by the current git user on origin/main since the given time. +// When withBody is true, it also fetches the commit body for each entry. +func mergedCommits(originPath string, since time.Time, withBody bool) []mergedEntry { + // Get the git user name for author filtering + authorCmd := exec.Command("git", "config", "user.name") + authorCmd.Dir = originPath + authorOut, err := authorCmd.Output() + if err != nil { + return nil + } + author := strings.TrimSpace(string(authorOut)) + + sinceStr := since.Format("2006-01-02") + cmd := exec.Command("git", "log", + "--format=%h\t%s\t%ad", + "--date=short", + "--since="+sinceStr, + "--author="+author, + "origin/main", + ) + cmd.Dir = originPath + out, err := cmd.Output() + if err != nil { + return nil + } + + var entries []mergedEntry + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 2 { + continue + } + + e := mergedEntry{ + Hash: parts[0], + Subject: parts[1], + } + if len(parts) == 3 { + e.Date = parts[2] + } + + // Extract PR number from subject + if m := prNumberRe.FindStringSubmatch(e.Subject); len(m) == 2 { + e.PRNumber = m[1] + e.Subject = strings.TrimSpace(prNumberRe.ReplaceAllString(e.Subject, "")) + } + + // Fetch commit body for summary + if withBody { + e.Body = commitBody(originPath, e.Hash) + } + + entries = append(entries, e) + } + + return entries +} + +// commitBody returns the body (message without subject) of a commit. +func commitBody(repoPath, hash string) string { + cmd := exec.Command("git", "log", "-1", "--format=%b", hash) + cmd.Dir = repoPath + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// countCommits counts commits on the branch (not on origin/main) since the given time. +func countCommits(worktreePath string, since time.Time) int { + sinceStr := since.Format("2006-01-02") + cmd := exec.Command("git", "rev-list", "--count", "--since="+sinceStr, "origin/main..HEAD") + cmd.Dir = worktreePath + out, err := cmd.Output() + if err != nil { + return 0 + } + var count int + fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count) + return count +} + +// lastCommitMessage returns the subject line of the most recent branch-only commit. +func lastCommitMessage(worktreePath string) string { + cmd := exec.Command("git", "log", "-1", "--format=%s", "origin/main..HEAD") + cmd.Dir = worktreePath + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// commitLabel returns a human-readable commit count string. +func commitLabel(n int) string { + if n == 1 { + return "1 commit" + } + return fmt.Sprintf("%d commits", n) +} + +// hasRecentSession returns true if the worktree has a session modified since the given time. +func hasRecentSession(worktreePath string, since time.Time) bool { + sessions, _ := session.FindSessions(worktreePath) + if len(sessions) == 0 { + return false + } + return time.Unix(sessions[0].Modified, 0).After(since) +} diff --git a/cmd/work.go b/cmd/work.go index 1c71fe5..3f874cb 100644 --- a/cmd/work.go +++ b/cmd/work.go @@ -164,15 +164,24 @@ func runWorkNew(cmd *cobra.Command, args []string) error { } ui.LogInfo(fmt.Sprintf("Creating worktree %s (branch %s)...", worktreeName, gitBranch)) - wtCmd := exec.Command("git", "worktree", "add", worktreePath, "-b", gitBranch, "origin/main") + // Use --no-checkout + separate checkout to avoid "Could not write new index file" + // on large repos (13K+ files). The two-step approach handles the index write reliably. + wtCmd := exec.Command("git", "worktree", "add", "--no-checkout", worktreePath, "-b", gitBranch, "origin/main") wtCmd.Dir = originPath if out, err := wtCmd.CombinedOutput(); err != nil { - // Clean up orphaned branch and partial worktree directory wt.CleanupFailedAdd(originPath, worktreePath, gitBranch) wt.GitMu.Unlock() return fmt.Errorf("git worktree add: %w: %s", err, string(out)) } + checkoutCmd := exec.Command("git", "checkout") + checkoutCmd.Dir = worktreePath + if out, err := checkoutCmd.CombinedOutput(); err != nil { + wt.CleanupFailedAdd(originPath, worktreePath, gitBranch) + wt.GitMu.Unlock() + return fmt.Errorf("git checkout in worktree: %w: %s", err, string(out)) + } + // Clean stale index.lock lockFile := filepath.Join(originPath, ".git", "worktrees", worktreeName, "index.lock") os.Remove(lockFile) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 5728a2f..c0b427a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -97,6 +97,19 @@ func (s *Server) registerTools() { s.handleAgentStatus, ) + s.server.AddTool( + mcpgo.NewTool("zen_who_am_i", + mcpgo.WithDescription("Summary of work done: merged PRs deployed to main, in-progress branches, and PR reviews for a given time period"), + mcpgo.WithString("repo", mcpgo.Description("Short repo name filter (e.g. 'mono')")), + mcpgo.WithString("period", mcpgo.Description("Time period (e.g. '1d', '7d', '30d'). Default: '7d'")), + mcpgo.WithBoolean("merged_only", mcpgo.Description("Only show merged & deployed PRs with full descriptions")), + mcpgo.WithReadOnlyHintAnnotation(true), + mcpgo.WithDestructiveHintAnnotation(false), + mcpgo.WithOpenWorldHintAnnotation(false), + ), + s.handleWhoAmI, + ) + s.server.AddTool( mcpgo.NewTool("zen_config_repos", mcpgo.WithDescription("List configured repositories with short names, full GitHub names, and base paths"), diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 9949b49..3724c42 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -3,7 +3,12 @@ package coordmcp import ( "context" "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "regexp" "sort" + "strings" "time" mcpgo "github.com/mark3labs/mcp-go/mcp" @@ -221,3 +226,246 @@ func (s *Server) handleConfigRepos(ctx context.Context, req mcpgo.CallToolReques } return jsonResult(repos) } + +// whoAmIMergedEntry represents a commit merged to origin/main. +type whoAmIMergedEntry struct { + Repo string `json:"repo"` + Hash string `json:"hash"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + PRNumber string `json:"pr_number,omitempty"` + Date string `json:"date"` +} + +// whoAmIWorktreeEntry holds per-worktree activity data. +type whoAmIWorktreeEntry struct { + Name string `json:"name"` + Repo string `json:"repo"` + Type string `json:"type"` + Branch string `json:"branch"` + Commits int `json:"commits"` + LastCommit string `json:"last_commit,omitempty"` + HasSession bool `json:"has_session"` + PRNumber int `json:"pr_number,omitempty"` +} + +// whoAmISummary holds the complete who-am-i response. +type whoAmISummary struct { + Period string `json:"period"` + Since string `json:"since"` + Repos []string `json:"repos"` + Merged []whoAmIMergedEntry `json:"merged"` + InProgress []whoAmIWorktreeEntry `json:"in_progress"` + PRReviews []whoAmIWorktreeEntry `json:"pr_reviews"` +} + +// handleWhoAmI returns a summary of work done across repos. +func (s *Server) handleWhoAmI(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + repoFilter := req.GetString("repo", "") + period := req.GetString("period", "7d") + mergedOnly := req.GetBool("merged_only", false) + + since, err := whoamiParsePeriod(period) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + + // Determine repos + repos := s.cfg.RepoNames() + if repoFilter != "" { + if s.cfg.RepoBasePath(repoFilter) == "" { + return mcpgo.NewToolResultError(fmt.Sprintf("unknown repo %q", repoFilter)), nil + } + repos = []string{repoFilter} + } + + // Merged commits + var merged []whoAmIMergedEntry + for _, repo := range repos { + basePath := s.cfg.RepoBasePath(repo) + originPath := filepath.Join(basePath, repo) + entries := whoamiMergedCommits(originPath, since, mergedOnly) + for i := range entries { + entries[i].Repo = repo + } + merged = append(merged, entries...) + } + + if mergedOnly { + if merged == nil { + merged = []whoAmIMergedEntry{} + } + return jsonResult(whoAmISummary{ + Period: period, + Since: since.Format("2006-01-02"), + Repos: repos, + Merged: merged, + InProgress: []whoAmIWorktreeEntry{}, + PRReviews: []whoAmIWorktreeEntry{}, + }) + } + + // In-progress worktrees + wts, err := worktree.ListAll(s.cfg) + if err != nil { + return mcpgo.NewToolResultError("failed to list worktrees: " + err.Error()), nil + } + + var inProgress, prReviews []whoAmIWorktreeEntry + for _, wt := range wts { + if repoFilter != "" && wt.Repo != repoFilter { + continue + } + + commits := whoamiCountCommits(wt.Path, since) + hasSession := session.HasActiveSession(wt.Path) + if commits == 0 && !whoamiHasRecentSession(wt.Path, since) { + continue + } + + entry := whoAmIWorktreeEntry{ + Name: wt.Name, + Repo: wt.Repo, + Type: string(wt.Type), + Branch: wt.Branch, + Commits: commits, + HasSession: hasSession, + PRNumber: wt.PRNumber, + } + if commits > 0 { + entry.LastCommit = whoamiLastCommit(wt.Path) + } + + if wt.Type == worktree.TypePRReview { + prReviews = append(prReviews, entry) + } else { + inProgress = append(inProgress, entry) + } + } + + if merged == nil { + merged = []whoAmIMergedEntry{} + } + if inProgress == nil { + inProgress = []whoAmIWorktreeEntry{} + } + if prReviews == nil { + prReviews = []whoAmIWorktreeEntry{} + } + + return jsonResult(whoAmISummary{ + Period: period, + Since: since.Format("2006-01-02"), + Repos: repos, + Merged: merged, + InProgress: inProgress, + PRReviews: prReviews, + }) +} + +// --- who-am-i git helpers (MCP-local, no dependency on cmd package) --- + +var whoamiPRNumberRe = regexp.MustCompile(`\(#(\d+)\)\s*$`) + +func whoamiParsePeriod(period string) (time.Time, error) { + now := time.Now() + if len(period) < 2 { + return time.Time{}, fmt.Errorf("invalid period %q", period) + } + unit := period[len(period)-1] + var val int + if _, err := fmt.Sscanf(period[:len(period)-1], "%d", &val); err != nil || val <= 0 { + return time.Time{}, fmt.Errorf("invalid period %q", period) + } + switch unit { + case 'd': + return now.AddDate(0, 0, -val), nil + case 'w': + return now.AddDate(0, 0, -val*7), nil + case 'm': + return now.AddDate(0, -val, 0), nil + default: + return time.Time{}, fmt.Errorf("invalid period unit %q", string(unit)) + } +} + +func whoamiMergedCommits(originPath string, since time.Time, withBody bool) []whoAmIMergedEntry { + authorCmd := exec.Command("git", "config", "user.name") + authorCmd.Dir = originPath + authorOut, err := authorCmd.Output() + if err != nil { + return nil + } + author := strings.TrimSpace(string(authorOut)) + + cmd := exec.Command("git", "log", + "--format=%h\t%s\t%ad", + "--date=short", + "--since="+since.Format("2006-01-02"), + "--author="+author, + "origin/main", + ) + cmd.Dir = originPath + out, err := cmd.Output() + if err != nil { + return nil + } + + var entries []whoAmIMergedEntry + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 2 { + continue + } + e := whoAmIMergedEntry{Hash: parts[0], Subject: parts[1]} + if len(parts) == 3 { + e.Date = parts[2] + } + if m := whoamiPRNumberRe.FindStringSubmatch(e.Subject); len(m) == 2 { + e.PRNumber = m[1] + e.Subject = strings.TrimSpace(whoamiPRNumberRe.ReplaceAllString(e.Subject, "")) + } + if withBody { + bodyCmd := exec.Command("git", "log", "-1", "--format=%b", e.Hash) + bodyCmd.Dir = originPath + if bodyOut, err := bodyCmd.Output(); err == nil { + e.Body = strings.TrimSpace(string(bodyOut)) + } + } + entries = append(entries, e) + } + return entries +} + +func whoamiCountCommits(wtPath string, since time.Time) int { + cmd := exec.Command("git", "rev-list", "--count", "--since="+since.Format("2006-01-02"), "origin/main..HEAD") + cmd.Dir = wtPath + out, err := cmd.Output() + if err != nil { + return 0 + } + var count int + fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count) + return count +} + +func whoamiLastCommit(wtPath string) string { + cmd := exec.Command("git", "log", "-1", "--format=%s", "origin/main..HEAD") + cmd.Dir = wtPath + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func whoamiHasRecentSession(wtPath string, since time.Time) bool { + sessions, _ := session.FindSessions(wtPath) + if len(sessions) == 0 { + return false + } + return time.Unix(sessions[0].Modified, 0).After(since) +} diff --git a/internal/reconciler/setup.go b/internal/reconciler/setup.go index 766004d..70f3042 100644 --- a/internal/reconciler/setup.go +++ b/internal/reconciler/setup.go @@ -127,14 +127,22 @@ func (r *SetupReconciler) ensureWorktree(originPath, worktreePath, worktreeName } branch := fmt.Sprintf("pr-%d", prNumber) - wtCmd := exec.Command("git", "worktree", "add", worktreePath, branch) + // Use --no-checkout + separate checkout to avoid "Could not write new index file" + // on large repos (13K+ files). + wtCmd := exec.Command("git", "worktree", "add", "--no-checkout", worktreePath, branch) wtCmd.Dir = originPath if out, err := wtCmd.CombinedOutput(); err != nil { - // Clean up orphaned branch and partial worktree directory wt.CleanupFailedAdd(originPath, worktreePath, branch) return fmt.Errorf("git worktree add: %w: %s", err, string(out)) } + checkoutCmd := exec.Command("git", "checkout") + checkoutCmd.Dir = worktreePath + if out, err := checkoutCmd.CombinedOutput(); err != nil { + wt.CleanupFailedAdd(originPath, worktreePath, branch) + return fmt.Errorf("git checkout in worktree: %w: %s", err, string(out)) + } + // Clean stale lock immediately lockFile := filepath.Join(originPath, ".git", "worktrees", worktreeName, "index.lock") os.Remove(lockFile) From 43ab7861616c1f8c9bd982d65fd1180d13b60831 Mon Sep 17 00:00:00 2001 From: mgreau Date: Fri, 3 Apr 2026 11:55:31 -0400 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20notify=20when=20Claude=20session=20?= =?UTF-8?q?transitions=20running=20=E2=86=92=20waiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect the moment a Claude session goes idle and send a macOS notification so you know to check back without watching a dashboard. - Add notify.SessionWaiting(worktreeName, model) using osascript - Track per-session previous status with sync.Map in ScanSessions - Detect running → waiting transition on each 10s tick - Debounce: re-notify same session at most once every 5 minutes --- internal/notify/notify.go | 9 +++++++++ internal/reconciler/session.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 1f96161..2726323 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -50,3 +50,12 @@ func StaleWorktrees(count int) error { "Run: zen cleanup", ) } + +// SessionWaiting notifies that a Claude session is waiting for user input. +func SessionWaiting(worktreeName, model string) error { + return Send( + "Claude is waiting", + fmt.Sprintf("%s needs your input", worktreeName), + model, + ) +} diff --git a/internal/reconciler/session.go b/internal/reconciler/session.go index dca1e02..ed81545 100644 --- a/internal/reconciler/session.go +++ b/internal/reconciler/session.go @@ -2,13 +2,24 @@ package reconciler import ( "fmt" + "sync" "time" "github.com/mgreau/zen/internal/config" + "github.com/mgreau/zen/internal/notify" "github.com/mgreau/zen/internal/session" "github.com/mgreau/zen/internal/worktree" ) +// prevSessionStatus and lastNotifiedAt track session state across scans +// to detect running → waiting transitions and debounce notifications. +var ( + prevSessionStatus sync.Map // SessionID → string status + lastNotifiedAt sync.Map // SessionID → time.Time +) + +const sessionNotifyDebounce = 5 * time.Minute + // ScanSessions scans all worktrees for Claude sessions and writes // a cached snapshot to ~/.zen/state/sessions.json. // @@ -51,13 +62,32 @@ func ScanSessions(cfg *config.Config, idleThreshold time.Duration) { // Parse model and tokens from the tail of the session file model, tokens, _ := session.ParseSessionDetailTail(filePath) + shortenedModel := session.ShortenModel(model) + + // Notify on running → waiting transition (debounced) + if status == "waiting" { + if prev, ok := prevSessionStatus.Load(s.ID); ok && prev.(string) == "running" { + var lastTime time.Time + if last, ok := lastNotifiedAt.Load(s.ID); ok { + lastTime = last.(time.Time) + } + if time.Since(lastTime) >= sessionNotifyDebounce { + if err := notify.SessionWaiting(wt.Name, shortenedModel); err != nil { + fmt.Printf("[%s] Session notify error for %s: %v\n", + time.Now().Format(time.RFC3339), wt.Name, err) + } + lastNotifiedAt.Store(s.ID, now) + } + } + } + prevSessionStatus.Store(s.ID, status) states = append(states, SessionState{ WorktreePath: wt.Path, WorktreeName: wt.Name, SessionID: s.ID, Status: status, - Model: session.ShortenModel(model), + Model: shortenedModel, Size: s.SizeStr, InputTokens: session.FormatTokenCount(tokens.InputTokens), OutputTokens: session.FormatTokenCount(tokens.OutputTokens), From 72e94c7412b65faa017434430dfc372fed734b93 Mon Sep 17 00:00:00 2001 From: mgreau Date: Fri, 3 Apr 2026 11:56:36 -0400 Subject: [PATCH 4/7] feat: click-to-resume notifications via terminal-notifier If terminal-notifier is installed, clicking the "Claude is waiting" notification opens a new terminal tab directly in the worktree. Falls back to osascript with the resume command in the subtitle. - Add SendWithAction() with terminal-notifier / osascript fallback - SessionWaiting now accepts a resumeCmd for the click handler - Compute resume command: "zen review resume N" or "zen work resume name" based on worktree type (TypePRReview vs TypeFeature) --- internal/notify/notify.go | 36 ++++++++++++++++++++++++++++++++-- internal/reconciler/session.go | 16 ++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 2726323..1df0000 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -14,6 +14,36 @@ func Send(title, message, subtitle string) error { return exec.Command("osascript", "-e", script).Run() } +// terminalNotifierPath returns the path to terminal-notifier if installed. +func terminalNotifierPath() string { + path, _ := exec.LookPath("terminal-notifier") + return path +} + +// SendWithAction sends a notification with an optional click action. +// If terminal-notifier is installed, clicking the notification runs executeOnClick. +// Otherwise falls back to osascript with the command appended to the subtitle. +func SendWithAction(title, message, subtitle, executeOnClick string) error { + tn := terminalNotifierPath() + if tn != "" && executeOnClick != "" { + args := []string{"-title", title, "-message", message} + if subtitle != "" { + args = append(args, "-subtitle", subtitle) + } + args = append(args, "-execute", executeOnClick) + return exec.Command(tn, args...).Run() + } + // Fallback: append resume hint to subtitle so command is visible + if executeOnClick != "" { + if subtitle != "" { + subtitle = subtitle + " | " + executeOnClick + } else { + subtitle = executeOnClick + } + } + return Send(title, message, subtitle) +} + // PRReview notifies about a new PR review request. func PRReview(prNumber int, prTitle, author, repo string) error { @@ -52,10 +82,12 @@ func StaleWorktrees(count int) error { } // SessionWaiting notifies that a Claude session is waiting for user input. -func SessionWaiting(worktreeName, model string) error { - return Send( +// resumeCmd is executed on notification click when terminal-notifier is available. +func SessionWaiting(worktreeName, model, resumeCmd string) error { + return SendWithAction( "Claude is waiting", fmt.Sprintf("%s needs your input", worktreeName), model, + resumeCmd, ) } diff --git a/internal/reconciler/session.go b/internal/reconciler/session.go index ed81545..434553f 100644 --- a/internal/reconciler/session.go +++ b/internal/reconciler/session.go @@ -2,6 +2,7 @@ package reconciler import ( "fmt" + "os" "sync" "time" @@ -72,7 +73,8 @@ func ScanSessions(cfg *config.Config, idleThreshold time.Duration) { lastTime = last.(time.Time) } if time.Since(lastTime) >= sessionNotifyDebounce { - if err := notify.SessionWaiting(wt.Name, shortenedModel); err != nil { + resumeCmd := sessionResumeCmd(wt) + if err := notify.SessionWaiting(wt.Name, shortenedModel, resumeCmd); err != nil { fmt.Printf("[%s] Session notify error for %s: %v\n", time.Now().Format(time.RFC3339), wt.Name, err) } @@ -101,3 +103,15 @@ func ScanSessions(cfg *config.Config, idleThreshold time.Duration) { time.Now().Format(time.RFC3339), err) } } + +// sessionResumeCmd returns the zen command to resume a session in a new terminal tab. +func sessionResumeCmd(wt worktree.Worktree) string { + zenBin, err := os.Executable() + if err != nil { + zenBin = "zen" + } + if wt.Type == worktree.TypePRReview && wt.PRNumber > 0 { + return fmt.Sprintf("%s review resume %d", zenBin, wt.PRNumber) + } + return fmt.Sprintf("%s work resume %s", zenBin, wt.Name) +} From 94c21f8a41a56b5e454a9c8c0349e21637dca68e Mon Sep 17 00:00:00 2001 From: mgreau Date: Fri, 3 Apr 2026 12:10:49 -0400 Subject: [PATCH 5/7] feat: periodic digest notification summarizing active work Add a configurable digest ticker that sends a compact macOS notification summarizing waiting sessions, pending PR reviews, and active feature work. Silent when everything is quiet. - notify.Digest(waitingSessions, pendingReviews, featureWork) - reconciler.SendDigest(cfg) reads sessions.json + lists worktrees - WatchConfig.DigestInterval + DigestIntervalDuration() getter - digestTicker in daemon (nil channel = disabled by default) - Enable with: watch: { digest_interval: "2h" } in ~/.zen/config.yaml --- cmd/watch.go | 20 +++++++++++++-- internal/config/config.go | 14 +++++++++++ internal/notify/notify.go | 20 +++++++++++++++ internal/reconciler/digest.go | 47 +++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 internal/reconciler/digest.go diff --git a/cmd/watch.go b/cmd/watch.go index 7d1de00..82d4a44 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -240,11 +240,16 @@ func watchDaemon() error { dispatchInterval := watchCfg.DispatchIntervalDuration() cleanupInterval := watchCfg.CleanupIntervalDuration() sessionScanInterval := watchCfg.SessionScanIntervalDuration() + digestInterval, digestEnabled := watchCfg.DigestIntervalDuration() concurrency := watchCfg.GetConcurrency() maxRetries := watchCfg.GetMaxRetries() - fmt.Printf("[%s] Watch daemon started (poll=%s, dispatch=%s, cleanup=%s, session_scan=%s, concurrency=%d, maxRetries=%d)\n", - time.Now().Format(time.RFC3339), pollInterval, dispatchInterval, cleanupInterval, sessionScanInterval, concurrency, maxRetries) + digestStr := "disabled" + if digestEnabled { + digestStr = digestInterval.String() + } + fmt.Printf("[%s] Watch daemon started (poll=%s, dispatch=%s, cleanup=%s, session_scan=%s, digest=%s, concurrency=%d, maxRetries=%d)\n", + time.Now().Format(time.RFC3339), pollInterval, dispatchInterval, cleanupInterval, sessionScanInterval, digestStr, concurrency, maxRetries) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -284,6 +289,14 @@ func watchDaemon() error { rotateTicker := time.NewTicker(1 * time.Hour) defer rotateTicker.Stop() + // Digest ticker — only active when digest_interval is configured + var digestC <-chan time.Time + if digestEnabled { + digestTicker := time.NewTicker(digestInterval) + defer digestTicker.Stop() + digestC = digestTicker.C + } + // Initial poll and session scan pollOnce(ctx, seenPRs, setupQueue, setupRec) reconciler.ScanSessions(cfg, 10*time.Second) @@ -315,6 +328,9 @@ func watchDaemon() error { case <-cleanupTicker.C: reconciler.ScanMergedPRs(ctx, cfg, cleanupQueue, cfg.Watch.GetCleanupAfterDays()) + + case <-digestC: + reconciler.SendDigest(cfg) } } } diff --git a/internal/config/config.go b/internal/config/config.go index b5a6929..fea3a85 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type WatchConfig struct { CleanupAfterDays int `yaml:"cleanup_after_days"` // default 5 Concurrency int `yaml:"concurrency"` // default 2 MaxRetries int `yaml:"max_retries"` // default 5 + DigestInterval string `yaml:"digest_interval"` // "" = disabled, e.g. "2h" } // DispatchIntervalDuration returns the dispatch interval as a time.Duration, @@ -77,6 +78,19 @@ func (w WatchConfig) GetMaxRetries() int { return 5 } +// DigestIntervalDuration returns the digest interval duration and whether it is enabled. +// An empty DigestInterval string disables the digest (returns 0, false). +func (w WatchConfig) DigestIntervalDuration() (time.Duration, bool) { + if w.DigestInterval == "" { + return 0, false + } + d, err := time.ParseDuration(w.DigestInterval) + if err != nil || d <= 0 { + return 0, false + } + return d, true +} + // SessionScanIntervalDuration returns the session scan interval as a time.Duration, // falling back to the default of 10 seconds. func (w WatchConfig) SessionScanIntervalDuration() time.Duration { diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 1df0000..9d0487c 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -3,6 +3,7 @@ package notify import ( "fmt" "os/exec" + "strings" ) // Send sends a macOS notification using osascript. @@ -91,3 +92,22 @@ func SessionWaiting(worktreeName, model, resumeCmd string) error { resumeCmd, ) } + +// Digest sends a periodic summary notification. Only sends if there is something actionable. +func Digest(waitingSessions, pendingReviews, featureWork int) error { + if waitingSessions == 0 && pendingReviews == 0 { + return nil + } + var parts []string + if waitingSessions > 0 { + parts = append(parts, fmt.Sprintf("%d waiting", waitingSessions)) + } + if pendingReviews > 0 { + parts = append(parts, fmt.Sprintf("%d PRs to review", pendingReviews)) + } + subtitle := "" + if featureWork > 0 { + subtitle = fmt.Sprintf("%d feature branch(es) active", featureWork) + } + return Send("zen digest", strings.Join(parts, " • "), subtitle) +} diff --git a/internal/reconciler/digest.go b/internal/reconciler/digest.go new file mode 100644 index 0000000..136a8c2 --- /dev/null +++ b/internal/reconciler/digest.go @@ -0,0 +1,47 @@ +package reconciler + +import ( + "fmt" + "time" + + "github.com/mgreau/zen/internal/config" + "github.com/mgreau/zen/internal/notify" + "github.com/mgreau/zen/internal/worktree" +) + +// SendDigest reads cached state files and sends a compact summary notification +// if there is anything actionable (waiting sessions or pending PR reviews). +// Silent if everything is quiet. +func SendDigest(cfg *config.Config) { + // Count waiting sessions from the cached snapshot (2-minute freshness window) + var waitingSessions int + snapshot, err := ReadSessionSnapshot() + if err == nil && IsSnapshotFresh(snapshot, 2*time.Minute) { + for _, s := range snapshot.Sessions { + if s.Status == "waiting" { + waitingSessions++ + } + } + } + + // Count worktrees by type + wts, err := worktree.ListAll(cfg) + if err != nil { + fmt.Printf("[%s] Digest: error listing worktrees: %v\n", + time.Now().Format(time.RFC3339), err) + return + } + var pendingReviews, featureWork int + for _, wt := range wts { + switch wt.Type { + case worktree.TypePRReview: + pendingReviews++ + case worktree.TypeFeature: + featureWork++ + } + } + + if err := notify.Digest(waitingSessions, pendingReviews, featureWork); err != nil { + fmt.Printf("[%s] Digest notify error: %v\n", time.Now().Format(time.RFC3339), err) + } +} From 97003aed2672409f2fb751498522c7f90b1422f0 Mon Sep 17 00:00:00 2001 From: mgreau Date: Fri, 3 Apr 2026 14:40:51 -0400 Subject: [PATCH 6/7] feat: use terminal-notifier for all daemon notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All notifications now use SendWithAction so clicking them does something useful when terminal-notifier is installed: - PRReview → zen review resume - WorktreeReady → zen review resume - PRMerged → zen cleanup - StaleWorktrees → zen cleanup Falls back to osascript with the command in the subtitle when terminal-notifier is not installed. --- internal/notify/notify.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 9d0487c..36f5097 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -2,10 +2,19 @@ package notify import ( "fmt" + "os" "os/exec" "strings" ) +// zenBin returns the path to the running zen binary. +func zenBin() string { + if bin, err := os.Executable(); err == nil { + return bin + } + return "zen" +} + // Send sends a macOS notification using osascript. func Send(title, message, subtitle string) error { script := fmt.Sprintf(`display notification %q with title %q`, message, title) @@ -47,38 +56,46 @@ func SendWithAction(title, message, subtitle, executeOnClick string) error { // PRReview notifies about a new PR review request. +// Clicking opens a terminal tab ready to start the review. func PRReview(prNumber int, prTitle, author, repo string) error { - return Send( + return SendWithAction( "New PR Review Request", fmt.Sprintf("PR #%d: %s", prNumber, prTitle), fmt.Sprintf("by %s in %s", author, repo), + fmt.Sprintf("%s review resume %d", zenBin(), prNumber), ) } // WorktreeReady notifies that a worktree is ready. +// Clicking opens a terminal tab in the worktree. func WorktreeReady(prNumber int, worktreePath string) error { - return Send( + return SendWithAction( "Worktree Ready", fmt.Sprintf("PR #%d worktree created", prNumber), worktreePath, + fmt.Sprintf("%s review resume %d", zenBin(), prNumber), ) } // PRMerged notifies about a PR merge. +// Clicking runs zen cleanup to remove the stale worktree. func PRMerged(prNumber int, prTitle string) error { - return Send( + return SendWithAction( "PR Merged", fmt.Sprintf("PR #%d: %s", prNumber, prTitle), "Worktree can be cleaned up", + fmt.Sprintf("%s cleanup", zenBin()), ) } // StaleWorktrees notifies about stale worktrees found. +// Clicking runs zen cleanup. func StaleWorktrees(count int) error { - return Send( + return SendWithAction( "Stale Worktrees Found", fmt.Sprintf("%d worktrees can be cleaned up", count), - "Run: zen cleanup", + "", + fmt.Sprintf("%s cleanup", zenBin()), ) } From 2a1443d8fef938c70c94e22e5cf6b1399229074e Mon Sep 17 00:00:00 2001 From: mgreau Date: Sat, 4 Apr 2026 09:36:28 -0400 Subject: [PATCH 7/7] fix: only use terminal-notifier for WorktreeReady notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorktreeReady is the most actionable notification — clicking it opens the worktree for review. All others revert to osascript to reduce noise. --- internal/notify/notify.go | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 36f5097..9fd9629 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -56,57 +56,49 @@ func SendWithAction(title, message, subtitle, executeOnClick string) error { // PRReview notifies about a new PR review request. -// Clicking opens a terminal tab ready to start the review. func PRReview(prNumber int, prTitle, author, repo string) error { - return SendWithAction( + return Send( "New PR Review Request", fmt.Sprintf("PR #%d: %s", prNumber, prTitle), fmt.Sprintf("by %s in %s", author, repo), - fmt.Sprintf("%s review resume %d", zenBin(), prNumber), ) } -// WorktreeReady notifies that a worktree is ready. -// Clicking opens a terminal tab in the worktree. +// WorktreeReady notifies that a worktree is ready for review. +// Clicking opens a terminal tab in the worktree (requires terminal-notifier). func WorktreeReady(prNumber int, worktreePath string) error { return SendWithAction( - "Worktree Ready", - fmt.Sprintf("PR #%d worktree created", prNumber), - worktreePath, + "Worktree Ready — click to review", + fmt.Sprintf("PR #%d", prNumber), + "", fmt.Sprintf("%s review resume %d", zenBin(), prNumber), ) } // PRMerged notifies about a PR merge. -// Clicking runs zen cleanup to remove the stale worktree. func PRMerged(prNumber int, prTitle string) error { - return SendWithAction( + return Send( "PR Merged", fmt.Sprintf("PR #%d: %s", prNumber, prTitle), "Worktree can be cleaned up", - fmt.Sprintf("%s cleanup", zenBin()), ) } // StaleWorktrees notifies about stale worktrees found. -// Clicking runs zen cleanup. func StaleWorktrees(count int) error { - return SendWithAction( + return Send( "Stale Worktrees Found", fmt.Sprintf("%d worktrees can be cleaned up", count), - "", - fmt.Sprintf("%s cleanup", zenBin()), + "Run: zen cleanup", ) } // SessionWaiting notifies that a Claude session is waiting for user input. -// resumeCmd is executed on notification click when terminal-notifier is available. func SessionWaiting(worktreeName, model, resumeCmd string) error { - return SendWithAction( + return Send( "Claude is waiting", fmt.Sprintf("%s needs your input", worktreeName), model, - resumeCmd, ) }