SLOPSHOPPER

aside

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…

newpanecommandmodel
★ 2v0.1.0MITupdated 2026-09-14JayDoubleu/aside
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aside
│ ┃ aside ✕ › fix the failing auth test and add an audit log call │ ┃ Ask about the session so far. Read-only: no │ ┃ nothing goes into the main thread. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ > : ctrl+x tab focuses the pane ⏎ ask ⏺ Update(src/auth.ts) │ ┃ [ Clear ] [ Close ] ⎿ 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 │ │ › /aside │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aside
Ask about the session so far. Read-only: no tools, nothing goes into the main thread. > : ctrl+x tab focuses the pane ⏎ ask [ Clear ] [ Close ]
README

aside

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 ]                      │
└─────────────────────────────────────┴──────────────────────────────────────────┘

Requirements

  • Claude Code 2.1.270 or newer with function hooks enabled: 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/).
  • A terminal at least 110 columns wide for the pane to dock beside the transcript; narrower, it opens inline above the prompt.

Install

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

Use

/asideopen the pane
/aside what has changed so far?open it and ask
Tabput the cursor in the pane's input (the pane opens focused, but the cursor is not in the field yet on 2.1.270)
Enterask
Escgive the keys back to the composer
ctrl+x tabfocus the pane again
ctrl+x xclose 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.

Options

Set under /config or in settings.json as pluginConfigs["aside@aside"].options:

optiondefault
liveFallbacktrueWhen 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.
liveModelhaikuModel for live answers (a --model value). Forks always use the session's model.
maxHistory8Earlier side exchanges carried in each question's prompt.

What "read-only" means here

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.

Cost

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.

Development

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.

License

MIT

Source 1 files
hooks/register.ts 262 lines
1import 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