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..f2b1950e5 100644
--- a/cmd/agentop/tui/sessions_title_test.go
+++ b/cmd/agentop/tui/sessions_title_test.go
@@ -1661,6 +1661,81 @@ 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)
+ }
+ })
+ }
+}
+
+// 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
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 1bedf998b..1758ffefd 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
@@ -3050,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")
+ }
+ })
+ }
+}
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.
//