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

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.

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.
⑂ 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./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.📬 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.⑂ N forks.
/lineage: the whole family tree with the current titles, your session marked.
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.
forkedFrom), that wins. Checked against that field on 25 sessions with the field hidden: 25 right.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.
~/.claude/projects/), read only, to find parents, forks, titles and prompt counts. Its own files under ~/.claude-forks/.~/.claude-forks/: links, approved reports, inbox entries.$.model.fork, the same model and account as the session), only when you press the button.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.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.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.
Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.
hooks/register.tsx 371 lines1// 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