Read-only side chat: /aside opens a pane beside the transcript where you ask about the session so far; answers come from a tool-less fork of the session's own…

A read-only side chat for Claude Code, as a mod (a plugin built on function hooks).
/aside opens a pane beside the transcript. You ask questions about the session so far; a tool-less fork of the session's own transcript answers, sharing the main thread's prompt cache. Nothing is written back into the main thread: the model in the main session never sees your side questions or their answers, and the side chat cannot call tools, edit files, or submit prompts.
┌ transcript ─────────────────────────┬ aside ──────────────────────────────────┐
│ ❯ refactor the auth middleware │ 2 questions │
│ ● Reading src/auth/*.ts ... │ you: which files has it touched so far? │
│ Edit(src/auth/session.ts) │ aside: src/auth/session.ts and │
│ ... │ src/auth/index.ts; tests are untouched. │
│ │ fork · 1244 ms · read 72848 · new 242 │
│ │ │
│ │ you: is it planning to change the tests? │
│ │ aside: thinking… │
│ │ > _ │
│ │ [ Clear ] [ Close ] │
└─────────────────────────────────────┴──────────────────────────────────────────┘
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Without the flag the module is not loaded and /aside does not exist. Function hooks are early access and the API changes between releases; this mod is validated and tested against 2.1.270 (see docs/investigation/).From the marketplace in this repository:
/plugin marketplace add JayDoubleu/aside
/plugin install aside@aside
Or straight from a checkout:
git clone https://github.com/JayDoubleu/aside
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./aside
/aside | open the pane |
/aside what has changed so far? | open it and ask |
| Tab | put the cursor in the pane's input (the pane opens focused, but the cursor is not in the field yet on 2.1.270) |
| Enter | ask |
| Esc | give the keys back to the composer |
| ctrl+x tab | focus the pane again |
| ctrl+x x | close the pane |
Every answer's footer says where it came from and what it cost:
fork · 1244 ms · read 72848 · new 242 · out 100: a fork over the transcript. read is the prompt-cache read of the whole session prefix, billed at the cache-read rate; new is what was added; out the reply.live · 800 ms · 5120 chars of transcript sent uncached: asked before the session's first turn had completed, when there is no transcript snapshot to fork yet, so the question was answered by a plain completion over the transcript's text (see liveFallback).Asked while a later turn is running, a fork answers from the last completed turn: you get the state as of Claude's previous reply, not the half-finished one.
Set under /config or in settings.json as pluginConfigs["aside@aside"].options:
| option | default | |
|---|---|---|
liveFallback | true | When no turn has completed yet (the session's first turn): on answers at once through $.model.complete over $.session.messages() (uncached, capped at 60,000 characters of transcript, marked live); off queues the question and a fork answers it when the turn ends. |
liveModel | haiku | Model for live answers (a --model value). Forks always use the session's model. |
maxHistory | 8 | Earlier side exchanges carried in each question's prompt. |
claude plugin validate . --strict prints the module's complete static surface:
hooks: session.start, command.run{command=aside}, turn.complete, ui.render{component=Pane}, ui.input{plugin=aside, element=q}, ui.press{plugin=aside}
calls: $.clock.now, $.command.register, $.model.complete, $.model.fork, $.session.messages, $.ui.close, $.ui.invalidate, $.ui.open, $.ui.resolve
No fs, process, http, store, tool or prompt verbs, so the module cannot reach the working tree, the network, or the main thread's prompt. The engine's fork denies every tool call and never writes to the transcript. The /aside command answers {} (a { text } answer would become transcript messages the model reads). An organization can refuse the module on that listing in a plugin.register hook.
A fork costs one prompt-cache read of the whole session prefix plus a few dozen new tokens: measured at 72,848 cache-read tokens on a 73k-token session, about $0.008 on Haiku and proportionally more on the session's model. Right after /compact the summary goes in uncached (~3k tokens). Live answers send the transcript text as fresh input to liveModel.
npm run validate # claude plugin validate, offline, no API key
npm test # node --test, no API key
npm run eval # claude plugin eval: real sessions, needs credentials, spends a few cents
The eval (evals/) checks the mod is invisible to the model: a trivial prompt gets the same reply with and without the plugin loaded. It passed 2/2 runs on each arm on 2.1.270 (docs/investigation/evidence/eval-invisible-to-the-model.json).
Tests run on Node 22's native TypeScript support against a small fake engine (tests/harness.ts) that dispatches events through the registered hooks the way Claude Code does. claude plugin test is 2.1.271+ and not used yet.
Typing: run /plugin-types in a session to write .claude/types/claude-code.d.ts next to tsconfig.json.
docs/investigation/ holds the measurements this design rests on: FINDINGS.md (nine questions, each answered with debug-log lines and rendered screens), DESIGN.md, the throwaway probe plugins, and the evidence.
MIT
hooks/register.ts 262 lines1import type { On, PluginOptions } from 'claude-code'
2
3/**
4 * aside: a read-only side chat beside the transcript.
5 *
6 * `/aside [question]` opens the pane (focused when the composer is empty);
7 * Tab puts the focus ring on the pane's input, Enter asks, Esc hands the keys
8 * back to the composer, ctrl+x tab focuses the pane again.
9 *
10 * Each question is one `$.model.fork` over the session's own transcript as
11 * of the last completed turn: tool-less, off the transcript, sharing the
12 * main thread's prompt cache; it answers during a later turn too, without
13 * seeing the turn in flight. A fork takes exactly one user message, so the
14 * side chat's own history is rendered into each prompt.
15 *
16 * Until the session's first turn has completed there is no snapshot to fork
17 * (`$.model.fork` resolves null at once). Then, with `liveFallback` on, the
18 * question is answered by `$.model.complete` over the text of
19 * `$.session.messages()`: immediate, uncached, without the session's system
20 * prompt, and marked "live" in the pane. With it off, the question queues
21 * and a fork answers it when the turn ends.
22 *
23 * What this module calls on `$` (the static listing `claude plugin validate`
24 * prints): clock.now, command.register, model.complete, model.fork,
25 * session.messages, ui.close, ui.invalidate, ui.open, ui.resolve. All reads
26 * and drawing: no fs, process, http, store, tool or prompt verbs, so the mod
27 * cannot reach the working tree, the network, or the main thread's prompt.
28 */
29
30type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
31
32type Exchange = {
33 q: string
34 a: string | null
35 askedAt: number
36 ms?: number
37 note?: string
38 kind?: 'fork' | 'live'
39 queued?: true
40}
41
42type Host = {
43 fork: (prompt: string) => Promise<{ text: string; usage: Usage } | null>
44 messages: () => Promise<readonly { role: 'user' | 'assistant'; text: string }[]>
45 complete: (prompt: string) => Promise<string>
46 invalidate: () => void
47 open: () => Promise<void>
48 close: () => Promise<void>
49 now: () => number
50}
51
52export const PANE_ID = 'aside'
53export const DEFAULTS = { liveFallback: true, liveModel: 'haiku', maxHistory: 8 } as const
54export const MAX_CHARS_PER_ANSWER = 1200
55export const LIVE_TRANSCRIPT_CHARS = 60000
56const LIVE_SYSTEM =
57 'You answer read-only side questions about a Claude Code session from its transcript. No tools; do not continue the task; answer briefly in plain prose.'
58
59const clip = (text: string): string =>
60 text.length > MAX_CHARS_PER_ANSWER ? `${text.slice(0, MAX_CHARS_PER_ANSWER)}…` : text
61
62export function register(on: On, options: PluginOptions = {}) {
63 const liveFallback = typeof options.liveFallback === 'boolean' ? options.liveFallback : DEFAULTS.liveFallback
64 const liveModel = typeof options.liveModel === 'string' && options.liveModel !== '' ? options.liveModel : DEFAULTS.liveModel
65 const maxHistory =
66 typeof options.maxHistory === 'number' && options.maxHistory >= 1 ? Math.floor(options.maxHistory) : DEFAULTS.maxHistory
67
68 let host: Host | null = null
69 let history: Exchange[] = []
70 let queued: Exchange[] = []
71 let inFlight = 0
72
73 const promptOf = (question: string): string => {
74 const prior = history
75 .filter(x => x.a !== null && x.kind !== undefined)
76 .slice(-maxHistory)
77 .map(x => `Q: ${x.q}\nA: ${x.a}`)
78 .join('\n\n')
79 return [
80 'This is a read-only side question about the conversation above. Answer from the transcript so far.',
81 'Do not use tools, do not propose edits, do not continue the main task; answer briefly in plain prose.',
82 prior === '' ? '' : `Earlier side questions and their answers:\n\n${prior}`,
83 `Side question: ${question}`,
84 ]
85 .filter(part => part !== '')
86 .join('\n\n')
87 }
88
89 async function answerLive(h: Host, entry: Exchange): Promise<void> {
90 const messages = await h.messages()
91 if (messages.length === 0) {
92 entry.a = '(nothing to ask about yet: the transcript is empty)'
93 return
94 }
95 let rendered = messages.map(m => `${m.role.toUpperCase()}: ${m.text}`).join('\n\n')
96 if (rendered.length > LIVE_TRANSCRIPT_CHARS) rendered = `…${rendered.slice(-LIVE_TRANSCRIPT_CHARS)}`
97 const text = await h.complete(`Transcript so far:\n\n${rendered}\n\n${promptOf(entry.q)}`)
98 entry.a = clip(text)
99 entry.kind = 'live'
100 entry.note = `${rendered.length} chars of transcript sent uncached`
101 }
102
103 function run(h: Host, entry: Exchange): void {
104 inFlight += 1
105 void h
106 .fork(promptOf(entry.q))
107 .then(async r => {
108 if (r !== null) {
109 entry.a = clip(r.text)
110 entry.kind = 'fork'
111 entry.note = `read ${r.usage.cache_read_input_tokens} · new ${r.usage.input_tokens + r.usage.cache_creation_input_tokens} · out ${r.usage.output_tokens}`
112 return
113 }
114 if (liveFallback) {
115 await answerLive(h, entry)
116 return
117 }
118 entry.queued = true
119 queued.push(entry)
120 })
121 .catch((err: unknown) => {
122 entry.a = `(no answer: ${err instanceof Error ? err.message : String(err)})`
123 })
124 .then(() => {
125 if (entry.a !== null) entry.ms = h.now() - entry.askedAt
126 inFlight -= 1
127 h.invalidate()
128 })
129 }
130
131 function ask(question: string): void {
132 const h = host
133 const q = question.trim()
134 if (!h || q === '') return
135 const entry: Exchange = { q, a: null, askedAt: h.now() }
136 history = [...history, entry]
137 h.invalidate()
138 run(h, entry)
139 }
140
141 on('session.start', async ($, e, next) => {
142 host = {
143 fork: prompt => $.model.fork({ prompt }),
144 messages: () => $.session.messages(),
145 complete: prompt => $.model.complete({ model: liveModel, maxTokens: 400, system: LIVE_SYSTEM, prompt }),
146 invalidate: () => $.ui.invalidate('ui.render'),
147 open: () => $.ui.open({ id: PANE_ID, title: 'aside', focus: true }),
148 close: () => $.ui.close({ id: PANE_ID }),
149 now: () => $.clock.now(),
150 }
151 await $.command.register({
152 name: 'aside',
153 description: 'Read-only side chat about this session (opens a pane; Tab focuses its input)',
154 argumentHint: '[question]',
155 immediate: true,
156 })
157 return next(e)
158 })
159
160 on('command.run', { command: 'aside' }, async ($, e, next) => {
161 if (!host) return next(e)
162 await host.open()
163 ask(e.args)
164 return {}
165 })
166
167 // Questions that found no snapshot (liveFallback off) are answered by a
168 // fork as soon as the turn in flight ends.
169 on('turn.complete', ($, e, next) => {
170 const h = host
171 const waiting = queued
172 queued = []
173 if (h) {
174 for (const entry of waiting) {
175 entry.queued = undefined
176 run(h, entry)
177 }
178 }
179 return next(e)
180 })
181
182 on('ui.render', { component: 'Pane' }, ($, e, next) => {
183 if (e.requestId !== PANE_ID) return next(e)
184 const { Box, Text, Input, Button } = $.ui.resolve(e)
185 const width = Math.max(20, e.props.bodyColumns - 1)
186 const shown = history.slice(-maxHistory)
187 const rows = shown.map((x, i) =>
188 Box({
189 key: `x${i}`,
190 flexDirection: 'column',
191 marginTop: i === 0 ? 0 : 1,
192 width,
193 children: [
194 Text({ bold: true, color: 'cyan', wrap: 'wrap', children: `you: ${x.q}` }),
195 x.a === null
196 ? Text({ dimColor: true, children: x.queued ? 'aside: waiting for this turn to end…' : 'aside: thinking…' })
197 : Text({ wrap: 'wrap', children: `aside: ${x.a}` }),
198 x.a !== null && x.ms !== undefined
199 ? Text({ dimColor: true, children: `${x.kind ?? 'error'} · ${x.ms} ms${x.note ? ` · ${x.note}` : ''}` })
200 : null,
201 ],
202 }),
203 )
204 const header =
205 history.length === 0
206 ? 'Ask about the session so far. Read-only: no tools, nothing goes into the main thread.'
207 : `${history.length} question${history.length === 1 ? '' : 's'}${inFlight ? ` · ${inFlight} pending` : ''}`
208 return Box({
209 flexDirection: 'column',
210 width,
211 children: [
212 Text({ dimColor: true, children: header }),
213 ...rows,
214 Box({
215 marginTop: 1,
216 flexDirection: 'column',
217 children: [
218 Input({
219 key: 'q',
220 label: '> ',
221 placeholder: e.props.isFocused ? 'Tab to focus, type, Enter to ask, Esc to leave' : 'ctrl+x tab focuses the pane',
222 submitLabel: 'ask',
223 onSubmit: () => undefined,
224 }),
225 Box({
226 flexDirection: 'row',
227 gap: 1,
228 children: [
229 Button({ key: 'clear', label: 'Clear', onPress: () => undefined }),
230 Button({ key: 'close', label: 'Close', onPress: () => undefined }),
231 ],
232 }),
233 ],
234 }),
235 ],
236 })
237 })
238
239 // The pane's events are answered here rather than in the element closures:
240 // a closure handle belongs to one drawing, and a submit that lands during a
241 // redraw is dropped by core ("no handler is held under handle N"), while the
242 // hook still sees the event and its value.
243 on('ui.input', { plugin: 'aside', element: 'q' }, ($, e, next) => {
244 if (e.kind !== 'submit') return next(e)
245 ask(e.value)
246 return { element: e.element, value: e.value }
247 })
248
249 on('ui.press', { plugin: 'aside' }, ($, e, next) => {
250 if (e.element === 'clear') {
251 history = []
252 host?.invalidate()
253 return { element: e.element }
254 }
255 if (e.element === 'close') {
256 void host?.close()
257 return { element: e.element }
258 }
259 return next(e)
260 })
261}
262