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
334 lines
11 KiB
Go
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
|
|
}
|