Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -160,6 +161,27 @@ zen work resume <name> # Resume a feature session in new iTerm tab
zen work delete <name> # 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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
162 changes: 104 additions & 58 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand All @@ -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
Expand Down
55 changes: 43 additions & 12 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -167,7 +175,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
ui.DimText(ui.ShortenHome(f.Path, home)))
}
}
ui.Hint("'zen work resume <name>' to continue | 'zen work new <repo> <branch>' to start | ● = active session")
ui.Hint("'zen work resume <name>' to continue | 'zen work new <repo> <branch>' to start | " + ui.GreenText("●") + " running " + ui.YellowText("●") + " waiting")
fmt.Println()

// Watch daemon
Expand All @@ -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}
Expand All @@ -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)
Expand Down
Loading
Loading