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
5 changes: 4 additions & 1 deletion cmd/aimux/cmd/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func newSessionsCmd(discover sessionsDiscoverFn, picker sessionsPickerFn, resume
if s.IsSubagent {
continue
}
if s.Automated && !includeAutomated && !exportMode {
continue // cron/SDK runs hidden when browsing; exports stay complete
}
filtered = append(filtered, s)
}

Expand Down Expand Up @@ -145,7 +148,7 @@ func newSessionsCmd(discover sessionsDiscoverFn, picker sessionsPickerFn, resume
cmd.Flags().IntVar(&limit, "limit", 0, "Max sessions to show (0 = all)")
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields: id,provider,project,age,turns,cost,annotation,prompt,tags")
cmd.Flags().StringVar(&mode, "mode", "hybrid", "Query ranking: hybrid, keyword or semantic")
cmd.Flags().BoolVar(&includeAutomated, "include-automated", false, "Include automated (SDK/cron) sessions in query results")
cmd.Flags().BoolVar(&includeAutomated, "include-automated", false, "Include automated (SDK/cron) sessions")
cmd.AddCommand(newSessionsStarCmd(discover))
cmd.AddCommand(newSessionsIndexCmd())
cmd.AddCommand(newSessionsRowsCmd(), newSessionsPreviewCmd(), newSessionsPickerToggleCmd())
Expand Down
45 changes: 45 additions & 0 deletions cmd/aimux/cmd/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -178,3 +179,47 @@ func TestSessionsCmd_Export(t *testing.T) {
t.Errorf("expected 2 JSONL lines, got %d", len(lines))
}
}

func TestSessionsList_HidesAutomatedUnlessAsked(t *testing.T) {
sessions := fakeSessions()
sessions[1].Automated = true
run := func(args ...string) string {
var stdout bytes.Buffer
c := newSessionsCmd(func(history.DiscoverOpts, string) ([]history.Session, error) { return sessions, nil }, nil, nil)
rootCmd.SetOut(&stdout)
rootCmd.SetErr(&bytes.Buffer{})
rootCmd.SetArgs(append([]string{"sessions", "--list", "--all"}, args...))
rootCmd.AddCommand(c)
defer rootCmd.RemoveCommand(c)
if err := rootCmd.Execute(); err != nil {
t.Fatal(err)
}
return stdout.String()
}
jsonOutput = true
defer func() { jsonOutput = false }()
if out := run(); strings.Contains(out, "sess-002") || !strings.Contains(out, "sess-001") {
t.Errorf("default list should hide the automated session:\n%s", out)
}
if out := run("--include-automated"); !strings.Contains(out, "sess-002") {
t.Errorf("--include-automated should show it:\n%s", out)
}
}

func TestSessionsExport_KeepsAutomatedSessions(t *testing.T) {
sessions := fakeSessions()
sessions[1].Automated = true
var stdout bytes.Buffer
c := newSessionsCmd(func(history.DiscoverOpts, string) ([]history.Session, error) { return sessions, nil }, nil, nil)
rootCmd.SetOut(&stdout)
rootCmd.SetErr(&bytes.Buffer{})
rootCmd.SetArgs([]string{"sessions", "--export", "--all"})
rootCmd.AddCommand(c)
defer rootCmd.RemoveCommand(c)
if err := rootCmd.Execute(); err != nil {
t.Fatal(err)
}
if !strings.Contains(stdout.String(), "sess-002") {
t.Errorf("--export must keep automated sessions (evaluation pipelines rely on the full set):\n%s", stdout.String())
}
}
26 changes: 22 additions & 4 deletions internal/frontend/tui/views/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -867,18 +867,27 @@ func sessionTitle(s history.Session) string {
// visibleSessions returns sessions matching the current filter, sorted
// by the active sort field.
// Near-empty sessions (1 turn, $0 cost) are hidden unless a filter is active.
// hiddenUntilH is the one rule for what H reveals (subagent and automated
// sessions while not searching); the list and the header count both use it.
func (v *SessionsView) hiddenUntilH(s history.Session, isSearching bool) bool {
return !v.showSubagents && !isSearching && (s.IsSubagent || s.Automated)
}

func (v *SessionsView) visibleSessions() []history.Session {
isSearching := v.filterText != "" || v.contentSearchIDs != nil
var result []history.Session
for _, s := range v.sessions {
if !v.showSubagents && !isSearching && s.IsSubagent {
if v.hiddenUntilH(s, isSearching) {
continue
}
if !isSearching && isHookSession(s) {
// H promises automated sessions: the noise filters below must not
// take them away again.
revealed := v.showSubagents && s.Automated
if !isSearching && !revealed && isHookSession(s) {
continue
}
// Hide near-empty sessions (auto-memory, system operations) unless searching
if !isSearching && s.CostUSD == 0 && s.TurnCount <= 5 {
if !isSearching && !revealed && s.CostUSD == 0 && s.TurnCount <= 5 {
continue
}
// Hide sessions with no timestamps (broken/incomplete files)
Expand Down Expand Up @@ -1071,15 +1080,24 @@ func (v *SessionsView) View() string {
countStr += sessDimStyle.Render(fmt.Sprintf(" (%d total)", len(v.sessions)))
}
if !v.showSubagents {
hiddenCount := 0
hiddenCount, automatedCount := 0, 0
isSearching := v.filterText != "" || v.contentSearchIDs != nil
for _, s := range v.sessions {
if !v.hiddenUntilH(s, isSearching) {
continue
}
if s.IsSubagent {
hiddenCount++
} else {
automatedCount++
}
}
if hiddenCount > 0 {
countStr += sessDimStyle.Render(fmt.Sprintf(" (+%d agent)", hiddenCount))
}
if automatedCount > 0 {
countStr += sessDimStyle.Render(fmt.Sprintf(" (+%d automated, H to show)", automatedCount))
}
}
if !v.showAll && v.currentDir != "" {
countStr += sessDimStyle.Render(" press A for all projects")
Expand Down
46 changes: 46 additions & 0 deletions internal/frontend/tui/views/sessions_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,49 @@ func TestContentSearch_SameQueryDifferentScopeKeepsLatest(t *testing.T) {
t.Errorf("visible = %v, want the latest search's result only", got)
}
}

func TestSessionsList_HidesAutomatedUntilH(t *testing.T) {
v := NewSessionsView()
sessions := rankedSessions()
sessions[1].Automated = true // "middle" is a cron/SDK run
v.SetSessions(sessions)
v.SetSize(160, 40)
if got := visibleIDs(v); len(got) != 2 || got[0] != "recent" || got[1] != "old" {
t.Errorf("visible = %v, want automated session hidden", got)
}
if !strings.Contains(v.View(), "automated") {
t.Error("header should say automated sessions are hidden")
}
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("H")})
if got := visibleIDs(v); len(got) != 3 {
t.Errorf("after H: %v, want all 3", got)
}
}

func TestSessionsList_HiddenCountMatchesWhatIsHidden(t *testing.T) {
v := NewSessionsView()
sessions := rankedSessions()
sessions[1].Automated = true
sessions[1].FirstPrompt = "nightly report token"
v.SetSessions(sessions)
v.SetSize(160, 40)
// a search that shows the automated session must not also call it hidden
v.filterText = "token"
if strings.Contains(v.View(), "automated, H to show") {
t.Error("header counts a visible (matching) automated session as hidden")
}
}

func TestSessionsList_HRevealsAutomatedPastOtherFilters(t *testing.T) {
v := NewSessionsView()
sessions := rankedSessions()
sessions[0].Automated = true
sessions[0].FirstPrompt = "YOU ARE A SESSION ANALYZER. Analyze"
sessions[1].Automated = true
sessions[1].TurnCount, sessions[1].CostUSD = 1, 0 // near-empty
v.SetSessions(sessions)
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("H")})
if got := visibleIDs(v); len(got) != 3 {
t.Errorf("after H: %v, want all 3 (H promises to show automated sessions)", got)
}
}
16 changes: 16 additions & 0 deletions internal/frontend/web/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ func (s *Server) handleDiff(w http.ResponseWriter, r *http.Request) {
}
}

// visibleHistory drops automated (cron/SDK) sessions unless asked for them,
// matching the TUI and search defaults.
func visibleHistory(sessions []history.Session, includeAutomated bool) []history.Session {
if includeAutomated {
return sessions
}
out := sessions[:0:0]
for _, s := range sessions {
if !s.Automated {
out = append(out, s)
}
}
return out
}

func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
dir := r.URL.Query().Get("dir")
opts := history.DiscoverOpts{Dir: dir}
Expand All @@ -311,6 +326,7 @@ func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sessions = visibleHistory(sessions, r.URL.Query().Get("automated") == "1")
if sessions == nil {
sessions = []history.Session{}
}
Expand Down
11 changes: 11 additions & 0 deletions internal/frontend/web/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/zanetworker/aimux/internal/history"
"github.com/zanetworker/aimux/internal/search"
)

Expand Down Expand Up @@ -67,3 +68,13 @@ func TestHandleSearch_ErrorsAndUnconfigured(t *testing.T) {
t.Errorf("unconfigured: code=%d results=%v", code, rs)
}
}

func TestHistoryHidesAutomatedByDefault(t *testing.T) {
all := []history.Session{{ID: "human"}, {ID: "cron", Automated: true}}
if got := visibleHistory(all, false); len(got) != 1 || got[0].ID != "human" {
t.Errorf("default: %v, want only the interactive session", got)
}
if got := visibleHistory(all, true); len(got) != 2 {
t.Errorf("?automated=1: %v, want both", got)
}
}
38 changes: 38 additions & 0 deletions internal/history/automated.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package history

import "strings"

// Automated sessions are runs nobody typed into: `claude -p` from cron and
// scripts, session analyzers, and similar. They usually far outnumber
// interactive sessions, so session lists and search hide them by default.
// This is the single definition both history (lists) and internal/search
// (search) use.

// DefaultAutomatedPrefixes are first-prompt openings of known automated runs.
var DefaultAutomatedPrefixes = []string{
"YOU ARE A SESSION ANALYZER", "Evaluate session", "Tag each library item",
"LINKS ARE MANDATORY", "Read the JSON file", "Run a prompt audit",
}

var tempDirPrefixes = []string{"/private/var/folders/", "/var/folders/", "/tmp/", "/private/tmp/"}

// IsAutomated reports whether a session was started by a program: Claude
// Code records an "sdk-*" entrypoint for SDK and `claude -p` runs; scripts
// often run from a temp directory; and some runs are recognisable by their
// first prompt.
func IsAutomated(entrypoint, cwd, firstPrompt string, prefixes []string) bool {
if strings.HasPrefix(entrypoint, "sdk") {
return true
}
for _, p := range tempDirPrefixes {
if strings.HasPrefix(cwd, p) {
return true
}
}
for _, p := range prefixes {
if p != "" && strings.HasPrefix(firstPrompt, p) {
return true
}
}
return false
}
84 changes: 84 additions & 0 deletions internal/history/automated_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package history

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func TestIsAutomated(t *testing.T) {
cases := []struct {
name string
entrypoint, cwd, firstPrompt string
want bool
}{
{"sdk entrypoint (claude -p, cron)", "sdk-cli", "/Users/me/tools", "summarize", true},
{"temp dir cwd", "cli", "/private/var/folders/y2/T/tmp.x", "hello", true},
{"known prompt prefix", "cli", "/Users/me/r", "Read the JSON file /var/folders/a.json", true},
{"interactive session", "cli", "/Users/me/research", "why does openshell need service accounts", false},
{"unknown entrypoint, normal cwd", "", "/Users/me/research", "hello", false},
}
for _, c := range cases {
if got := IsAutomated(c.entrypoint, c.cwd, c.firstPrompt, DefaultAutomatedPrefixes); got != c.want {
t.Errorf("%s: IsAutomated = %v, want %v", c.name, got, c.want)
}
}
if IsAutomated("cli", "/Users/me", "Read the JSON file x", nil) {
t.Error("with no prefixes configured, prompt text alone must not mark a session automated")
}
}

func writeLines(t *testing.T, dir, id string, lines ...map[string]any) string {
t.Helper()
var b strings.Builder
for _, l := range lines {
j, _ := json.Marshal(l)
b.Write(j)
b.WriteByte('\n')
}
p := filepath.Join(dir, id+".jsonl")
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
t.Fatal(err)
}
return p
}

func userLine(entrypoint, cwd, text string) map[string]any {
return map[string]any{
"type": "user", "entrypoint": entrypoint, "cwd": cwd, "timestamp": "2026-10-09T10:00:00Z",
"message": map[string]any{"role": "user", "content": text},
}
}

func TestScanSession_SetsAutomated(t *testing.T) {
dir := t.TempDir()
auto, err := scanSession("a", writeLines(t, dir, "a", userLine("sdk-cli", "/Users/me/tools", "Read the JSON file /var/folders/x")), "/p")
if err != nil {
t.Fatal(err)
}
if !auto.Automated {
t.Error("sdk-cli session not marked automated")
}
human, _ := scanSession("h", writeLines(t, dir, "h", userLine("cli", "/Users/me/research", "is there a way to get my clipboard back")), "/p")
if human.Automated {
t.Error("interactive session marked automated")
}
}

func TestScanSession_FindsFirstPromptPastLineTen(t *testing.T) {
dir := t.TempDir()
var lines []map[string]any
for i := 0; i < 12; i++ { // metadata lines before any human message
lines = append(lines, map[string]any{"type": "system", "cwd": "/Users/me/r", "entrypoint": "cli"})
}
lines = append(lines, userLine("cli", "/Users/me/r", "YOU ARE A SESSION ANALYZER. Analyze this"))
s, err := scanSession("late", writeLines(t, dir, "late", lines...), "/p")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(s.FirstPrompt, "YOU ARE A SESSION ANALYZER") || !s.Automated {
t.Errorf("FirstPrompt=%q Automated=%v; a first prompt after line 10 must still be found and classified", s.FirstPrompt, s.Automated)
}
}
Loading
Loading