Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
184 changes: 173 additions & 11 deletions agsearch
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"])


Expand Down Expand Up @@ -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]]


Expand Down Expand Up @@ -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))


Expand Down
Binary file added docs/fork.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading