diff --git a/internal/claude/claude.go b/internal/claude/claude.go
index 6c0b55b..0ca422b 100644
--- a/internal/claude/claude.go
+++ b/internal/claude/claude.go
@@ -17,8 +17,9 @@
//
// Ignored by default: tool_use, successful tool_result, thinking, queue/mode/
// permission bookkeeping, file-history snapshots, last-prompt, subagent
-// (isSidechain) and injected (isMeta) entries, subagent files under
-// */subagents/*, and .claude/transcripts/ses_*.jsonl (v1).
+// (isSidechain) and injected (isMeta) entries, user records the harness wrote
+// itself (see harnessWrappers), subagent files under */subagents/*, and
+// .claude/transcripts/ses_*.jsonl (v1).
package claude
import (
@@ -289,6 +290,9 @@ func readThread(fi fileInfo) (session.Thread, error) {
if role == "" {
continue
}
+ if role == session.RoleUser && harnessOnly(text) {
+ continue // a user record the harness wrote itself, not a turn
+ }
entries = append(entries, session.Entry{Kind: session.KindMessage, Role: role, Text: text, Time: ts})
}
@@ -367,6 +371,66 @@ func joinText(blocks []claudeBlock, raw json.RawMessage) string {
return strings.Join(parts, "\n")
}
+// harnessWrappers are the XML wrappers Claude Code writes into type:"user"
+// records that are neither the user's words nor the user's act: a background
+// task's report, what a local command printed, its caveat, and an injected
+// reminder. The wrappers that record something the user did — the
+// slash-command trio, a ! bash line — stay on the timeline, however noisy,
+// because their text is the request.
+//
+// Only these names, and only spelled in full: markup a user pasted (
,
+//
) is a turn they meant to send, and is
+// not .
+var harnessWrappers = map[string]bool{
+ "local-command-caveat": true, "local-command-stdout": true,
+ "system-reminder": true, "task-notification": true,
+}
+
+// harnessOnly reports whether a user message is nothing but harness wrappers.
+// One record can carry several in a row. A wrapper with no closing tag is not
+// one the harness wrote, so the record is kept.
+//
+// Nothing but: a record that still says something once its wrappers are read
+// off is kept whole, text and wrappers together. Every injected record in the
+// corpus this was measured against is wrapper-only, so trimming a mixed one
+// would buy no quiet while risking the words of a user whose message happens to
+// open with or a pasted reminder.
+func harnessOnly(text string) bool {
+ rest, seen := strings.TrimSpace(text), false
+ for {
+ name, ok := openTag(rest)
+ if !ok || !harnessWrappers[name] {
+ return seen && rest == ""
+ }
+ seen = true
+ _, end := session.IndexFold(rest, ""+name+">")
+ if end < 0 {
+ return false
+ }
+ rest = strings.TrimSpace(rest[end:])
+ }
+}
+
+// openTag returns the lowercased name of a bare element opening s.
+// Anything else — attributes, a closing tag, a name with punctuation — is not
+// one of the wrappers, which the harness always writes bare.
+func openTag(s string) (string, bool) {
+ if !strings.HasPrefix(s, "<") {
+ return "", false
+ }
+ end := strings.IndexByte(s, '>')
+ if end < 2 {
+ return "", false
+ }
+ name := s[1:end]
+ for _, r := range name {
+ if r != '-' && r != '_' && !('0' <= r && r <= '9') && !('a' <= r && r <= 'z') && !('A' <= r && r <= 'Z') {
+ return "", false
+ }
+ }
+ return strings.ToLower(name), true
+}
+
func normalizeRole(role string) string {
switch role {
case session.RoleUser, session.RoleAssistant:
diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go
index 68ce209..27b8a3e 100644
--- a/internal/claude/claude_test.go
+++ b/internal/claude/claude_test.go
@@ -126,6 +126,79 @@ func TestCwdIsWhereTheSessionStarted(t *testing.T) {
}
}
+// The harness files records of its own under role user: a background task's
+// report, what a local command printed, reminders. Every record below is
+// synthetic. The ones that keep their text are the reason the rule is a name
+// list rather than "starts with a tag" — and why a slash command and a ! bash
+// line, which are things the user did, stay on the timeline.
+const harnessTranscript = `{"type":"user","sessionId":"sess-h","cwd":"/home/u/src/catchup","timestamp":"2026-06-26T10:00:00Z","message":{"role":"user","content":"land the parser fix"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:10Z","message":{"role":"user","content":"t1completedthe agent reported back"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:20Z","message":{"role":"user","content":"/review\nreview\nthe parser branch"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:30Z","message":{"role":"user","content":"branch is clean"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:40Z","message":{"role":"user","content":"ls internal"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:00:50Z","message":{"role":"user","content":"claude\nsession"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:00Z","message":{"role":"user","content":"\n\n mind the budget"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:10Z","message":{"role":"user","content":[{"type":"text","text":"the command was run locally"}]}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:20Z","message":{"role":"user","content":"cut short"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:30Z","message":{"role":"user","content":"be brief\n\nwhy did the build fail?"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:40Z","message":{"role":"user","content":[{"type":"text","text":"/mine is my own tag, keep it"}]}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:01:50Z","message":{"role":"user","content":"hello
why does this not render?"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:02:00Z","message":{"role":"user","content":"my own notes"}}
+{"type":"user","sessionId":"sess-h","timestamp":"2026-06-26T10:02:10Z","message":{"role":"user","content":"the log shows after every run"}}
+{"type":"assistant","sessionId":"sess-h","timestamp":"2026-06-26T10:02:20Z","message":{"role":"assistant","content":[{"type":"text","text":"reading it now"}]}}
+`
+
+func TestHarnessWrittenUserRecordsDropped(t *testing.T) {
+ root := t.TempDir()
+ writeTranscript(t, root, "-home-u-src-catchup", "sess-h", harnessTranscript, time.Now())
+
+ p := New()
+ roots := session.Roots{Claude: root}
+ src, err := p.Resolve(context.Background(), roots, "sess-h")
+ if err != nil {
+ t.Fatal(err)
+ }
+ th, err := p.Read(context.Background(), src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "land the parser fix",
+ // A slash command and a ! bash line are things the user did: the args
+ // and the command are the request, so both records stay.
+ "/review\nreview\nthe parser branch",
+ "ls internal",
+ "claude\nsession",
+ // A wrapper that never closes is not the harness's spelling: kept.
+ "cut short",
+ "be brief\n\nwhy did the build fail?", // mixed: kept whole
+ "/mine is my own tag, keep it",
+ "hello
why does this not render?",
+ "my own notes",
+ "the log shows after every run",
+ "reading it now",
+ }
+ var got []string
+ for _, e := range th.Entries {
+ got = append(got, e.Text)
+ }
+ if len(got) != len(want) {
+ t.Fatalf("got %d entries, want %d: %q", len(got), len(want), got)
+ }
+ for i, w := range want {
+ if got[i] != w {
+ t.Errorf("entry %d = %q, want %q", i, got[i], w)
+ }
+ }
+ // A dropped record's text is searchable nowhere: not in a query, not in a row.
+ for _, gone := range []string{"the agent reported back", "branch is clean",
+ "mind the budget", "run locally"} {
+ if strings.Contains(th.VisibleText(), gone) {
+ t.Errorf("harness text %q survived into the timeline", gone)
+ }
+ }
+}
+
func TestListSkipsSubagents(t *testing.T) {
root := t.TempDir()
writeTranscript(t, root, "-proj", "main-1", transcript, time.Now())