From b7f07abcddc652be67a4117f725554d4131879fa Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 20:34:14 -0500 Subject: [PATCH 1/3] fix: Stop naming sessions "/clear" and refresh stale harvested titles A session that starts with /clear and stays idle gets the title "/clear". Claude Code writes the /clear turn as the first user turn of the new transcript. The harvester changed the command envelope to "/clear" and used it as the prompt title. The title then stayed. The sessions pane started a harvest only for rows with no harvested title. The prompt and the ai-title that came later did not reach the TITLE column until a restart or a picker visit. Two changes: - promptCandidate rejects the /clear command. This covers the last-prompt record and all user-turn tiers. A session with only the /clear turn now falls through to its cwd. - untitledSettled also counts a named live row when its transcript is newer than the cached LogModTime. The harvest is incremental, so it reads only that file again. A refresh that names nothing new counts as a miss, so the backoff limits how often an active session is read. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/tui/session_metadata.go | 25 +++++++++++++-- cmd/agentop/tui/sessions_title_test.go | 36 +++++++++++++++++++++ core/observe/claude/harvest_test.go | 44 ++++++++++++++++++++++++-- core/observe/claude/prompt.go | 10 +++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/cmd/agentop/tui/session_metadata.go b/cmd/agentop/tui/session_metadata.go index dc8bb658c..6c8b4f2ce 100644 --- a/cmd/agentop/tui/session_metadata.go +++ b/cmd/agentop/tui/session_metadata.go @@ -240,8 +240,9 @@ func awaitsHarvest(s session.SessionSummary) bool { return s.Resident == nil || // this cheap: without it every event on a still-unnamed session would trigger a scan, and // with it a busy session is harvested once, after it pauses. // -// Only sessions the metadata does NOT name are considered, so a fully-harvested list triggers -// nothing at all. +// Only sessions the metadata does NOT name are considered, plus named sessions whose transcript +// moved since the harvest that named them (see transcriptMoved). A fully-harvested list whose +// transcripts are unchanged triggers nothing at all. // // "FULLY HARVESTED" IS NOT "EVERY ROW NAMED ON SCREEN", and this comment used to conflate the two. // sessionHasTitle asks about the HARVEST only — sessionTitleFor's served-title fallback is @@ -272,7 +273,7 @@ func awaitsHarvest(s session.SessionSummary) bool { return s.Resident == nil || // comment; the optimisation waits for a profile that asks for it. func (m *model) untitledSettled(now time.Time) bool { for _, s := range m.sessions { - if !awaitsHarvest(s) || m.sessionHasTitle(s.ID) { + if !awaitsHarvest(s) || (m.sessionHasTitle(s.ID) && !m.transcriptMoved(s.ID)) { continue } // A ZERO UpdatedAt IS NOT "QUIET SINCE THE EPOCH". The field is whatever /v1/sessions @@ -311,6 +312,24 @@ func (m *model) untitledSettled(now time.Time) bool { return false } +// transcriptMoved reports whether the transcript a harvested title came from changed after the +// harvest that read it. A NAMED row needs this to be read again: the title it has can be the cwd +// fallback or an early prompt — a session started by /clear and left idle is named by its cwd — +// and the transcript can since have grown a better one. Without it, a named row kept its first +// title for as long as agentop ran. +// +// One Stat per named live row per tick, on the rows awaitsHarvest admits. A harvest that only +// refreshes a named row names nothing NEW, so it counts as a miss and widens the backoff: an active +// session is re-read at most once per untitledBackoff, not once per pause. +func (m *model) transcriptMoved(id string) bool { + meta := m.sessionsData[id] + if meta.LogFile == "" { + return false + } + st, err := os.Stat(meta.LogFile) + return err == nil && st.ModTime().After(meta.LogModTime) +} + // untitledFresh reports whether some unnamed row on screen was NOT counted against the backoff // yet — a session that appeared since the last harvest was scored. // diff --git a/cmd/agentop/tui/sessions_title_test.go b/cmd/agentop/tui/sessions_title_test.go index 9e674788c..2fb554a87 100644 --- a/cmd/agentop/tui/sessions_title_test.go +++ b/cmd/agentop/tui/sessions_title_test.go @@ -1661,6 +1661,42 @@ func TestUntitledSettled_BlankAndUnknownRows(t *testing.T) { } } +// TestUntitledSettled_NamedRowWhoseTranscriptMoved pins the re-read of a NAMED row. +// +// A session started by /clear and left idle is named by its cwd. Before this, a named row was never +// read again while agentop ran, so the prompt and ai-title that came later never reached the column. +func TestUntitledSettled_NamedRowWhoseTranscriptMoved(t *testing.T) { + settled := time.Now().Add(-2 * untitledSettleDelay) + log := filepath.Join(t.TempDir(), "s1.jsonl") + if err := os.WriteFile(log, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + harvested := time.Now().Add(-time.Hour) + if err := os.Chtimes(log, harvested, harvested); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + meta SessionMetadata + want bool + }{ + {"unchanged since the harvest", SessionMetadata{Title: "/w/repo", LogFile: log, LogModTime: harvested}, false}, + {"changed since the harvest", SessionMetadata{Title: "/w/repo", LogFile: log, LogModTime: harvested.Add(-time.Minute)}, true}, + {"no transcript recorded", SessionMetadata{Title: "/w/repo"}, false}, + {"transcript gone", SessionMetadata{Title: "/w/repo", LogFile: log + ".gone"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newTitleModel(t, map[string]SessionMetadata{"s1": tc.meta}, "s1") + m.sessions = []session.SessionSummary{{ID: "s1", UpdatedAt: settled}} + if got := m.untitledSettled(time.Now()); got != tc.want { + t.Errorf("untitledSettled = %v, want %v", got, tc.want) + } + }) + } +} + // TestHarvestNamedSomething_JudgesOnlyRowsOnScreen pins the backoff's notion of progress. // // Two ways to get this wrong, and the function had the second one. len(meta) > 0 is true on every diff --git a/core/observe/claude/harvest_test.go b/core/observe/claude/harvest_test.go index 1bedf998b..d27c5e7d2 100644 --- a/core/observe/claude/harvest_test.go +++ b/core/observe/claude/harvest_test.go @@ -1262,8 +1262,8 @@ func TestTitleFromTranscript_UnwrapsSlashCommands(t *testing.T) { }, { "name only", - `clear\n/clear\n`, - "/clear", + `review\n/review\n`, + "/review", }, { "ordinary prose is untouched", @@ -1290,6 +1290,46 @@ func TestTitleFromTranscript_UnwrapsSlashCommands(t *testing.T) { } } +// /clear never names a session. Claude Code writes it as the first user turn of the transcript it +// starts, so a session left idle after /clear was titled "/clear". +func TestTitleFromTranscript_ClearNamesNothing(t *testing.T) { + const clear = `/clear\n clear\n ` + user := func(content string) string { + body, err := json.Marshal(strings.ReplaceAll(content, `\n`, "\n")) + if err != nil { + t.Fatal(err) + } + return `{"type":"user","cwd":"/w/repo","message":{"role":"user","content":` + string(body) + `}}` + } + human := func(line string) string { + return strings.Replace(line, `"type":"user",`, `"type":"user","origin":{"kind":"human"},`, 1) + } + for _, tc := range []struct { + name string + lines []string + want string + }{ + {"alone falls through to the cwd", []string{user(clear)}, "/w/repo"}, + {"attributed human turn", []string{human(user(clear))}, "/w/repo"}, + {"last-prompt record", []string{user(clear), `{"type":"last-prompt","lastPrompt":"/clear"}`}, "/w/repo"}, + {"the prompt after it wins", []string{user(clear), user("fix the title bug")}, "fix the title bug"}, + {"a prompt before it is kept", []string{user("fix the title bug"), user(clear)}, "fix the title bug"}, + {"a longer command name is not /clear", []string{user(`/clearcache`)}, "/clearcache"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeSessionTranscript(t, dir, "s.jsonl", tc.lines...) + got, err := titleFromTranscript(filepath.Join(dir, "s.jsonl")) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("title = %q, want %q", got, tc.want) + } + }) + } +} + // The cwd fallback is NOT clipped, so sibling directories stay distinguishable. // // A path's distinguishing end is its leaf and clipTitle keeps the head, so two worktrees under a diff --git a/core/observe/claude/prompt.go b/core/observe/claude/prompt.go index 0fa1ef0db..92f3f7c79 100644 --- a/core/observe/claude/prompt.go +++ b/core/observe/claude/prompt.go @@ -477,12 +477,20 @@ func promptCandidate(text string) (string, bool) { candidate = stripWrapperTag(text) } candidate = cutTrailingHarness(candidate) - if isSyntheticPrompt(candidate) { + if isSyntheticPrompt(candidate) || isClear(candidate) { return "", false } return candidate, true } +// isClear reports whether s is the /clear command. /clear resets the session, so it names nothing: +// Claude Code writes it as the first user turn of the NEW transcript, before the user asks anything, +// and a session left idle after it was titled "/clear". +func isClear(s string) bool { + f := strings.Fields(s) + return len(f) > 0 && f[0] == "/clear" +} + // isSyntheticPrompt reports whether s READS AS markup Claude Code inserted rather than as something // the user typed. // From 42d3f40fb62f2dafe181083efec47c021acdb618 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 20:49:24 -0500 Subject: [PATCH 2/3] test: Pin that a re-read of a named row counts as a miss An active session's transcript changes on every turn. A re-read of its named row names nothing new, so the harvestedMsg handler counts it as a miss and the backoff widens. No test checked this. Without it, every pause in an active session could start a harvest at the 5s floor. The test also checks that no harvest starts after the re-read records the transcript's current mtime. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/tui/sessions_title_test.go | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmd/agentop/tui/sessions_title_test.go b/cmd/agentop/tui/sessions_title_test.go index 2fb554a87..f2b1950e5 100644 --- a/cmd/agentop/tui/sessions_title_test.go +++ b/cmd/agentop/tui/sessions_title_test.go @@ -1697,6 +1697,45 @@ func TestUntitledSettled_NamedRowWhoseTranscriptMoved(t *testing.T) { } } +// TestSessionsPane_RefreshOfNamedRowCountsAsMiss pins the limit on re-reads of a NAMED row. +// +// An active session's transcript moves on every turn. If a re-read that names nothing new did not +// count as a miss, every pause in that session would start a harvest at the 5s floor. +func TestSessionsPane_RefreshOfNamedRowCountsAsMiss(t *testing.T) { + log := filepath.Join(t.TempDir(), "s1.jsonl") + if err := os.WriteFile(log, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + st, err := os.Stat(log) + if err != nil { + t.Fatal(err) + } + moved := SessionMetadata{Title: "/w/repo", LogFile: log, LogModTime: st.ModTime().Add(-time.Minute)} + m := newTitleModel(t, map[string]SessionMetadata{"s1": moved}, "s1") + m.sessions = []session.SessionSummary{{ID: "s1", UpdatedAt: time.Now().Add(-time.Hour)}} + m.harvest = func() (map[string]SessionMetadata, error) { return nil, nil } + + m.lastHarvest = time.Now().Add(-untitledBackoffCap) + m.Update(refreshTickMsg(time.Now())) + if !m.harvesting { + t.Fatal("no harvest started for a named row whose transcript moved") + } + // The re-read gives the same title and the transcript's current mtime. + m.Update(harvestedMsg{meta: map[string]SessionMetadata{ + "s1": {Title: "/w/repo", LogFile: log, LogModTime: st.ModTime()}, + }}) + if m.untitledMisses != 1 { + t.Errorf("a re-read that named nothing new left untitledMisses = %d, want 1", m.untitledMisses) + } + + // The transcript has not moved since that re-read, so no harvest starts. + m.lastHarvest = time.Now().Add(-untitledBackoffCap) + m.Update(refreshTickMsg(time.Now())) + if m.harvesting { + t.Error("harvested again although the transcript did not move") + } +} + // TestHarvestNamedSomething_JudgesOnlyRowsOnScreen pins the backoff's notion of progress. // // Two ways to get this wrong, and the function had the second one. len(meta) > 0 is true on every From 6b67e7e1dfdc7267bdefad65a40f99833cced81d Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 21:37:06 -0500 Subject: [PATCH 3/3] fix: Re-parse a cached "/clear" title during the incremental harvest Fixes review: a cached "/clear" title on an idle transcript was kept by the mtime skip Files: - core/observe/claude/harvest.go - core/observe/claude/harvest_test.go Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/observe/claude/harvest.go | 3 +- core/observe/claude/harvest_test.go | 43 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/core/observe/claude/harvest.go b/core/observe/claude/harvest.go index 8c4a07fc5..21c4083f8 100644 --- a/core/observe/claude/harvest.go +++ b/core/observe/claude/harvest.go @@ -450,7 +450,8 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin // int64 per entry, and the Stat here already has it. Deliberately not done in this // change: it is a second on-disk field for a case the explicit subcommand already // answers, and the field added here needs to prove itself first. - if since != nil && !st.ModTime().After(harvestedAt(since[id], path)) { + // A "/clear" title came from an older build; its idle transcript never moves, so re-parse it. + if since != nil && !st.ModTime().After(harvestedAt(since[id], path)) && !isClear(since[id].Title) { // A skipped transcript still WON this id: the entry being kept from `since` // carries its title. So claim it, and drop any weaker claim an older // duplicate already staked in an earlier directory — ReadDir is diff --git a/core/observe/claude/harvest_test.go b/core/observe/claude/harvest_test.go index d27c5e7d2..1758ffefd 100644 --- a/core/observe/claude/harvest_test.go +++ b/core/observe/claude/harvest_test.go @@ -3090,3 +3090,46 @@ func TestHarvest_SessionWithoutSubagentsHasNoMap(t *testing.T) { t.Errorf("Subagents = %v, want nil", got) } } + +// An entry an older build titled "/clear" is re-parsed even when its transcript has not moved: +// a session left idle after /clear never writes again, so the mtime skip would keep the title. +func TestHarvest_IncrementalReparsesAClearTitle(t *testing.T) { + for _, title := range []string{"/clear", "/clear my-feature"} { + t.Run(title, func(t *testing.T) { + metadataHome(t) + cfg := filepath.Join(t.TempDir(), "claude") + proj := filepath.Join(cfg, "projects", "-p") + log := filepath.Join(proj, "s1.jsonl") + writeSessionTranscript(t, proj, "s1.jsonl", `{"type":"ai-title","aiTitle":"current"}`) + touch(t, log, time.Now().Add(-time.Hour)) + + path, err := SessionMetadataPath() + if err != nil { + t.Fatal(err) + } + old, err := json.Marshal(map[string]SessionMetadata{"s1": { + Title: title, AgentType: "Claude Code", LogFile: log, LogModTime: time.Now(), + }}) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, old, 0o600); err != nil { + t.Fatal(err) + } + + res, err := Harvest(Options{ConfigDir: cfg, Merge: true, Incremental: true}) + if err != nil { + t.Fatal(err) + } + if res.Harvested != 1 || res.Skipped != 0 { + t.Errorf("harvested/skipped = %d/%d, want 1/0", res.Harvested, res.Skipped) + } + if got := readMetadataFile(t, res.Path)["s1"].Title; got != "current" { + t.Errorf("title = %q, want %q", got, "current") + } + }) + } +}