diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9d32b..d1a59f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ migration in the same line. ## [Unreleased] +### Added + +- **Forked sessions are marked `fork`** ahead of the title, in the list and in the preview + and `read` headers alike, and those headers also name the branch a fork came from and + the message the two split at. Claude Code forks a + conversation by copying the transcript into a new file under a new session id and + records nothing that says so, so the two branches sat in the list as unrelated rows + with the same title, the same project and the same opening prompt. Picking the wrong + one resumes a branch missing everything after the split. Detection reads the only + trace the format leaves: copied messages keep the uuids they had in the original. + Claude Code sessions only, and it costs one extra partial read per new transcript. + ## [0.1.1] - 2026-08-22 Documentation and messaging. No behaviour change. diff --git a/README.md b/README.md index 4928f3b..5d6c742 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ only changed files are reparsed. - SDK and other automated sessions remain searchable but rank below user-started sessions. - Sessions from deleted worktrees resume from the nearest existing parent directory. - Recently active sessions are marked `●` and require confirmation before reattaching. +- Forked Claude Code sessions are marked `fork`, and name the branch they split from. ## Development diff --git a/agsearch b/agsearch index 26ac59e..8fca061 100755 --- a/agsearch +++ b/agsearch @@ -50,6 +50,7 @@ META_PATH = os.path.join(CACHE_DIR, "meta.json") SESSIONS_PATH = os.path.join(CACHE_DIR, "sessions.tsv") # one line per session, for _filter SUBMAP_PATH = os.path.join(CACHE_DIR, "submap.json") # parent-sid -> [subagent file paths] INDEX_PATH = os.path.join(CACHE_DIR, "index.json") # sid -> {source, path} for preview/resume +FORKS_PATH = os.path.join(CACHE_DIR, "forks.json") # forked sid -> {of, at} CACHE_FMT = 6 # bump when the TSV column layout / keying changes, to invalidate old fragments @@ -254,6 +255,126 @@ def parse_codex_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): return sid, final +# ------------------------------------------------------------------ forks + +# Claude Code forks a session by copying the transcript so far into a new file under a new +# session id. Nothing in the format announces that: no parent field, no marker entry. The only +# trace is that the copied messages keep the uuids they had in the original, so two Claude +# sessions whose FIRST message carries the same uuid are one conversation branched in two. +# +# Worth saying out loud, because until now the list showed them as two unrelated sessions with +# the same title, the same project and the same opening prompt, and picking the wrong one +# resumes a branch that is missing everything you did after the fork. + +FORK_FAMILY_MAX = 12 # a bigger "family" than this is a fingerprint collision, not a fork +FORK_SCAN_LINES = 4000 # how far into a file to look for its first real message + + +def _root_uuid(path, scan=FORK_SCAN_LINES): + """uuid of a Claude session's first user/assistant entry: its fork fingerprint. + + Cheap on purpose — this runs per session file, and the answer never changes once a file + exists, so build_index carries it forward instead of recomputing it. + """ + try: + fh = open(path, "r", errors="replace") + except OSError: + return "" + with fh: + for i, line in enumerate(fh): + if i >= scan: + break + try: + o = json.loads(line) + except json.JSONDecodeError: + continue + if o.get("type") in ("user", "assistant") and o.get("uuid"): + return o["uuid"] + return "" + + +def _msg_uuids(path): + """Ordered (uuid, timestamp) for a Claude session's user/assistant entries.""" + out = [] + try: + fh = open(path, "r", errors="replace") + except OSError: + return out + with fh: + for line in fh: + try: + o = json.loads(line) + except json.JSONDecodeError: + continue + if o.get("type") in ("user", "assistant") and o.get("uuid"): + out.append((o["uuid"], o.get("timestamp", ""))) + return out + + +def _shared_prefix(a, b): + n = 0 + while n < len(a) and n < len(b) and a[n][0] == b[n][0]: + n += 1 + return n + + +def _older(a, b): + """True if branch `a` is the one branch `b` grew out of, rather than the other way round. + + Two branches agree up to the message where they split, and whichever carried on FIRST at + that point is the one that existed to be copied. If one of them runs out at the split it + *is* the copied prefix: the branch somebody forked from and then stopped using, which is + why length can never be the signal on its own — an abandoned original is usually the + shorter of the two. + """ + k = _shared_prefix(a, b) + if k >= len(a) or k >= len(b): + return len(a) <= len(b) + return a[k][1] <= b[k][1] + + +def detect_forks(index): + """{forked sid: {"of": original sid, "at": messages shared}} across the whole index. + + Which branch is the original is decided at the message where two branches stop agreeing, + by _older(); each fork is then attributed to the closest earlier branch it shares a prefix + with, so a fork of a fork points at the fork and not at the root. + + Only the handful of sessions that share a fingerprint are read here; everything else costs + a dict lookup. + """ + family = {} + for sid, info in index.items(): + if info.get("source") == "cc" and info.get("root"): + family.setdefault(info["root"], []).append(sid) + + forks = {} + for sids in family.values(): + if not 2 <= len(sids) <= FORK_FAMILY_MAX: + continue + seq = {s: _msg_uuids(index[s]["path"]) for s in sids} + ranked = [] # oldest branch first, by insertion + for s in sorted(sids): + i = 0 + while i < len(ranked) and _older(seq[ranked[i]], seq[s]): + i += 1 + ranked.insert(i, s) + for i, sid in enumerate(ranked[1:], 1): + of = max(ranked[:i], key=lambda p: _shared_prefix(seq[p], seq[sid])) + at = _shared_prefix(seq[of], seq[sid]) + if at: + forks[sid] = {"of": of, "at": at} + return forks + + +def load_forks(): + """The fork map written at index time, or {} if it was never built.""" + try: + return json.load(open(FORKS_PATH)) + except (OSError, json.JSONDecodeError): + return {} + + # ------------------------------------------------------------------ cache def _frag_path(jsonl_path): @@ -313,6 +434,10 @@ def build_index(include_thinking=False, force=False): """Return the full index as a list of TSV strings, refreshing per-file caches.""" os.makedirs(FRAG_DIR, exist_ok=True) meta = {} + try: # roots are immutable, so carry them over cache hits + old_index = json.load(open(INDEX_PATH)) + except (OSError, json.JSONDecodeError): + old_index = {} if os.path.exists(META_PATH) and not force: try: meta = json.load(open(META_PATH)) @@ -375,6 +500,9 @@ def build_index(include_thinking=False, force=False): sub_map.setdefault(sid0, []).append(path) # subagent folds into parent else: index[sid0] = {"source": source, "path": path} + if source == "cc": + root = (old_index.get(sid0) or {}).get("root") + index[sid0]["root"] = root or _root_uuid(path) for fn in os.listdir(FRAG_DIR): # drop fragments for deleted sessions if fn not in live_frags: @@ -388,6 +516,7 @@ def build_index(include_thinking=False, force=False): json.dump(new_meta, open(META_PATH, "w")) json.dump(sub_map, open(SUBMAP_PATH, "w")) json.dump(index, open(INDEX_PATH, "w")) + json.dump(detect_forks(index), open(FORKS_PATH, "w")) return lines @@ -718,6 +847,28 @@ def _flag(v): return str(v).lower() in ("1", "true") +def fork_mark(sid): + """The flag a fork puts ahead of its title, in a preview or a read. + + Same word in the same place as the list row the session was selected from. Carrying the + fact only on the meta line put it in a different position in each view, and last on a line + that leads with the project and the date, which is the wrong end for a flag. + """ + return _FORK_MARK if sid in load_forks() else "" + + +def fork_line(sid): + """The clause under that flag: which branch it came from, and where the two split. + + The original's id is spelled short because this sits inside a line that must not wrap; the + prefix is enough to find it in the list, which is the only thing you want it for. + """ + fork = load_forks().get(sid) + if not fork: + return "" + return f" · fork of {fork['of'][:8]} at msg {fork['at']}" + + def load_session_rows(sid, thinking=False, limit=MSG_INDEX_CHARS): """(source, [(row, is_subagent)]) for one session, chronological. @@ -784,9 +935,9 @@ def render_transcript(sid, thinking="0", query=""): r0 = rows[0] title = r0[6] or next((r[7] for r, _ in tagged if r[4] == "user"), "") or "(untitled)" n_sub = sum(1 for _, sub in tagged if sub) - print(f"\033[1m{title[:80]}\033[0m") + print(fork_mark(sid) + f"\033[1m{title[:80]}\033[0m") print(f"\033[2m{short_proj(r0[1])} · {r0[3][:10]} · {len(rows)} msgs" - + (f" · {n_sub} subagent" if n_sub else "") + "\033[0m") + + (f" · {n_sub} subagent" if n_sub else "") + fork_line(sid) + "\033[0m") print(f"\033[2m{resume_line(sid, r0[1])}\033[0m\n") for r, sub in tagged: @@ -816,10 +967,10 @@ def render_preview(sid, thinking, query): r0 = rows[0] n_sub = sum(1 for _, sub in tagged if sub) disp_title = r0[6] or next((r[7] for r, _ in tagged if r[4] == "user"), "") or "(untitled)" - print(f"\033[1m{disp_title[:80]}\033[0m") + print(fork_mark(sid) + f"\033[1m{disp_title[:80]}\033[0m") gone = " · orig dir gone" if r0[1] and not os.path.isdir(r0[1]) else "" print(f"\033[2m{short_proj(r0[1])} · {r0[3][:10]} · {len(rows)} msgs" - + (f" · {n_sub} subagent" if n_sub else "") + gone + "\033[0m") + + (f" · {n_sub} subagent" if n_sub else "") + gone + fork_line(sid) + "\033[0m") body = _preview_lines(tagged, keys, source) if body: @@ -1075,6 +1226,12 @@ _LIVE_MARK = "\033[1;31m●\033[0m " # session still being written to # Informational only: the session still resumes (from the nearest surviving ancestor dir), # so this is muted enough to read as a footnote rather than a warning. _GONE_MARK = " \033[2morig dir gone\033[0m" +# Leads the title rather than trailing it. The list pane is a fraction of the terminal, so +# anything parked after the title is the first thing truncated away — exactly on the rows that +# need it, since a fork carries the same long title as the session it was forked from. Leading +# it costs nothing on the other 99% of rows, and every title starts in the same column, so the +# marks still line up to be scanned. A word, not a glyph: ⑂ and ⋔ are unreadable at 14px. +_FORK_MARK = "\033[2mfork\033[0m " def _active_sids(sids): @@ -1111,15 +1268,17 @@ def _missing_dirs(cwds): return gone -def _row(sid, cwd, date, source, kind, title, badge, active=False, dir_gone=False): +def _row(sid, cwd, date, source, kind, title, badge, active=False, dir_gone=False, + forked=False): mark = _AUTO_MARK if kind == "auto" else _SRC_MARK.get(source, " ") live = _LIVE_MARK if active else " " + forkm = _FORK_MARK if forked else "" tail = _GONE_MARK if dir_gone else "" body = (f"{date} {mark} \033[36m{short_proj(cwd)[:15]:<15}\033[0m " - f"{badge} {live}{title[:64]}{tail}") + f"{badge} {live}{forkm}{title[:64]}{tail}") if kind == "auto": body = (f"\033[2m{date} \033[0m{_AUTO_MARK}\033[2m {short_proj(cwd)[:15]:<15} " - f"{badge} \033[0m{live}\033[2m{title[:64]}\033[0m{tail}") + f"{badge} \033[0m{live}{forkm}\033[2m{title[:64]}\033[0m{tail}") return SEP.join([sid, cwd, body, "1" if active else "0"]) @@ -1290,11 +1449,13 @@ def rank_sessions(rows, qterms, usage=None, now=None): return [(sc, m, f) for sc, m, _st, f in scored] -def _smart_rows(rows, qterms, live=frozenset(), usage=None, gone=frozenset()): +def _smart_rows(rows, qterms, live=frozenset(), usage=None, gone=frozenset(), + forks=frozenset()): """Render the ranked sessions as fzf rows. Badge = matched/total query terms.""" total = len(qterms) return [_row(f[C_SID], f[C_CWD], f[C_DATE], f[C_SOURCE], f[C_KIND], f[C_TITLE], - f"\033[33m{m}/{total}\033[0m", f[C_SID] in live, f[C_CWD] in gone) + f"\033[33m{m}/{total}\033[0m", f[C_SID] in live, f[C_CWD] in gone, + f[C_SID] in forks) for _score, m, f in rank_sessions(rows, qterms, usage)[:200]] @@ -1322,12 +1483,13 @@ def cmd_filter(argv): live = _active_sids([f[C_SID] for f in rows]) gone = _missing_dirs([f[C_CWD] for f in rows]) + forks = load_forks() if not qterms: # initial list: yours first, then automation rows = sorted(rows, key=lambda f: f[C_KIND] == "auto") out = [_row(f[C_SID], f[C_CWD], f[C_DATE], f[C_SOURCE], f[C_KIND], f[C_TITLE], " ", - f[C_SID] in live, f[C_CWD] in gone) for f in rows] + f[C_SID] in live, f[C_CWD] in gone, f[C_SID] in forks) for f in rows] else: - out = _smart_rows(rows, qterms, live, _usage_counts(), gone) + out = _smart_rows(rows, qterms, live, _usage_counts(), gone, forks) sys.stdout.write("\n".join(out)) diff --git a/docs/fork.png b/docs/fork.png new file mode 100644 index 0000000..1d19bec Binary files /dev/null and b/docs/fork.png differ diff --git a/tests/test_fork_detect.py b/tests/test_fork_detect.py new file mode 100644 index 0000000..140a92d --- /dev/null +++ b/tests/test_fork_detect.py @@ -0,0 +1,163 @@ +"""A forked session has to look different from the session it was forked from. + +Claude Code forks a conversation by copying the transcript into a new file under a new session +id, and records nothing that says so. The two then sit in the list as separate rows with the +same title, the same project and the same opening prompt, and resuming the wrong one silently +drops everything that happened after the split. The only trace in the format is that copied +messages keep their original uuids, which is what detect_forks reads. +""" + +import json +import os +import re +import tempfile +import unittest + +from load_agsearch import load_agsearch + +ag = load_agsearch() + +ANSI = re.compile(r"\033\[[0-9;]*m") + + +def strip(s): + return ANSI.sub("", s) + + +def write_session(d, sid, msgs): + """One Claude session file. `msgs` is [(uuid, timestamp)] — the rest is filler.""" + path = os.path.join(d, sid + ".jsonl") + with open(path, "w") as fh: + for i, (uuid, ts) in enumerate(msgs): + role = "user" if i % 2 == 0 else "assistant" + fh.write(json.dumps({ + "type": role, "uuid": uuid, "sessionId": sid, "cwd": "/repo", + "timestamp": ts, "message": {"role": role, "content": "turn %d" % i}, + }) + "\n") + return path + + +def index_of(d, sessions): + """{sid: [(uuid, ts)]} -> the index shape build_index writes, files included.""" + index = {} + for sid, msgs in sessions.items(): + path = write_session(d, sid, msgs) + index[sid] = {"source": "cc", "path": path, "root": ag._root_uuid(path)} + return index + + +def turns(uuids, start=0): + return [(u, "2026-08-19T%02d:00:00.000Z" % (start + i)) for i, u in enumerate(uuids)] + + +class DetectForkTests(unittest.TestCase): + def _detect(self, sessions): + with tempfile.TemporaryDirectory() as d: + return ag.detect_forks(index_of(d, sessions)) + + def test_the_branch_that_continued_second_is_the_fork(self): + # Both carry a-b-c; the trunk went on at 03:00 and the fork was made at 09:00. + forks = self._detect({ + "trunk": turns(["a", "b", "c"]) + turns(["t1", "t2"], start=3), + "later": turns(["a", "b", "c"]) + turns(["f1", "f2"], start=9), + }) + self.assertEqual(forks, {"later": {"of": "trunk", "at": 3}}) + + def test_an_abandoned_trunk_is_not_the_fork(self): + # The original was forked from and never touched again, so everything it holds is + # shared. Length is not the signal: the fork is much longer than what it came from. + forks = self._detect({ + "stub": turns(["a", "b"]), + "carried_on": turns(["a", "b"]) + turns(["c", "d", "e"], start=5), + }) + self.assertEqual(forks, {"carried_on": {"of": "stub", "at": 2}}) + + def test_a_fork_of_a_fork_points_at_the_fork(self): + forks = self._detect({ + "first": turns(["a", "b"]) + turns(["p1"], start=2), + "second": turns(["a", "b"]) + turns(["s1", "s2"], start=4), + "third": turns(["a", "b"]) + turns(["s1", "s2"], start=4) + turns(["x"], start=8), + }) + self.assertEqual(forks["second"], {"of": "first", "at": 2}) + self.assertEqual(forks["third"], {"of": "second", "at": 4}) + + def test_unrelated_sessions_are_not_a_family(self): + self.assertEqual(self._detect({ + "one": turns(["a", "b", "c"]), + "two": turns(["x", "y", "z"]), + }), {}) + + def test_sessions_that_only_share_a_later_message_are_not_a_family(self): + # The fingerprint is the FIRST message. Sharing something further in is not a fork. + self.assertEqual(self._detect({ + "one": turns(["a", "shared"]), + "two": turns(["b", "shared"]), + }), {}) + + def test_codex_sessions_are_never_forks(self): + with tempfile.TemporaryDirectory() as d: + index = index_of(d, {"one": turns(["a"]), "two": turns(["a"])}) + for info in index.values(): + info["source"] = "codex" + self.assertEqual(ag.detect_forks(index), {}) + + def test_an_implausibly_large_family_is_treated_as_a_collision(self): + same = {"s%d" % i: turns(["a", "b"]) for i in range(ag.FORK_FAMILY_MAX + 2)} + self.assertEqual(self._detect(same), {}) + + +class ForkDisplayTests(unittest.TestCase): + def _row(self, forked): + return strip(ag._row("sid", "/repo/app", "2026-08-19", "cc", "cli", "Some title", + " ", forked=forked)) + + def test_the_row_says_fork_only_when_it_is_one(self): + self.assertIn("fork", self._row(True)) + self.assertNotIn("fork", self._row(False)) + + def test_the_mark_comes_before_the_title(self): + """The list pane is a fraction of the terminal, so anything after the title is cut. + + A fork carries the same long title as the session it came from, which is exactly the + row where the title runs long enough to be truncated. Trailing the title, the mark was + invisible below a ~200 column terminal. + """ + row = self._row(True) + self.assertLess(row.index("fork"), row.index("Some title")) + + def test_the_header_flags_the_fork_ahead_of_the_title(self): + """Same word in the same place as the list row it was selected from.""" + with tempfile.TemporaryDirectory() as d: + forks_path = ag.FORKS_PATH + ag.FORKS_PATH = os.path.join(d, "forks.json") + try: + json.dump({"kid": {"of": "0f21fa0f", "at": 41}}, open(ag.FORKS_PATH, "w")) + self.assertEqual(strip(ag.fork_mark("kid")), "fork ") + self.assertEqual(ag.fork_mark("someone-else"), "") + finally: + ag.FORKS_PATH = forks_path + + def test_the_header_clause_names_the_original_and_the_split(self): + with tempfile.TemporaryDirectory() as d: + forks_path = ag.FORKS_PATH + ag.FORKS_PATH = os.path.join(d, "forks.json") + try: + json.dump({"kid": {"of": "0f21fa0f-3074-4864-b949-e7d75449a373", "at": 41}}, + open(ag.FORKS_PATH, "w")) + self.assertEqual(ag.fork_line("kid"), " · fork of 0f21fa0f at msg 41") + self.assertEqual(ag.fork_line("someone-else"), "") + finally: + ag.FORKS_PATH = forks_path + + def test_a_missing_fork_map_costs_nothing(self): + forks_path = ag.FORKS_PATH + ag.FORKS_PATH = "/nonexistent/forks.json" + try: + self.assertEqual(ag.load_forks(), {}) + self.assertEqual(ag.fork_line("anything"), "") + finally: + ag.FORKS_PATH = forks_path + + +if __name__ == "__main__": + unittest.main()