When Claude ends a reply with a question, explains the choice in a side pane and lets you explore any option, or your own, in a real forked session

A Claude Code mod that turns the question at the end of Claude's reply into a side pane you can explore: every option with what it leads to, and a real forked session for any answer you want to try before you commit.

After every turn:
YES if you have to decide something before work can go on, NO for "anything else?" and other pleasantries.$.model.fork replays the session's own transcript (served from the prompt cache), so it knows everything Claude knew. It has no tools and replies with JSON: the question, the context you need, each option with what it leads to and its pros and cons, and a recommendation.You answer Claude in the prompt as usual. Nothing in the pane goes back to Claude.

Explore forks the real session at the message that asked the question:
claude -p --resume <session> --resume-session-at <that message> --fork-session
The fork is told you picked that option and carries on with read-only tools (Read, Grep, Glob). In 12 to 20 seconds the pane shows what it would do, its steps, and the next question it would ask you. Explore again from there to go deeper: each level forks the branch above it.
/explain other <answer>, adds it as an option and explores it like the rest.claude --resume <branch> --fork-session, in a new Herdr pane when you're inside Herdr, or copied to your clipboard otherwise.Every branch is its own session file, so the main conversation never sees any of it.
| Command | What it does |
|---|---|
/explain | Open the pane for the latest question |
/explain b | Explore option B (A to H) |
/explain other <answer> | Explore your own answer |
/explain map | Show the map |
/explain back, /explain top | Move up the tree |
/explain continue | Open the current branch as a live session |
/plugin install decision-tree --marketplace ray-amjad/claude-code-mods
Answer y to add the marketplace, then pick a scope.
| Option | Default | What it does |
|---|---|---|
gateModel | haiku | The model that answers YES or NO |
/explain and the pane opens above the prompt.--resume-session-at is an undocumented Claude Code flag (tested on 2.1.296). If the mod can't find the message to cut at, it forks from the latest point and says so.claude -p run with a turn's cost, and its session file stays in the project's history.claude --plugin-dir ./decision-tree
claude plugin validate .
In claude -p, set DECISION_TREE_WAIT=1 so the run waits for the fork before it exits.
hooks/register.jsx 422 lines1// decision-tree: when Claude's reply ends by asking you something, a fork of the
2// conversation explains the choice in a side pane. Then you can explore
3// any option: the mod forks the real session at that question, plays the answer out in
4// the fork, and shows the next question. Each branch is its own session, so nothing you
5// explore reaches the main conversation.
6
7const PANE = 'question'
8
9// The answer every explanation is asked for, so the pane can draw it
10const SHAPE = '{"question": "the question in one plain line", ' +
11 '"context": "one or two sentences: what I need to know to decide", ' +
12 '"options": [{"label": "2 to 5 words", "detail": "what it means", ' +
13 '"leadsTo": "what happens next if I pick it", "pros": ["short"], "cons": ["short"]}], ' +
14 '"recommendation": {"label": "the label of one option", "why": "one sentence"}}'
15
16// The tree for the latest question. A node is { sid, msgId, answer, status, data, children }:
17// sid and msgId say which session, cut at which message, a branch below it forks from
18let root = null
19// Option indexes from the root to the node the pane shows
20let path = []
21// What the pane shows: 'node' (one question) or 'map' (the whole tree)
22let view = 'node'
23// Where the session transcripts live, from the Stop hook
24let transcriptPath = ''
25
26// Cheap check, no model: does the end of the reply ask something?
27// A question mark in the last paragraph, or a numbered / lettered / bulleted list of options.
28function looksLikeQuestion(answer) {
29 const tail = answer.trim().slice(-1500)
30 const paragraphs = tail.split(/\n\s*\n/)
31 const last = paragraphs[paragraphs.length - 1] || ''
32 if (/\?\s*(\*\*|_|`)?\s*$/.test(last) || /\?\s*$/m.test(last)) return true
33 const options = tail.match(/^\s*(\d+[.)]|[A-D][.):]|[-*]\s+\*\*Option|Option [A-D1-9])/gm) || []
34 return options.length >= 2 && /\b(which|prefer|choose|pick|want|should I|let me know)\b/i.test(tail)
35}
36
37// The gate: a small model confirms the question needs a real decision from the user
38async function needsDecision($, answer, model) {
39 const r = await $.model.complete({
40 model,
41 system: 'You read the end of an AI coding assistant reply. Answer YES if it ends by asking ' +
42 'the user to decide, choose, or supply something before work can go on. Answer NO for ' +
43 'rhetorical questions, pleasantries such as "anything else?", or a reply that just reports. ' +
44 'Reply with YES or NO only.',
45 prompt: answer.trim().slice(-2000),
46 maxTokens: 5,
47 timeoutMs: 20000,
48 })
49 return r.isAnswered && /^\s*yes/i.test(r.text)
50}
51
52// The first explanation: a model fork replays the conversation and returns the choice as JSON
53async function explainRoot($, answer) {
54 const node = { sid: await $.session.id(), answer, status: 'thinking', children: [] }
55 root = node
56 path = []
57 view = 'node'
58 await openPane($, false)
59 $.ui.invalidate('ui.render')
60
61 const r = await $.model.fork({
62 prompt: 'Your last reply ended by asking me a question. Do not answer it and do not use tools. ' +
63 'Help me answer it. Reply with JSON only, no code fence, in this shape: ' + SHAPE + '. ' +
64 'Use the options you offered; if you offered none, give the 2 to 4 answers I would most ' +
65 'likely pick. At most 2 pros and 2 cons each. No em or en dashes.',
66 })
67 settle(node, r.isAnswered ? r.text : '', r.isAnswered ? 'the fork did not return JSON' : r.reason)
68 if (root !== node) return
69 $.ui.invalidate('ui.render')
70}
71
72// Explore option i of the node the pane shows: fork the real session at that node's
73// question, tell the fork we picked i, and let it play that out with read-only tools.
74// With own, the answer is yours: it joins the question's options first, then is explored
75async function explore($, i, own) {
76 const tree = root
77 const node = nodeAt(path)
78 if (own && node && node.data && node.data.options) {
79 const options = node.data.options
80 i = options.findIndex(o => o.label.toLowerCase() === own.toLowerCase())
81 if (i < 0) i = options.push({ label: own, mine: true }) - 1
82 }
83 const option = node && node.data && node.data.options && node.data.options[i]
84 if (!option) return $.ui.toast('decision-tree: no option ' + String.fromCharCode(65 + i) + ' here')
85 path = [...path, i]
86 view = 'node'
87 $.ui.invalidate('ui.render')
88 if (node.children[i]) return
89
90 const child = { label: option.label, status: 'exploring', children: [], startedAt: Date.now() }
91 node.children[i] = child
92
93 // The message to cut the parent session at: the reply that asked this question
94 if (!node.msgId) node.msgId = await lastAssistantId($, node.sid, node.answer)
95 if (!node.msgId) $.ui.log('decision-tree: message id not found, forking from the latest point')
96 const args = ['claude', '-p', whatIf(option),
97 '--resume', node.sid, '--fork-session', '--output-format', 'json',
98 '--permission-mode', 'default', '--allowedTools=Read,Grep,Glob',
99 '--disallowedTools=Bash,Edit,Write,NotebookEdit']
100 if (node.msgId) args.push('--resume-session-at', node.msgId)
101
102 let out = null
103 try {
104 const run = await $.process.run(args, {
105 cwd: await $.session.cwd(),
106 env: { DECISION_TREE_CHILD: '1' },
107 timeoutMs: 300000,
108 })
109 out = resultOf(run.stdout)
110 if (!out) child.reason = (run.stderr || run.stdout || 'no output').slice(0, 300)
111 } catch (err) {
112 child.reason = String(err)
113 }
114 if (root !== tree) return
115 if (out) {
116 child.sid = out.session_id
117 child.answer = out.result
118 settle(child, out.result || '', 'the branch did not return JSON')
119 } else {
120 child.status = 'failed'
121 }
122 child.ms = Date.now() - child.startedAt
123 $.ui.invalidate('ui.render')
124}
125
126// The prompt a branch gets: our answer, then the same JSON as the root plus what happened
127function whatIf(option) {
128 return 'WHAT-IF BRANCH: this is a forked copy of the session for exploring one answer. ' +
129 'The real session is not affected. My answer to your question: "' + option.label + '"' +
130 (option.detail ? ' (' + option.detail + ')' : '') + '. Carry on as if I picked it, but change no files and run no ' +
131 'commands; you may Read, Grep and Glob to make the plan concrete. Then reply with JSON only, ' +
132 'no code fence, in this shape, plus two more fields: "outcome" (two to four sentences: what ' +
133 'you would do and how things stand after) and "steps" (up to 4 short concrete steps). ' +
134 'The question and options are the NEXT decision you would need from me after this; if there ' +
135 'is none, give an empty question and options []. Shape: ' + SHAPE + '. No em or en dashes.'
136}
137
138// Turn a reply into the node's data, or mark the node failed
139function settle(node, text, reason) {
140 const data = parseJson(text)
141 if (data && Array.isArray(data.options)) {
142 node.data = data
143 node.status = 'ready'
144 } else {
145 node.status = 'failed'
146 node.reason = node.reason || reason
147 }
148}
149
150// claude -p --output-format json prints the result object, or the whole event list
151function resultOf(stdout) {
152 try {
153 const out = JSON.parse(stdout)
154 const res = Array.isArray(out) ? out.find(x => x.type === 'result') : out
155 return res && res.session_id ? res : null
156 } catch {
157 return null
158 }
159}
160
161// Find the id of the assistant message that asked the question, in the session's transcript
162async function lastAssistantId($, sid, answer) {
163 // The Stop hook's transcript path names the folder; without it, rebuild it from the cwd
164 const folder = transcriptPath ? transcriptPath.replace(/\/[^/]+$/, '') :
165 (await $.env.get('HOME')) + '/.claude/projects/' + (await $.session.cwd()).replace(/[^a-zA-Z0-9]/g, '-')
166 const file = folder + '/' + sid + '.jsonl'
167 if (!(await $.fs.exists(file))) return undefined
168 const lines = (await $.fs.read(file)).split('\n')
169 const want = (answer || '').trim().slice(-60)
170 for (let n = lines.length - 1; n >= 0; n--) {
171 let row
172 try { row = JSON.parse(lines[n]) } catch { continue }
173 if (row.type !== 'assistant' || row.isSidechain) continue
174 const text = (row.message.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
175 if (text && text.trim().endsWith(want)) return row.uuid
176 }
177 return undefined
178}
179
180// The map: one row per question and per option, drawn as a tree with the path to each row
181// Question: Which storage?
182// ├ A JSON file on disk ▸
183// │ └ Q: How should I build it?
184// └ B SQLite ★
185function mapRows(node, p, prefix, rows) {
186 const data = node.data
187 if (!data || !data.question) return rows
188 rows.push({ text: prefix + 'Q: ' + data.question, path: p, kind: 'question' })
189 const pick = (data.recommendation && data.recommendation.label) || ''
190 data.options.forEach((o, i) => {
191 const last = i === data.options.length - 1
192 const child = node.children[i]
193 const mark = !child ? '' : child.status === 'exploring' ? ' ...' : child.status === 'failed' ? ' ✗' : ' ▸'
194 rows.push({
195 text: prefix + (last ? '└ ' : '├ ') + String.fromCharCode(65 + i) + ' ' + o.label + (o.label === pick ? ' ★' : '') + (o.mine ? ' ✎' : '') + mark,
196 path: child ? [...p, i] : null,
197 kind: child ? 'explored' : 'option',
198 })
199 if (child && child.status === 'ready') mapRows(child, [...p, i], prefix + (last ? ' ' : '│ '), rows)
200 })
201 return rows
202}
203
204function nodeAt(p) {
205 let node = root
206 for (const i of p) node = node && node.children[i]
207 return node
208}
209
210// Continue a branch for real: a new Claude session forked from it, in a Herdr pane if there is one
211async function continueBranch($, node) {
212 if (!node || !node.sid) return
213 const command = 'claude --resume ' + node.sid + ' --fork-session'
214 if ((await $.env.get('HERDR_ENV')) === '1') {
215 try {
216 const split = await $.process.run(['herdr', 'pane', 'split', '--current', '--direction', 'down',
217 '--cwd', await $.session.cwd(), '--no-focus'])
218 const id = JSON.parse(split.stdout).result.pane.pane_id
219 await $.process.run(['herdr', 'pane', 'run', id, command])
220 return $.ui.toast('decision-tree: branch opened in pane ' + id)
221 } catch (err) {
222 $.ui.log('could not open a Herdr pane: ' + err)
223 }
224 }
225 await $.ui.copy({ text: command })
226 $.ui.toast('decision-tree: copied ' + command)
227}
228
229// Open the side pane. Unasked, Claude Code only places it on a wide terminal.
230async function openPane($, asked) {
231 // focus takes only true, so leave it out for an unasked open
232 const pane = { id: PANE, title: 'Decision tree' }
233 // Asked from /explain: take the keys, Escape closes it, and ask for room above the prompt
234 const opened = await $.ui.open(asked ? { ...pane, focus: true, closeOnEscape: true, rows: 30 } : pane)
235 if (!opened.isPlaced) $.ui.toast('decision-tree: run /explain to see the pane')
236}
237
238// The fork may wrap its JSON in prose or a fence; take the outermost braces
239function parseJson(text) {
240 const start = text.indexOf('{')
241 const end = text.lastIndexOf('}')
242 if (start < 0 || end <= start) return null
243 try {
244 return JSON.parse(text.slice(start, end + 1))
245 } catch {
246 return null
247 }
248}
249
250export function register(on, options) {
251 on('session.start', async ($, e, next) => {
252 await $.command.register({
253 name: 'explain',
254 description: 'Show the decision tree; A to H explores an option, other <answer> explores your own',
255 argumentHint: '[A-H | other <answer> | map | back | top | continue]',
256 immediate: true,
257 })
258 return next(e)
259 })
260
261 // Remember where transcripts are written, to find message ids to fork at
262 on('classic.Stop', async ($, e, next) => {
263 if (e.transcript_path) transcriptPath = e.transcript_path
264 return next(e)
265 }).catch(($, e, next) => (next.called ? undefined : next(e)))
266
267 // After each of Claude's replies: regex check, then the gate, then the fork
268 on('turn.complete', async ($, e, next) => {
269 const result = await next(e)
270 // A branch's own claude -p run never explains its own replies
271 if ((await $.env.get('DECISION_TREE_CHILD')) === '1') return result
272 if (e.agentId || e.reason !== 'answer' || !looksLikeQuestion(e.answer)) return result
273 if (!(await needsDecision($, e.answer, options.gateModel || 'haiku'))) return result
274 // Run the fork without holding up the prompt; in a -p run, wait so the process lives on
275 const work = explainRoot($, e.answer).catch(err => $.ui.log('decision-tree failed: ' + err))
276 if ((await $.env.get('DECISION_TREE_WAIT')) === '1') await work
277 return result
278 })
279
280 // /explain opens the pane; /explain B explores option B; back, top and continue move around
281 on('command.run', { command: 'explain' }, async ($, e) => {
282 if (!root) return { text: 'No question to explain yet.' }
283 const raw = (e.args || '').trim()
284 const arg = raw.toLowerCase()
285 view = arg === 'map' ? 'map' : 'node'
286 if (arg === 'back') path = path.slice(0, -1)
287 else if (arg === 'top') path = []
288 else if (arg === 'continue') await continueBranch($, nodeAt(path))
289 else if (/^[a-h]$/.test(arg) || /^other\s+\S/.test(arg)) {
290 const own = /^other\s/.test(arg) ? raw.replace(/^other\s+/i, '') : undefined
291 const work = explore($, own ? -1 : arg.charCodeAt(0) - 97, own).catch(err => $.ui.log('explore failed: ' + err))
292 if ((await $.env.get('DECISION_TREE_WAIT')) === '1') await work
293 }
294 $.ui.invalidate('ui.render')
295 await openPane($, true)
296 return {}
297 })
298
299 // The pane: where you are in the tree, what the branch did, then the question and its options
300 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
301 const { Box, Text, Button, Input } = $.ui.resolve(e)
302 const width = Math.max(30, (e.props.bodyColumns || 60) - 2)
303 const node = nodeAt(path)
304
305 if (!root || !node) return <Text dimColor>Waiting for Claude to ask you something.</Text>
306
307 // The breadcrumb: Question › B SQLite › A WAL mode
308 const crumbs = ['Question']
309 let walk = root
310 for (const i of path) {
311 crumbs.push(String.fromCharCode(65 + i) + ' ' + walk.children[i].label)
312 walk = walk.children[i]
313 }
314 const nav = (
315 <Box gap={2} marginTop={1}>
316 {path.length ? <Button key="back" label="Back" onPress={() => { path = path.slice(0, -1); $.ui.invalidate('ui.render') }} /> : null}
317 {path.length > 1 ? <Button key="top" label="Top" onPress={() => { path = []; $.ui.invalidate('ui.render') }} /> : null}
318 {node.sid && path.length ? <Button key="cont" label="Continue branch" onPress={() => continueBranch($, node)} /> : null}
319 <Button key="map" label="Map" onPress={() => { view = 'map'; $.ui.invalidate('ui.render') }} />
320 <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
321 </Box>
322 )
323
324 if (node.status === 'thinking') return <Text color="yellow">Reading the conversation to explain the question...</Text>
325
326 // The map: the whole tree at once. Press a question or an explored option to open it.
327 if (view === 'map') {
328 const here = path.join('.')
329 return (
330 <Box flexDirection="column" width={width}>
331 <Text bold>Map · ★ recommended · ▸ explored · ✎ yours</Text>
332 <Text>{' '}</Text>
333 {mapRows(root, [], '', []).map((row, n) => {
334 // Mark the option you are on, or the first question when you are at the top
335 const isHere = row.path && row.path.join('.') === here && (row.kind !== 'question' || !row.path.length)
336 const text = (isHere ? '● ' : ' ') + row.text
337 if (!row.path) return <Text key={'r' + n} dimColor>{text}</Text>
338 return <Button key={'r' + n} plain label={text}
339 onPress={() => { path = row.path; view = 'node'; $.ui.invalidate('ui.render') }} />
340 })}
341 <Box gap={2} marginTop={1}>
342 <Button key="node" label="Back to question" onPress={() => { view = 'node'; $.ui.invalidate('ui.render') }} />
343 <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
344 </Box>
345 </Box>
346 )
347 }
348 if (node.status === 'exploring') {
349 return (
350 <Box flexDirection="column" width={width}>
351 <Text dimColor>{crumbs.join(' › ')}</Text>
352 <Text color="yellow">{'Forking the session and playing out "' + node.label + '"... (read-only, about 20 to 60s)'}</Text>
353 {nav}
354 </Box>
355 )
356 }
357 if (node.status === 'failed') {
358 return (
359 <Box flexDirection="column" width={width}>
360 <Text dimColor>{crumbs.join(' › ')}</Text>
361 <Text color="red">Could not explain it: {String(node.reason)}</Text>
362 {nav}
363 </Box>
364 )
365 }
366
367 const data = node.data
368 const pick = (data.recommendation && data.recommendation.label) || ''
369 return (
370 <Box flexDirection="column" width={width}>
371 <Text dimColor>{crumbs.join(' › ')}</Text>
372 {path.length ? (
373 <Box borderStyle="quote" borderColor="magenta" paddingLeft={1} flexDirection="column" marginBottom={1}>
374 <Text bold color="magenta">{'If you pick ' + node.label}</Text>
375 {data.outcome ? <Text>{data.outcome}</Text> : null}
376 {(data.steps || []).map((s, j) => <Text key={'s' + j} color="blue">{(j + 1) + '. ' + s}</Text>)}
377 <Text dimColor>{'branch session ' + String(node.sid).slice(0, 8) + (node.ms ? ' · ' + Math.round(node.ms / 1000) + 's' : '')}</Text>
378 </Box>
379 ) : null}
380 {data.question ? (
381 <Box borderStyle="double" borderColor="cyan" paddingX={1} flexDirection="column">
382 <Text bold color="cyan">{data.question}</Text>
383 {data.context ? <Text dimColor>{data.context}</Text> : null}
384 </Box>
385 ) : <Text dimColor>No further decision on this branch.</Text>}
386 {data.options.map((o, i) => {
387 const isPick = o.label === pick && !o.mine
388 const child = node.children[i]
389 const color = isPick ? 'green' : o.mine ? 'yellow' : 'gray'
390 return (
391 <Box key={'opt' + i} flexDirection="column">
392 <Text dimColor>{' │'}</Text>
393 <Box borderStyle={isPick ? 'bold' : 'round'} borderColor={color} paddingX={1} flexDirection="column">
394 <Text bold color={color === 'gray' ? 'white' : color}>{String.fromCharCode(65 + i) + ' ' + o.label + (isPick ? ' ★ recommended' : '') + (o.mine ? ' ✎ your answer' : '')}</Text>
395 {o.detail ? <Text>{o.detail}</Text> : null}
396 {o.leadsTo ? <Text color="blue">{'→ ' + o.leadsTo}</Text> : null}
397 {(o.pros || []).map((p, j) => <Text key={'p' + j} color="green">{'+ ' + p}</Text>)}
398 {(o.cons || []).map((c, j) => <Text key={'c' + j} color="red">{'- ' + c}</Text>)}
399 <Button key={'x' + i} label={child ? (child.status === 'exploring' ? 'Exploring...' : 'Open branch ▸') : 'Explore ▸'}
400 onPress={() => explore($, i)} />
401 </Box>
402 </Box>
403 )
404 })}
405 {data.question ? (
406 <Box flexDirection="column">
407 <Text dimColor>{' │'}</Text>
408 <Box borderStyle="round" borderColor="gray" borderDimColor paddingX={1} flexDirection="column">
409 <Text dimColor bold>{String.fromCharCode(65 + data.options.length) + ' Other'}</Text>
410 <Input key={'other.' + path.join('.')} placeholder="type your own answer" submitLabel="explore"
411 onSubmit={value => { if (value.trim()) explore($, -1, value.trim()) }} />
412 </Box>
413 </Box>
414 ) : null}
415 {pick ? <Text>{' '}</Text> : null}
416 {pick ? <Text color="green">{'★ ' + pick + ': ' + data.recommendation.why}</Text> : null}
417 {nav}
418 </Box>
419 )
420 })
421}
422