1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
//! Per-session head/tail summarization + identity capture.
use super::*;
/// Build a [`SessionSummary`] for one session file via HEAD + TAIL reads only.
pub fn summarize_session(path: &Path) -> Result<SessionSummary> {
// The session id is authoritatively the jsonl basename (== uuid; verified the
// env var CLAUDE_CODE_SESSION_ID equals it). For a SUBAGENT transcript the stem is
// `agent-<hex>`; the shared helper strips the prefix to the bare-hex canonical id
// (the record `agentId`, what `agents` prints) so a `list` subagent row is joinable.
let session_id = crate::subagent::session_id_from_path(path);
// ── HEAD read: first genuine-user message + identity fields ──
let mut first_user: Option<MessagePreview> = None;
let mut cwd: Option<String> = None;
let mut version: Option<String> = None;
let mut git_branch: Option<String> = None;
let mut data_session_id: Option<String> = None;
let (head_skipped, head_consumed) =
head_records_prefiltered(path, line_is_list_candidate, |rec| {
// First user message = a genuine human turn, an answered AskUserQuestion, or a
// tool-use rejection-with-message (§4.1/§4.4/§4.2.4). No PlanIndex in this
// single-record head scan, so a rejection surfaces its typed instruction without
// the `[plan: …]` pointer (the pointer is a turns/search affordance). A
// `<task-notification>` / inbound `<teammate-message>` renders its clean label /
// inbound-comm form via `preview_text` rather than the raw XML it used to show.
if let Some(text) = preview_text(rec) {
// Capture identity off the first user record (it carries cwd / version /
// gitBranch / sessionId in real data).
cwd = rec.cwd.clone();
version = rec.version.clone();
git_branch = rec.git_branch.clone();
data_session_id = rec.session_id.clone();
first_user = Some(MessagePreview::from(rec.timestamp.clone(), &text));
return false; // stop the head scan
}
true
})?;
// ── TAIL read: last genuine-user + last agent message (newest-first) ──
let mut last_user: Option<MessagePreview> = None;
let mut last_agent: Option<MessagePreview> = None;
// LAST-seen identity: the tail walks newest-first, so the first version/branch-
// bearing record it visits IS the newest one. Costs nothing - the same records
// are already being read for last_user/last_agent.
let mut version_last: Option<String> = None;
let mut git_branch_last: Option<String> = None;
// `head_consumed` as the floor keeps the two windows DISJOINT: a malformed line is
// counted exactly once (R12 killed the head+tail double-book on files where both
// scans used to walk the same region).
let tail_skipped =
tail_records_prefiltered(path, line_is_list_candidate, head_consumed, |rec| {
if version_last.is_none() {
version_last = rec.version.clone();
}
if git_branch_last.is_none() {
git_branch_last = rec.git_branch.clone();
}
if last_agent.is_none() {
if let Some(text) = rec.agent_text() {
last_agent = Some(MessagePreview::from(rec.timestamp.clone(), &text));
}
}
if last_user.is_none() {
if let Some(text) = preview_text(rec) {
last_user = Some(MessagePreview::from(rec.timestamp.clone(), &text));
// Backfill identity from the tail if the head never found a genuine
// user (e.g. a session whose only user turns are near the end).
capture_identity_if_empty(
rec,
&mut cwd,
&mut version,
&mut git_branch,
&mut data_session_id,
);
}
}
last_user.is_none() || last_agent.is_none()
})?;
// Prefer the filename-derived id; cross-check with the data id (§2.4 spirit).
let session_id = if session_id.is_empty() {
data_session_id.unwrap_or_default()
} else {
session_id
};
// Id-domain discriminator: a subagent transcript's `session_id` is a non-re-feedable
// bare hex; carry `is_subagent` + the re-feedable parent uuid (the dir before
// `subagents/`) so a `list` consumer can distinguish + re-feed. A top-level file is its
// own parent (the same r5 shape `search`/`files`/`turns`/`recover` carry).
let is_subagent = crate::subagent::is_subagent_path(path);
let parent_session_id =
crate::subagent::parent_session_id_from_path(path).unwrap_or_else(|| session_id.clone());
// ── Transparent elicitation-sidecar merge (§3.10) ──
// A TOP-LEVEL session's unresolved-pending elicitations (the latest activity, MISSING from
// the native transcript) annotate the row with `with elicitation sidecar` + the pending
// kind. A subagent transcript has no sidecar (keyed by the top-level session). The sidecar
// is tiny → a plain read; its malformed-line count folds into `skipped_lines` (never silent).
let mut sidecar_skipped = 0usize;
let pending_elicitations = if is_subagent {
Vec::new()
} else {
let (pending, skipped) = crate::elicitation::unresolved_pending(path)?;
sidecar_skipped = skipped;
pending
.iter()
.filter_map(crate::elicitation::pending_text)
.collect()
};
// File-existence = hook-installed evidence (resolved pairs stay in the file), the
// machine-legible third state beside "pending" and "none pending".
let sidecar_present =
!is_subagent && crate::elicitation::sidecar_path(path).is_some_and(|p| p.is_file());
Ok(SessionSummary {
session_id,
is_subagent,
parent_session_id,
path: path.to_path_buf(),
cwd,
// The base fields are LAST-seen (what the session is on NOW); the head
// capture becomes the *_first pair. Either window can be empty - fall back
// to the other so a one-record session reports the same value everywhere.
version: version_last.clone().or_else(|| version.clone()),
version_first: version.or(version_last),
git_branch: git_branch_last.clone().or_else(|| git_branch.clone()),
git_branch_first: git_branch.or(git_branch_last),
first_user,
last_user,
last_agent,
skipped_lines: head_skipped + tail_skipped + sidecar_skipped,
pending_elicitations,
sidecar_present,
})
}
pub(crate) fn capture_identity_if_empty(
rec: &Record,
cwd: &mut Option<String>,
version: &mut Option<String>,
git_branch: &mut Option<String>,
data_session_id: &mut Option<String>,
) {
if cwd.is_none() {
*cwd = rec.cwd.clone();
}
if version.is_none() {
*version = rec.version.clone();
}
if git_branch.is_none() {
*git_branch = rec.git_branch.clone();
}
if data_session_id.is_none() {
*data_session_id = rec.session_id.clone();
}
}
// ── Text rendering ──