Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

334 lines
11 KiB
Go

// ABOUTME: `session list` subcommand — lists sessions with the
// ABOUTME: full set of HTTP query-param equivalents as CLI flags.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/service"
)
func newSessionListCommand() *cobra.Command {
var (
project, excludeProject, machine, agent string
date, dateFrom, dateTo, activeSince string
since string
minMessages, maxMessages int
minUserMessages int
includeOneShot bool
includeAutomated, includeChildren bool
outcome, healthGrade string
minToolFailures int
hasSecret bool
cursor string
limit int
sort string
reverse bool
resume, active bool
)
cmd := &cobra.Command{
Use: "list",
Short: "List sessions with filters",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
resolvedActiveSince, err := resolveSinceFlag(since, activeSince)
if err != nil {
return err
}
activeSince = resolvedActiveSince
svc, cleanup, err := resolveService(cmd)
if err != nil {
return err
}
defer cleanup()
f := service.ListFilter{
Project: project,
ExcludeProject: excludeProject,
Machine: machine,
Agent: agent,
Date: date,
DateFrom: dateFrom,
DateTo: dateTo,
ActiveSince: activeSince,
MinMessages: minMessages,
MaxMessages: maxMessages,
MinUserMessages: minUserMessages,
IncludeOneShot: includeOneShot,
IncludeAutomated: includeAutomated,
IncludeChildren: includeChildren,
Outcome: outcome,
HealthGrade: healthGrade,
HasSecret: hasSecret,
Cursor: cursor,
Limit: limit,
}
if cmd.Flags().Changed("min-tool-failures") {
f.MinToolFailures = &minToolFailures
}
// --resume / --active surface only recently-active sessions for
// quick relaunch: push a now-15m active_since window to the
// service so the limit is applied after the filter, and let the
// default recent sort keep newest-first ordering. An explicit
// --active-since or --since takes precedence so callers can
// widen or narrow the window.
now := time.Now()
if (resume || active) &&
!cmd.Flags().Changed("active-since") &&
!cmd.Flags().Changed("since") {
f.ActiveSince = now.Add(-resumeActiveWindow).
UTC().Format(time.RFC3339)
}
// Parse the multi-key sort spec; --reverse flips the natural
// direction of any term left without an explicit :asc/:desc, which
// is folded into the canonical spec string so the wire form fully
// captures the ordering.
keys, err := db.ParseSortSpec(sort)
if err != nil {
return fmt.Errorf("invalid sort %q: %w", sort, err)
}
// An empty spec means the implicit default; materialize it so
// --reverse has a term to flip instead of silently no-opping.
if len(keys) == 0 {
keys = []db.SortKey{{Key: db.DefaultSortKey()}}
}
if reverse {
for i := range keys {
if keys[i].Descending == nil {
d := !db.SortDefaultDescending(keys[i].Key)
keys[i].Descending = &d
}
}
}
f.OrderBy = db.FormatSortSpec(keys)
list, err := svc.List(cmd.Context(), f)
if err != nil {
return err
}
notice, err := sessionListDefaultExclusionNotice(
cmd.Context(), svc, f, list.Total)
if err != nil {
return err
}
if notice != "" {
fmt.Fprintln(cmd.ErrOrStderr(), notice)
}
if outputFormat(cmd) == "json" {
return json.NewEncoder(cmd.OutOrStdout()).Encode(list)
}
home, _ := os.UserHomeDir()
return printSessionListHuman(
cmd.OutOrStdout(), list, now, home)
},
}
flags := cmd.Flags()
flags.StringVar(&project, "project", "",
"Filter by project name")
flags.StringVar(&excludeProject, "exclude-project", "",
"Exclude sessions from the given project")
flags.StringVar(&machine, "machine", "",
"Filter by machine name")
flags.StringVar(&agent, "agent", "",
"Filter by agent (claude, codex, cursor, ...)")
flags.StringVar(&date, "date", "",
"Filter sessions active on YYYY-MM-DD")
flags.StringVar(&dateFrom, "date-from", "",
"Filter sessions active on or after YYYY-MM-DD")
flags.StringVar(&dateTo, "date-to", "",
"Filter sessions active on or before YYYY-MM-DD")
flags.StringVar(&activeSince, "active-since", "",
"Filter sessions active since RFC3339 timestamp")
flags.StringVar(&since, "since", "",
"Only sessions active since a relative duration (12h, 14d, 2w, 3m = 3 months, 1y) or YYYY-MM-DD")
flags.IntVar(&minMessages, "min-messages", 0,
"Minimum total message count")
flags.IntVar(&maxMessages, "max-messages", 0,
"Maximum total message count")
flags.IntVar(&minUserMessages, "min-user-messages", 0,
"Minimum user message count")
flags.BoolVar(&includeOneShot, "include-one-shot", false,
"Include one-shot sessions (excluded by default)")
flags.BoolVar(&includeAutomated, "include-automated", false,
"Include automated sessions (excluded by default)")
flags.BoolVar(&includeChildren, "include-children", false,
"Include subagent/child sessions")
flags.StringVar(&outcome, "outcome", "",
"Filter by outcome (comma-separated: success,failure,...)")
flags.StringVar(&healthGrade, "health-grade", "",
"Filter by health grade (comma-separated: A,B,C,D,F)")
flags.IntVar(&minToolFailures, "min-tool-failures", 0,
"Minimum tool-failure signal count (0 is a valid filter)")
flags.BoolVar(&hasSecret, "has-secret", false,
"Only sessions with detected secret leaks")
flags.StringVar(&cursor, "cursor", "",
"Pagination cursor from a previous response")
flags.IntVar(&limit, "limit", 0,
fmt.Sprintf(
"Maximum sessions to return (default %d, max %d)",
db.DefaultSessionLimit, db.MaxSessionLimit,
))
flags.StringVar(&sort, "sort", "recent",
"Sort by a comma-separated list of keys, each optionally key:asc or "+
"key:desc (e.g. messages:desc,started:asc). Keys: "+
strings.Join(db.SortKeys(), ", "))
flags.BoolVarP(&reverse, "reverse", "r", false,
"Reverse the natural direction of sort keys that have no explicit "+
":asc/:desc suffix")
flags.BoolVar(&resume, "resume", false,
fmt.Sprintf("Show only sessions active within the last %d minutes, "+
"newest first, for quick resume",
int(resumeActiveWindow.Minutes())))
flags.BoolVar(&active, "active", false,
"Alias for --resume")
return cmd
}
func sessionListDefaultExclusionNotice(
ctx context.Context,
svc service.SessionService,
f service.ListFilter,
visibleTotal int,
) (string, error) {
if f.Cursor != "" || (f.IncludeOneShot && f.IncludeAutomated) {
return "", nil
}
hiddenOneShot := 0
if !f.IncludeOneShot {
withOneShot := sessionListCountFilter(f)
withOneShot.IncludeOneShot = true
withOneShot.IncludeAutomated = f.IncludeAutomated
list, err := svc.List(ctx, withOneShot)
if err != nil {
return "", fmt.Errorf(
"counting one-shot session exclusions: %w", err)
}
hiddenOneShot = hiddenSessionCount(list.Total, visibleTotal)
}
hiddenAutomated := 0
if !f.IncludeAutomated {
withAutomated := sessionListCountFilter(f)
withAutomated.IncludeOneShot = f.IncludeOneShot
withAutomated.IncludeAutomated = true
list, err := svc.List(ctx, withAutomated)
if err != nil {
return "", fmt.Errorf(
"counting automated session exclusions: %w", err)
}
hiddenAutomated = hiddenSessionCount(list.Total, visibleTotal)
}
hiddenTotal := hiddenOneShot + hiddenAutomated
if hiddenTotal == 0 {
return "", nil
}
var hiddenParts []string
var flagParts []string
if hiddenOneShot > 0 {
hiddenParts = append(hiddenParts,
fmt.Sprintf("%d one-shot", hiddenOneShot))
flagParts = append(flagParts, "--include-one-shot")
}
if hiddenAutomated > 0 {
hiddenParts = append(hiddenParts,
fmt.Sprintf("%d automated", hiddenAutomated))
flagParts = append(flagParts, "--include-automated")
}
return fmt.Sprintf(
"Excluded %d %s by default: %s. Use %s to include them.",
hiddenTotal,
pluralSession(hiddenTotal),
strings.Join(hiddenParts, ", "),
strings.Join(flagParts, " and/or "),
), nil
}
func sessionListCountFilter(f service.ListFilter) service.ListFilter {
f.Cursor = ""
f.Limit = 1
return f
}
func hiddenSessionCount(expandedTotal, visibleTotal int) int {
if expandedTotal <= visibleTotal {
return 0
}
return expandedTotal - visibleTotal
}
func pluralSession(n int) string {
if n == 1 {
return "session"
}
return "sessions"
}
// sessionNameWidth caps the NAME column so a long first message can't
// push the trailing CWD column off the right edge of the terminal.
const sessionNameWidth = 44
// printSessionListHuman writes a resume-oriented table of the session
// list: an in-flight marker for recently-active sessions, the full session
// ID (the copyable handle for `session get`/`messages`/`usage`), the
// humanized AGE since last activity, AGENT, PROJECT, BRANCH, MSGS, NAME,
// and a ~-collapsed CWD. now and home are passed in so output is
// deterministic under test. A trailing hint is printed when another page is
// available. Prints "(no sessions)" for empty lists. Every session-derived
// string is run through sanitizeTerminal so untrusted DB rows cannot drive
// terminal escape sequences.
func printSessionListHuman(
w io.Writer, list *service.SessionList, now time.Time, home string,
) error {
if len(list.Sessions) == 0 {
fmt.Fprintln(w, "(no sessions)")
return nil
}
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "\tID\tAGE\tAGENT\tPROJECT\tBRANCH\tMSGS\tNAME\tCWD")
for _, s := range list.Sessions {
marker := ""
if isSessionRecentlyActive(s, now) {
marker = activeMarker
}
name := truncName(sessionDisplayName(s), sessionNameWidth)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
marker,
sanitizeTerminal(s.ID),
humanizeSessionAge(s, now),
sanitizeTerminal(orEmDash(s.Agent)),
sanitizeTerminal(orEmDash(s.Project)),
sanitizeTerminal(orEmDash(s.GitBranch)),
s.MessageCount,
sanitizeTerminal(name),
sanitizeTerminal(collapseHome(s.Cwd, home)),
)
}
if err := tw.Flush(); err != nil {
return err
}
if list.NextCursor != "" {
// Cursor is an opaque server-minted string. Sanitize too
// so a malicious DB row can't feed escapes through a hint.
fmt.Fprintf(w, "\nMore results: --cursor %s\n",
sanitizeTerminal(list.NextCursor))
}
return nil
}