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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! Which Claude session ids belong to the same managed session (#7617).
//!
//! Why: Claude Code mints a NEW `session_id` on every restart — a relaunch, a
//! `/login` account switch, a crash — and the `💸` statusline segment folds the
//! savings ledger by exactly the id Claude Code hands it on stdin. On
//! 2026-09-12 one managed session carried three ids in a single evening
//! (3544c9e5 → 63589e53 → f3def033), and after each restart the segment
//! vanished until the new id had earned rows of its own. The daemon already
//! knows the correlation — `SessionStart: linked Claude session to managed
//! session (#1744)` — but it keeps only the CURRENT id on the record, so the
//! prior ids' rows become unreachable the moment they are superseded.
//!
//! What: an append-only sidecar under the framework root, one file per managed
//! session listing every Claude session id ever linked to it.
//! [`record_link`] is written by the daemon's `SessionStart` correlation;
//! [`linked_claude_ids`] is read by the statusline, which needs no daemon to be
//! running and no session store to parse.
//!
//! GROWTH AND PRUNING: the store is append-only and unbounded — one file per
//! managed session, one short line per Claude restart, so a machine accumulates
//! roughly one line per session start forever. It sits under `usage/` beside the
//! ledger it serves deliberately: the only thing a stale link can cost is a fold
//! across ids whose rows are gone, which folds to zero and renders the empty
//! state. `tm repair savings-ledger` does NOT sweep this directory today — it
//! quarantines `savings.jsonl` and `no-fold-warned/` by name (see
//! `crate::core::savings_repair`). Adding it there is the pruning path when the
//! size is ever worth reclaiming; nothing here depends on that having happened.
//!
//! FAIL-OPEN BY CONSTRUCTION: every read failure — an absent directory, an
//! unreadable file, a partial write — yields NO siblings. The caller then shows
//! its explicit empty state. Nothing here ever invents a link, so a fold can
//! never be attributed to a session that did not earn it.
//! Test: the `tests` module below.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The sidecar directory, under the framework root's `usage/`.
///
/// Why: it sits beside `savings.jsonl` and `no-fold-warned/` because everything
/// here exists to serve that ledger — see the module header's GROWTH AND
/// PRUNING note for what the savings repair does and does not sweep today.
const SESSION_LINKS_DIR: &str = "session-links";
/// Where `managed_id`'s link list lives.
///
/// What: `<root>/usage/session-links/<managed_id>`. `None` for an id that is
/// not a single safe path segment — the id is used verbatim as a filename, so
/// anything with a separator, or a relative-path spelling, is refused rather
/// than resolved.
/// Test: `a_traversing_managed_id_is_refused`.
fn link_file(root: &Path, managed_id: &str) -> Option<PathBuf> {
let id = managed_id.trim();
if id.is_empty()
|| id == "."
|| id == ".."
|| id.contains('/')
|| id.contains('\\')
|| id.starts_with('.')
{
return None;
}
Some(root.join("usage").join(SESSION_LINKS_DIR).join(id))
}
/// Record that `claude_id` belongs to `managed_id`.
///
/// Why: the daemon's correlation overwrites the record's single
/// `claude_session_id` field, so without this the previous id — and every
/// savings row keyed by it — is orphaned. Appending rather than replacing is
/// the whole point.
/// What: appends `claude_id` as its own line, once. Already-present ids are a
/// no-op, so the repeated `SessionStart` a resume or compaction raises costs one
/// read. Best-effort: a failure to create the directory or write the file is
/// returned as `false` and never propagated — a missing link degrades the
/// segment, and must not fail a session start.
/// Test: `a_link_is_recorded_once`, `a_second_id_joins_the_same_managed_session`,
/// `a_traversing_managed_id_is_refused`.
pub fn record_link(root: &Path, managed_id: &str, claude_id: &str) -> bool {
let claude_id = claude_id.trim();
if claude_id.is_empty() {
return false;
}
let Some(path) = link_file(root, managed_id) else {
return false;
};
crate::core::home_write_fence::check(&path); // #8545
if read_ids(&path).iter().any(|id| id == claude_id) {
return false;
}
let Some(parent) = path.parent() else {
return false;
};
if std::fs::create_dir_all(parent).is_err() {
return false;
}
let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
else {
return false;
};
write_link_line(&mut file, claude_id).is_ok()
}
/// Write one id and its newline to `sink` as a SINGLE write.
///
/// Why (#7617, critic HIGH 2; the #7579 shape): `writeln!` expands to
/// `write_fmt`, which hands the formatter's pieces to the sink separately — the
/// id and its `\n` reach the `O_APPEND` descriptor as two writes, and a second
/// writer racing between them lands its own id mid-line. That is not
/// theoretical here: the daemon's `SessionStart` correlation is the caller, and
/// a splice would fabricate a session id that never existed, which
/// [`linked_claude_ids`] would then hand the statusline as a sibling to fold.
/// #7579 put ten such lines in the operator's savings ledger by the same
/// mistake; `savings::write_row_line` is the fix this mirrors.
/// What: builds `<id>\n` in one buffer and hands it over in one `write_all`.
/// Test: `an_id_and_its_newline_leave_in_one_write`.
fn write_link_line(sink: &mut impl std::io::Write, claude_id: &str) -> std::io::Result<()> {
let mut line = String::with_capacity(claude_id.len() + 1);
line.push_str(claude_id);
line.push('\n');
sink.write_all(line.as_bytes())
}
/// Every Claude session id that shares a managed session with `claude_id`.
///
/// Why (#7617): this is what lets the segment fold across a restart instead of
/// reading as a disappearance. The lookup is by CONTENT rather than through a
/// reverse index because the statusline runs in a short-lived process with no
/// daemon to ask, and one small directory scan is cheaper than keeping a second
/// file consistent with the first.
/// What: the ids from the one link file that lists `claude_id`, INCLUDING
/// `claude_id` itself, so a caller can fold the whole set in one pass. Empty
/// when nothing links it — an unmanaged session, a session that started before
/// this store existed, or any read failure at all.
/// Test: `siblings_include_the_queried_id`, `an_unlinked_id_has_no_siblings`,
/// `an_absent_store_has_no_siblings`.
pub fn linked_claude_ids(root: &Path, claude_id: &str) -> Vec<String> {
let claude_id = claude_id.trim();
if claude_id.is_empty() {
return Vec::new();
}
let dir = root.join("usage").join(SESSION_LINKS_DIR);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
for entry in entries.flatten() {
let ids = read_ids(&entry.path());
if ids.iter().any(|id| id == claude_id) {
return ids;
}
}
Vec::new()
}
/// Every Claude session id each managed session ever carried, keyed by the
/// Claude id: Claude id → the managed ids whose link file lists it (#7771).
///
/// Why: an agent's owner file names the CLAUDE session that dispatched it, and
/// the session record keeps only the CURRENT one. After a restart or `/clear`
/// the dispatching id is recorded nowhere but here, so the worktree reclaim
/// could not tell a superseded session from one it had never heard of.
/// What: STRICT, unlike [`linked_claude_ids`] — the reclaim is a destructive
/// path, so a partial read must not pass for a complete one (ADR-0045). An
/// absent store is `Ok` and empty. A directory or file that cannot be read, or a
/// file name that is not UTF-8, is `Err`. Dot-files (`.DS_Store`) are skipped;
/// [`record_link`] never writes one.
/// Test: `link_history_maps_each_claude_id_to_its_managed_sessions`,
/// `link_history_of_an_absent_store_is_empty`,
/// `link_history_refuses_an_unreadable_link_file`.
pub fn link_history(root: &Path) -> Result<HashMap<String, Vec<String>>, String> {
let dir = root.join("usage").join(SESSION_LINKS_DIR);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
Err(e) => return Err(format!("{} could not be listed: {e}", dir.display())),
};
let mut history: HashMap<String, Vec<String>> = HashMap::new();
for entry in entries {
let entry = entry.map_err(|e| format!("{} could not be listed: {e}", dir.display()))?;
let name = entry.file_name();
let Some(managed) = name.to_str() else {
return Err(format!("{} holds a non-UTF-8 file name", dir.display()));
};
if managed.starts_with('.') {
continue;
}
let path = entry.path();
let body = std::fs::read_to_string(&path)
.map_err(|e| format!("{} could not be read: {e}", path.display()))?;
for claude in body.lines().map(str::trim).filter(|l| !l.is_empty()) {
history
.entry(claude.to_owned())
.or_default()
.push(managed.to_owned());
}
}
Ok(history)
}
/// The non-empty lines of `path`, trimmed.
///
/// What: an unreadable or absent file reads as no ids, which is what makes
/// every failure path above degrade to "no siblings" rather than to a guess.
fn read_ids(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect()
}
#[cfg(test)]
#[path = "session_links_tests.rs"]
mod tests;