SLOPSHOPPER

fork-lineage

Remembers which session a fork came from (renames never break it), shows it above the prompt, and sends a fork's report to its parent only when you approve it

newbandcommandtoastpromptmodel
★ 5v0.2.1MITupdated 2026-10-06mutlumehmet/claude-plugins/plugins/fork-lineage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fork-lineage
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ fork-lineage │ ⏺ Read(src/auth.ts) │ This session is not a fork, so there is no │ ⎿ Read 6 lines │ parent to report to │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /lineage ⎿ fork-lineage: No lineage: this session has no transcript yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

fork-lineage

A Claude Code mod that remembers which session a fork came from, shows it above the prompt, and carries a fork's report back to its parent when you say so. Part of claude-plugins and of the Claude Code Toolkit.

How it works: a fork remembers its parent through renames, and its report goes back only after you approve it

Why

You fork a session to try something on the side, rename it, rename the parent, and a day later nobody remembers which came from which. The work done in the fork stays in the fork, unless you copy it back by hand. fork-lineage keeps the link and makes the hand back one button.

What it does

  • In a fork: the band above the prompt says ⑂ fork of <parent title>, with a ↑ report to parent button while the fork has prompts of its own that were never reported. After a report it says ✓ reported, and the button comes back when you do more work.
  • The report: the button (or /report-parent) asks the fork's own model for a short report of what was decided, changed and left open in the fork. It shows the report and asks: Send to parent, Not needed or Cancel. Nothing is sent before you pick Send.
  • Parent running: the report reaches it at once, as a message from the fork.
  • Parent closed: the report waits. When you resume the parent, a blinking 📬 1 report waiting and a Read button appear. Read shows the report and adds it to the conversation without running a turn. If you never press it, the report goes along with your next message instead, so it is never lost and never arrives twice.
  • In a parent: the band says ⑂ N forks.

The whole story: a session forks with /branch, the fork works over several turns, and before it is closed it reports to the parent after you approve; an open parent gets it at once, a closed one finds it waiting behind Read

  • /lineage: the whole family tree with the current titles, your session marked.
  • At 80% and 90% context, a fork that has not reported yet gets one reminder.

/lineage prints the family tree

How it works

Nothing about the link is stored when you fork. Every time, the mod works it out from Claude Code's own transcripts, and reads each session's title at that moment, so renaming a parent or a fork never breaks anything.

  1. Who is the parent. A fork copies its parent's messages with the same message ids. Among the older sessions that hold the fork's first message, the parent is the one sharing the most message ids (then the longest shared start, then the oldest). If the transcript names its parent (forkedFrom), that wins. Checked against that field on 25 sessions with the field hidden: 25 right.
  2. The report. A tool-less call over the fork's own transcript, told first which side it is on (the copied history starts with the parent's conversation and name, which once made the model report as the parent), and given the list of prompts typed in the fork. The list matters once a long fork has been compacted: the summary mixes the parent's history with the fork's work, and without the list the model guessed the boundary both ways, leaving fork work out and claiming the parent's.
  3. Delivery. Live: addressed to the parent's session id. Closed: a small file in the parent's inbox, picked up by this mod when the parent comes back.

The files live in ~/.claude-forks/ (CLAUDE_FORKS_DIR overrides) and follow a small open contract, CONTRACT.md, so another tool (a session dashboard, a launcher) can read and write the same links and reports without depending on this mod. Writes are atomic, and an inbox entry is claimed before it is delivered.

Cleanup, at most once a day: a fork's link and reports go once Claude Code has removed its transcript (cleanupPeriodDays), and a delivered report 30 days after delivery. A report that was never delivered is never deleted.

What it reads and does

  • Reads: Claude Code's transcripts under your config dir (~/.claude/projects/), read only, to find parents, forks, titles and prompt counts. Its own files under ~/.claude-forks/.
  • Writes: only under ~/.claude-forks/: links, approved reports, inbox entries.
  • Does: draws a line in the band, a toast, transcript lines, the approval question. On Send, one message to the parent session on this machine. On Read, the report as a user row in the parent's conversation.
  • Model calls: one per report, through Claude Code itself ($.model.fork, the same model and account as the session), only when you press the button.
  • Hooks: prompt.submit adds a waiting report to your next message (it never changes your text); turn.complete refreshes the band; command.run answers /lineage and /report-parent only.
  • Runs: python3 on its own hooks/lineage.py, which reads the transcripts (they can be larger than a mod may read directly) and does the atomic file writes.
  • Never: sends a report without your Send, edits a transcript, or makes a network call of its own. Reports can hold conversation content; they stay on your machine.

Install

Needs Claude Code 2.1.289 or later (mods) and python3.

/plugin marketplace add mutlumehmet/claude-plugins
/plugin install fork-lineage@mehmetmutlu

Or the whole Toolkit: /plugin install toolkit@mehmetmutlu.

A mod runs inside Claude Code with your permissions. Read the code before you install any mod.

Known gaps

  • A fork made from a session in another config dir (another account) is found only when the transcript names its parent.
  • A parent session in a different permission mode may hold a live report for approval; the mod counts it as delivered once queued.
  • The report shows as one transcript line per report line (a transcript line takes no line breaks).
  • Nothing is drawn in the VS Code chat panel.

About

Mehmet Mutlu: work that's shipped, at mehmetmutlu.dev

Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.

License

MIT

Source 1 files
hooks/register.tsx 371 lines
1// fork-lineage: knows which session this one was forked from, and carries a
2// fork's report back to its parent, only when the person asks.
3//
4// - The band shows `⑂ fork of <parent title>` with a `↑ report to parent` button
5//   while the fork has prompts of its own that were never reported, and
6//   `⑂ N forks` on a parent. Titles are read live, so renames never break it.
7// - /lineage prints the family tree; /report-parent does what the button does.
8// - A parent with a waiting report shows a blinking `📬 N reports waiting` and a
9//   Read button: pressed, the report shows in the transcript and joins the
10//   conversation with no turn run. Unpressed, it rides along with the next prompt.
11// - A report is written by the fork's own model from the contract's prompt, shown,
12//   and sent only after the person picks Send: live to the parent session by id,
13//   else into the parent's inbox, which this mod's prompt.submit hook adds to the
14//   parent's next prompt (claimed first, so it arrives once).
15// - Never automatic. At 80% and 90% context an unreported fork gets a toast.
16// - Once a day it deletes the files of sessions whose transcript Claude Code has
17//   removed, and inbox entries delivered over 30 days ago; never an undelivered one.
18//
19// Files follow the claude-forks contract v1 (CONTRACT.md beside the README);
20// hooks/lineage.py does every read of transcripts and every write.
21
22import type { EngineInterface, Register } from 'claude-code'
23
24const PROMPT =
25  'Summarise what we decided, changed and left open in this fork, for the parent session. Short bullets.'
26const REMIND_AT = [80, 90]
27const PRUNE_KEY = 'last-prune'
28const BLINK_MS = 800
29const DAY_MS = 86_400_000
30
31type Row = {
32  id: string
33  title: string | null
34  parentId: string | null
35  detectedBy: string | null
36  born: number
37  ownPrompts: number
38  lastOwnPromptAt: string | null
39  firstOwnPrompt?: string | null
40  ownPromptList?: string[]
41  ownPromptsOmitted?: number
42  state: string | null
43  handedBackAt: string | null
44  notNeededAt: string | null
45}
46type Lineage = { self: Row; parent: Row | null; children: Row[]; family: Row[] }
47
48let lineage: Lineage | null = null
49let waiting = 0
50let isLit = false
51let blink: { cancel: () => void } | null = null
52let isReading = false
53// Reports read with the button that the conversation refused: they go with the next prompt
54let unsent: string[] = []
55let reminded = 0
56let isBusy = false
57
58export const register: Register = (on) => {
59  on('session.start', async ($, e, next) => {
60    await $.command.register({ name: 'lineage', description: 'Show which session this one was forked from, and its forks' })
61    await $.command.register({ name: 'report-parent', description: 'Write a report of this fork, show it, and send it to the parent session if you approve' })
62    try {
63      await prune($)
64    } catch {}
65    await refresh($)
66    if (waiting > 0) {
67      $.ui.toast(waiting + ' fork report' + (waiting > 1 ? 's' : '') + ' waiting: press Read in the band')
68    }
69    return next(e)
70  })
71
72  // A parent's inbox: reports from its forks ride along with the next prompt
73  on('prompt.submit', async ($, e, next) => {
74    const reports = [...unsent, ...(await claimReports($))]
75    unsent = []
76    if (reports.length === 0) return next(e)
77    $.ui.toast(reports.length + ' fork report' + (reports.length > 1 ? 's' : '') + ' added to this prompt')
78    return next({ ...e, context: [...(e.context ?? []), ...reports] })
79  })
80
81  on('turn.complete', async ($, e, next) => {
82    const result = await next(e)
83    if (e.agentId) return result
84    await refresh($)
85    await remind($)
86    return result
87  })
88
89  on('command.run', { command: 'lineage' }, async ($) => {
90    await refresh($)
91    return { text: tree() }
92  })
93
94  on('command.run', { command: 'report-parent' }, async ($) => {
95    await report($)
96    return { text: '' }
97  })
98
99  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100    const below = await next(e)
101    if (e.props.hasSurvey || !lineage) return below
102    const { Box, Button, Text } = $.ui.resolve(e)
103    const parent = lineage.parent
104    const kids = lineage.children.length
105    if (!parent && kids === 0 && waiting === 0) return below
106    const width = e.props.bodyColumns
107    const name = parent ? clip(parent.title ?? parent.id.slice(0, 8), Math.max(12, width - 40)) : ''
108    const line = (
109      <Box>
110        {parent ? <Text color="magenta">{'⑂ fork of '}</Text> : null}
111        {parent ? <Text bold>{name}</Text> : null}
112        {parent && isPending() ? <Text>{'  '}</Text> : null}
113        {parent && isPending() ? (
114          <Button key="report" label={isBusy ? 'writing report…' : '↑ report to parent'} onPress={() => report($)} />
115        ) : null}
116        {parent && !isPending() ? <Text dimColor>{'  ' + doneLabel()}</Text> : null}
117        {kids > 0 ? <Text dimColor>{(parent ? '  · ' : '') + '⑂ ' + kids + ' fork' + (kids > 1 ? 's' : '')}</Text> : null}
118        {waiting > 0 ? <Text color={isLit ? 'yellow' : undefined} bold={isLit} dimColor={!isLit}>{'  📬 ' + waiting + ' report' + (waiting > 1 ? 's' : '') + ' waiting '}</Text> : null}
119        {waiting > 0 ? (
120          <Button key="read" label={isReading ? 'reading…' : 'Read'} onPress={() => readNow($)} />
121        ) : null}
122      </Box>
123    )
124    if (!below) return line
125    return (
126      <Box flexDirection="column">
127        {below}
128        {line}
129      </Box>
130    )
131  })
132}
133
134async function paths($: EngineInterface) {
135  const home = (await $.env.get('HOME')) ?? ''
136  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? home + '/.claude'
137  const forksDir = (await $.env.get('CLAUDE_FORKS_DIR')) ?? home + '/.claude-forks'
138  return {
139    configDir: configDir.replace(/\/+$/, ''),
140    forksDir,
141    script: $.plugin.root + '/hooks/lineage.py',
142    id: await $.session.id(),
143    cwd: await $.session.cwd(),
144  }
145}
146
147async function helper($: EngineInterface, args: string[], stdin?: string): Promise<any> {
148  const p = await paths($)
149  const run = await $.process.run(['python3', p.script, ...args], { stdin, timeoutMs: 30000 })
150  if (run.exitCode !== 0) {
151    $.ui.log('fork-lineage: ' + args[0] + ' failed: ' + run.stderr.trim().split('\n').pop(), { to: 'debug' })
152    return null
153  }
154  try {
155    return JSON.parse(run.stdout)
156  } catch {
157    return null
158  }
159}
160
161async function refresh($: EngineInterface) {
162  const p = await paths($)
163  const shown = await helper($, ['show', p.configDir, p.forksDir, p.id])
164  lineage = shown && shown.self ? (shown as Lineage) : null
165  const w = await helper($, ['waiting', p.forksDir, p.id])
166  waiting = w && Array.isArray(w.waiting) ? w.waiting.length : 0
167  if (waiting > 0 && !blink) blink = $.clock.every(BLINK_MS, () => flash($))
168  if (waiting === 0 && blink) {
169    blink.cancel()
170    blink = null
171    isLit = false
172  }
173  $.ui.invalidate('ui.render')
174}
175
176// The waiting line blinks until the reports are read
177function flash($: EngineInterface) {
178  isLit = !isLit
179  $.ui.invalidate('ui.render')
180}
181
182// Read pressed: each waiting report is shown in the transcript and added to the
183// conversation as a user row the model reads, with no turn run. Claimed first,
184// so it never arrives twice; the next prompt finds nothing left to add.
185async function readNow($: EngineInterface) {
186  if (isReading) return
187  isReading = true
188  $.ui.invalidate('ui.render')
189  try {
190    const reports = await claimReports($)
191    for (const text of reports) {
192      logLines($, text)
193      try {
194        const added = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
195        if ('deny' in added) unsent.push(text)
196      } catch {
197        unsent.push(text)
198      }
199    }
200    if (reports.length) $.ui.toast(reports.length + ' fork report' + (reports.length > 1 ? 's' : '') + ' read and added to the conversation')
201  } finally {
202    isReading = false
203    await refresh($)
204  }
205}
206
207// At most once a day: drop the files of sessions whose transcript is gone
208async function prune($: EngineInterface) {
209  const now = await $.clock.now()
210  let last = 0
211  try {
212    last = Number(await $.store.get(PRUNE_KEY)) || 0
213  } catch {}
214  if (last && now - last < DAY_MS) return
215  const p = await paths($)
216  await helper($, ['prune', p.forksDir, p.configDir])
217  try {
218    await $.store.set(PRUNE_KEY, now)
219  } catch {}
220}
221
222// A fork with prompts of its own after its last report, and not marked not needed
223function isPending(): boolean {
224  const me = lineage?.self
225  if (!me || !lineage?.parent || me.ownPrompts === 0) return false
226  if (me.state === 'not_needed') return false
227  if (!me.handedBackAt) return true
228  return !!me.lastOwnPromptAt && me.lastOwnPromptAt > me.handedBackAt
229}
230
231function doneLabel(): string {
232  const me = lineage?.self
233  if (me?.state === 'not_needed') return 'no report needed'
234  if (me?.handedBackAt) return '✓ reported'
235  return 'nothing to report yet'
236}
237
238async function remind($: EngineInterface) {
239  if (!isPending()) return
240  const { context } = await $.session.usage()
241  const pct = context.percent ?? 0
242  const level = REMIND_AT.filter((x) => pct >= x).pop() ?? 0
243  if (level > reminded) {
244    reminded = level
245    $.ui.toast('Context at ' + level + '%: this fork has not reported to its parent yet. Press ↑ report to parent in the band')
246  }
247}
248
249async function report($: EngineInterface) {
250  if (isBusy) return
251  await refresh($)
252  const parent = lineage?.parent
253  if (!lineage || !parent) {
254    $.ui.toast('This session is not a fork, so there is no parent to report to')
255    return
256  }
257  isBusy = true
258  $.ui.invalidate('ui.render')
259  try {
260    const reply = await $.model.fork({ prompt: briefing(lineage.self, parent) + '\n\n' + PROMPT })
261    if (!reply.isAnswered) {
262      $.ui.toast('No report written (' + reply.reason + ')')
263      return
264    }
265    const title = parent.title ?? parent.id.slice(0, 8)
266    logLines($, 'Report for the parent "' + title + '":\n' + reply.text)
267    let choice = 'Cancel'
268    try {
269      choice = await $.ui.ask('Send this report to the parent session "' + title + '"?', {
270        header: 'Report',
271        options: ['Send to parent', 'Not needed', 'Cancel'],
272      })
273    } catch {}
274    const p = await paths($)
275    if (choice === 'Send to parent') {
276      const sent = await $.session.send({ to: { sessionId: parent.id }, text: frame(lineage.self, reply.text) })
277      await helper(
278        $,
279        ['handback', p.forksDir, p.id, parent.id, p.configDir, p.cwd, sent.isDelivered ? 'sendmessage' : '-'],
280        reply.text,
281      )
282      $.ui.toast(
283        sent.isDelivered
284          ? 'Report sent to "' + title + '"'
285          : 'Parent is not running: the report waits in its inbox for its next prompt',
286      )
287    } else if (choice === 'Not needed') {
288      await helper($, ['not-needed', p.forksDir, p.id, parent.id, p.configDir, p.cwd])
289      $.ui.toast('Marked: no report needed')
290    }
291  } finally {
292    isBusy = false
293    await refresh($)
294  }
295}
296
297// The fork's transcript opens with the parent's conversation (and often the parent's
298// name), so the model is told outright which side it is on before the contract prompt.
299// The fork's own prompts go in as a list: after compaction the summary mixes the two
300// sides and the first own prompt no longer appears word for word, so the model would
301// otherwise guess the boundary (seen both ways: fork work left out, parent work claimed)
302function briefing(me: Row, parent: Row): string {
303  const list = me.ownPromptList ?? []
304  let own = ''
305  if (list.length) {
306    const more = me.ownPromptsOmitted ? '\n(and ' + me.ownPromptsOmitted + ' later prompts)' : ''
307    own =
308      ' These are the prompts the user typed in this fork, in order; everything before the first of them was the parent\'s:\n' +
309      list.map((t, i) => i + 1 + '. ' + t).join('\n') +
310      more +
311      '\nThis conversation may have been compacted into a summary that mixes the parent\'s history with this fork\'s work. Use this list, not the summary, to decide what belongs to the fork: report the work done in answer to these prompts, all of them from the first, and nothing the parent did before the fork.\n'
312  } else if (me.firstOwnPrompt) {
313    own = ' This fork\'s own work starts at the prompt "' + me.firstOwnPrompt + '"; everything before it was the parent\'s.'
314  }
315  return (
316    'You are the fork "' + (me.title ?? me.id) + '" (session ' + me.id + '), forked from the parent session "' +
317    (parent.title ?? parent.id) + '" (session ' + parent.id + ').' + own + ' Report only what happened in this fork, and reply with the report alone.'
318  )
319}
320
321// A transcript line takes no line breaks (they show as a replacement glyph), so a
322// report is logged one line at a time, blank lines left out
323function logLines($: EngineInterface, text: string) {
324  for (const line of text.replace(/\r/g, '').split('\n')) if (line.trim()) $.ui.log(line)
325}
326
327function frame(me: Row, text: string): string {
328  return 'Report from your fork "' + (me.title ?? me.id.slice(0, 8)) + '" (session ' + me.id + '):\n\n' + text.trim()
329}
330
331// The parent side: claim each waiting report and hand its text to the model
332async function claimReports($: EngineInterface): Promise<string[]> {
333  if (waiting === 0) return []
334  const p = await paths($)
335  const out = await helper($, ['claim', p.forksDir, p.id])
336  waiting = 0
337  $.ui.invalidate('ui.render')
338  if (!out || !Array.isArray(out.reports)) return []
339  const titles = new Map((lineage?.family ?? []).map((r) => [r.id, r.title]))
340  return out.reports.map(
341    (r: { childId: string; text: string }) =>
342      'Report from your fork "' + (titles.get(r.childId) ?? r.childId.slice(0, 8)) + '" (session ' + r.childId + '):\n\n' + r.text.trim(),
343  )
344}
345
346function tree(): string {
347  if (!lineage) return 'No lineage: this session has no transcript yet.'
348  const rows = lineage.family
349  const ids = new Set(rows.map((r) => r.id))
350  const kids = new Map<string | null, Row[]>()
351  for (const r of rows) {
352    const key = r.parentId && ids.has(r.parentId) ? r.parentId : null
353    kids.set(key, [...(kids.get(key) ?? []), r])
354  }
355  const out: string[] = []
356  const walk = (r: Row, depth: number) => {
357    const label = r.title ?? r.id.slice(0, 8)
358    const state = r.state === 'handed_back' ? ' ✓ reported' : r.state === 'not_needed' ? ' · not needed' : ''
359    const you = r.id === lineage!.self.id ? '   ← this session' : ''
360    out.push((depth ? '  '.repeat(depth - 1) + '└ ' : '') + label + '  (' + r.id.slice(0, 8) + ')' + state + you)
361    for (const k of kids.get(r.id) ?? []) walk(k, depth + 1)
362  }
363  for (const r of kids.get(null) ?? []) walk(r, 0)
364  if (rows.length === 1) out.push('No forks: this session was not forked from another, and has no forks.')
365  return out.join('\n')
366}
367
368function clip(s: string, n: number): string {
369  return s.length > n ? s.slice(0, n - 1) + '…' : s
370}
371