A side pane (/side) where you talk directly to a subagent forked from the main conversation, while the main conversation keeps working.

A second conversation beside your main one. Type /side and a pane opens next to the transcript; the first thing you say there forks a subagent from the main conversation as it stands, so it already knows everything you have discussed. You talk to that fork directly in the pane while the main conversation keeps working, and neither waits for the other.
At the prompt of a Claude Code terminal session:
/plugin install vkazas-side --marketplace VkaZas/claude-code-mods
Answer y to add the marketplace, then pick a scope (user is the first). From your shell instead (Claude Code 2.1.292 or later):
claude plugin install vkazas-side --marketplace VkaZas/claude-code-mods
/side opens the pane and puts the cursor in its input box. esc takes you back to the main prompt.The fork is a real subagent: each message is a model turn on your plan, and it can use tools like the main conversation can. It is told to avoid editing files the main conversation may be editing, and to say which files before it changes any.
| Option | Values | Default |
|---|---|---|
| Language | en, zh | en |
The language sets the pane's labels, and the instructions the fork gets with your first message (in en, it answers in the language you write in; in zh, in Chinese).
session.start: registers /side and starts a timer that drives the spinner while the fork works.command.run, for /side only: opens the pane.tool.call: when the caller is this pane's fork, notes which tool it is running, for the spinner line. Every call goes through unchanged.turn.complete and classic.SubagentStop: when the agent is this pane's fork, takes its answer and shows it in the pane. Everything else passes through unchanged.prompt.submit, session.receive and session.append: a notification that this pane's fork finished is dropped where it would start a main turn, consumed where it would be delivered, and cut to one line in the rare case it is appended. Notifications from any other agent, and your own prompts, go through unchanged.ui.render on its own pane: draws the conversation, the input and the two buttons./side 打开侧边面板,第一句话会从主对话当前状态 fork 出一个「分身」子代理,它记得 fork 之前的一切。你在面板里直接和分身对话,主对话照常干活,互不阻塞。分身的回复只留在面板里,不会唤醒主对话;需要时点「同步到主对话」把记录交给主对话,想让分身跟上主对话的新进展就点「重新 fork」。在 /config 里把 Language 设成 zh,面板就是中文,分身也会用中文回答。
hooks/register.tsx 522 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SideMessage } from '../types'
5
6const PANE = 'side-chat'
7const KEEP = 60
8const SHOWN = 30
9const SYNC_CHARS = 8000
10const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11
12const messages = atom({ plugin: 'vkazas-side', key: 'messages' } as const, [])
13const agentId = atom({ plugin: 'vkazas-side', key: 'agentId' } as const, null)
14const spawned = atom({ plugin: 'vkazas-side', key: 'spawned' } as const, [])
15const isBusy = atom({ plugin: 'vkazas-side', key: 'isBusy' } as const, false)
16const busySince = atom({ plugin: 'vkazas-side', key: 'busySince' } as const, null)
17const activity = atom({ plugin: 'vkazas-side', key: 'activity' } as const, '')
18const draft = atom({ plugin: 'vkazas-side', key: 'draft' } as const, '')
19const tick = atom({ plugin: 'vkazas-side', key: 'tick' } as const, 0)
20
21// ---------- words, in the language the options choose ----------
22
23type Lang = 'en' | 'zh'
24
25const TEXT = {
26 en: {
27 // The fork inherits the main conversation; this frames the first message it gets.
28 firstMessage: [
29 'You are a "side fork" forked from the main conversation. The user is talking to you directly in a side pane in Claude Code, and the main conversation may be busy with something else at the same time.',
30 '- Answer what the user says in the side pane, in the language they write in.',
31 '- You know the main conversation only up to the moment of the fork; you cannot see what it did after. When unsure, say so.',
32 '- Avoid changing files the main conversation may be changing; say which files before you change any.',
33 '- Keep answers short, and end with a one- or two-sentence conclusion.',
34 '- After each answer you pause; when the user follows up in the pane you wake with the conversation so far. No need to explain this to the user.',
35 '',
36 'The user says in the side pane:',
37 ].join('\n'),
38 // The main conversation hears that the side agent finished, not what it said:
39 // the reply lives in the pane, and Sync to main hands it over on request.
40 shortResult:
41 'The side fork finished. Its full reply is shown only in the side pane and was not passed to the main conversation; the user will press "Sync to main" if they want it. This notification needs no response.',
42 dropped: 'The side fork answered; the reply is in the /side pane',
43 consumed: 'vkazas-side: the side fork\'s reply stays in the side pane',
44 noText: '(no text reply this round)',
45 interrupted: '(this round was interrupted)',
46 errored: '(this round ended with an error)',
47 stillBusy: 'The side fork is still answering; send once it is done',
48 spawnDescription: 'side fork',
49 noFork: (why: string) => `Could not fork a side agent: ${why}`,
50 noStart: 'The side fork did not seem to start; try sending again',
51 forked: (id: string) => `Forked side agent ${id} from the main conversation`,
52 notSent: (why: string) => `Not delivered to the side fork: ${why}. Try "Re-fork".`,
53 me: 'User (side pane)',
54 side: 'Side fork',
55 nothingToSync: 'Nothing in the side pane to sync yet',
56 omitted: '…(earlier part omitted)',
57 syncHeader:
58 '[Transcript of the side fork conversation: the user wants you to know what was discussed and done there. For reference only; no separate reply needed.]',
59 syncFailed: (why: string) => `Sync failed: ${why}`,
60 syncedNote: 'Synced into the main conversation; it sees this next turn',
61 synced: 'Synced to the main conversation',
62 reforked: 'Disconnected this side fork; your next message forks a new one from where the main conversation is now',
63 reloaded: 'The mod reloaded; the reply to the last message was missed',
64 command: 'Open the side pane: talk directly to a subagent forked from the main conversation',
65 title: '✻ Side',
66 opened: 'Side pane opened.',
67 notOpened: (why: string) => `The side pane could not be shown: ${why}`,
68 answering: '● answering',
69 unforked: '○ not forked',
70 ready: '● ready',
71 introTitle: 'Talk to a side fork here',
72 intro: [
73 '· Your first message forks a subagent from the main conversation as it stands; it remembers everything up to the fork',
74 '· The main conversation and the side fork work independently; neither blocks the other',
75 '· Its replies stay here; press "Sync to main" to hand them over',
76 '· Press "Re-fork" to catch it up with the main conversation',
77 ],
78 folded: (n: number) => ` …${n} earlier messages folded`,
79 thinking: 'thinking',
80 waitPlaceholder: 'Wait for the answer before the next message…',
81 firstPlaceholder: 'Ask something; it forks a side agent first…',
82 placeholder: 'Say something to the side fork…',
83 send: 'Send',
84 hintWide: 'enter send · tab buttons · esc back to main',
85 hint: 'enter send · esc back to main',
86 syncButton: 'Sync to main',
87 reforkButton: 'Re-fork',
88 },
89 zh: {
90 firstMessage: [
91 '你是从主对话 fork 出来的「分身」。用户正在 Claude Code 的侧边面板里直接和你对话,主对话那边可能同时在做别的事。',
92 '- 直接回答用户在侧边面板里说的话,用中文。',
93 '- 你只知道 fork 那一刻之前的主对话;之后主对话做了什么你看不到,拿不准就直说。',
94 '- 尽量不要改主对话可能正在改的文件;要改文件先说清楚改哪些。',
95 '- 回答简洁,最后用一两句话给结论。',
96 '- 每次答完你会暂停;用户在面板里追问时,你会带着之前的对话被唤醒接着聊。不用跟用户解释你是「一次性」的。',
97 '',
98 '用户在侧边面板说:',
99 ].join('\n'),
100 shortResult:
101 '分身答完了。完整回复只显示在侧边面板里,没有转进主对话;用户需要时会点「同步到主对话」。这条通知不需要回应。',
102 dropped: '分身答完了,回复在 /side 面板里',
103 consumed: 'vkazas-side:分身的回复留在侧边面板里',
104 noText: '(这一轮没有文字回复)',
105 interrupted: '(这一轮被中断了)',
106 errored: '(这一轮出错结束)',
107 stillBusy: '分身还在回答上一句,等它说完再发',
108 spawnDescription: '侧边分身',
109 noFork: (why: string) => `没能 fork 出分身:${why}`,
110 noStart: '分身好像没启动起来,可以再发一次试试',
111 forked: (id: string) => `已从主对话 fork 出分身 ${id}`,
112 notSent: (why: string) => `没送到分身:${why}。可以点「重新 fork」再试`,
113 me: '用户(侧边面板)',
114 side: '分身',
115 nothingToSync: '侧边还没有对话可同步',
116 omitted: '……(前面省略)',
117 syncHeader: '[侧边分身的对话记录:用户想让你知道分身那边谈了什么、做了什么。仅供参考,不需要单独回复。]',
118 syncFailed: (why: string) => `同步失败:${why}`,
119 syncedNote: '已同步进主对话,主对话下一轮能看到',
120 synced: '已同步到主对话',
121 reforked: '已断开当前分身,下一句话会从主对话的最新状态重新 fork',
122 reloaded: 'mod 重新加载了:上一句的回复没接到',
123 command: '打开侧边分身面板:和一个 fork 了主对话上下文的子代理直接对话',
124 title: '✻ 分身',
125 opened: '侧边分身面板已打开。',
126 notOpened: (why: string) => `侧边分身面板没能显示:${why}`,
127 answering: '● 回答中',
128 unforked: '○ 未 fork',
129 ready: '● 待命',
130 introTitle: '在这里直接和「分身」对话',
131 intro: [
132 '· 第一句话会从主对话当前状态 fork 出分身,它记得 fork 之前的一切',
133 '· 主对话和分身各干各的,互不阻塞',
134 '· 分身的回复只留在这里,需要时点「同步到主对话」',
135 '· 想让分身跟上主对话的新进展,点「重新 fork」',
136 ],
137 folded: (n: number) => ` …更早的 ${n} 条已折叠`,
138 thinking: '分身在想',
139 waitPlaceholder: '分身回答完再发下一句…',
140 firstPlaceholder: '问点什么,会先 fork 出分身…',
141 placeholder: '跟分身说点什么…',
142 send: '发送',
143 hintWide: 'enter 发送 · tab 选按钮 · esc 回主对话',
144 hint: 'enter 发送 · esc 回主对话',
145 syncButton: '同步到主对话',
146 reforkButton: '重新 fork',
147 },
148}
149
150let lang: Lang = 'en'
151
152function shorten(text: string): string {
153 return text.replace(/<result>[\s\S]*?<\/result>/, `<result>${TEXT[lang].shortResult}</result>`)
154}
155
156// Is this text a notification from an agent this pane forked?
157async function fromSideAgent($: EngineInterface, text: string): Promise<boolean> {
158 const current = await read($, agentId)
159 const ids = [...((await read($, spawned)) ?? []), ...(current === null ? [] : [current])]
160 return ids.some(id => mentionsAgent(text, id))
161}
162
163function mentionsAgent(text: string, id: string): boolean {
164 return text.includes(`<task-id>${id}</task-id>`) && text.includes('<result>')
165}
166
167function clockTime(at: number | undefined): string {
168 return at === undefined ? '' : new Date(at).toTimeString().slice(0, 5)
169}
170
171async function add($: EngineInterface, message: SideMessage) {
172 await update($, messages, list => [...(list ?? []), { ...message, at: Date.now() }].slice(-KEEP))
173}
174
175async function setIdle($: EngineInterface) {
176 await update($, isBusy, () => false)
177 await update($, busySince, () => null)
178 await update($, activity, () => '')
179}
180
181// The pane's window starts at the top of its tree, so new messages fall below
182// it: move the window once the redraw that holds the target has landed.
183async function reveal($: EngineInterface, to: { key: string } | 'end', block?: 'start' | 'end') {
184 for (let attempt = 0; attempt < 6; attempt++) {
185 try {
186 const moved = await $.ui.scroll({ in: PANE, to, block })
187 if (moved.deny === undefined) return
188 } catch {
189 // not drawn yet, or no scrolling here
190 }
191 await $.clock.sleep(80).catch(() => undefined)
192 }
193}
194
195// A reply can reach us twice (turn.complete and SubagentStop): show it once.
196async function deliver($: EngineInterface, id: string | undefined, text: string | undefined) {
197 if (id === undefined || id !== (await read($, agentId))) return
198 const reply = text === undefined || text === '' ? TEXT[lang].noText : text
199 const list = (await read($, messages)) ?? []
200 const last = list[list.length - 1]
201 if (!(last !== undefined && last.role === 'side' && last.text === reply)) {
202 await add($, { role: 'side', text: reply })
203 }
204 await setIdle($)
205 // Land on the top of the new reply, so it reads from its first line.
206 const count = ((await read($, messages)) ?? []).length
207 await reveal($, { key: `msg-${count - 1}` }, 'start')
208}
209
210async function ask($: EngineInterface, raw: string) {
211 const text = raw.trim()
212 if (text === '') return
213 if (await read($, isBusy)) {
214 $.ui.toast(TEXT[lang].stillBusy)
215 return
216 }
217 await update($, draft, () => '')
218 await add($, { role: 'me', text })
219 await update($, isBusy, () => true)
220 await update($, busySince, () => Date.now())
221 await update($, activity, () => '')
222 void reveal($, 'end')
223
224 const current = await read($, agentId)
225 if (current === null) {
226 const started = await $.agent.spawn({
227 prompt: `${TEXT[lang].firstMessage}\n${text}`,
228 description: TEXT[lang].spawnDescription,
229 subagentType: 'fork',
230 })
231 if (started.deny !== undefined) {
232 await add($, { role: 'note', text: TEXT[lang].noFork(started.deny) })
233 await setIdle($)
234 return
235 }
236 // Fall back to the agent list when the start did not name the agent.
237 const id =
238 started.agentId ??
239 (await $.agent.list().catch(() => []))
240 .filter(a => a.spawnedBy === 'vkazas-side' && a.status === 'running')
241 .map(a => a.id)
242 .pop()
243 if (id === undefined) {
244 await add($, { role: 'note', text: TEXT[lang].noStart })
245 await setIdle($)
246 return
247 }
248 await update($, agentId, () => id)
249 await update($, spawned, list => [...(list ?? []).filter(x => x !== id), id].slice(-50))
250 await add($, { role: 'note', text: TEXT[lang].forked(id.slice(0, 8)) })
251 return
252 }
253
254 const sent = await $.session.send({ to: { agentId: current }, text })
255 if (!sent.isDelivered) {
256 await add($, { role: 'note', text: TEXT[lang].notSent(String(sent.reason)) })
257 await setIdle($)
258 }
259}
260
261async function syncToMain($: EngineInterface) {
262 const list = (await read($, messages)) ?? []
263 const lines = list
264 .filter(m => m.role !== 'note')
265 .map(m => `${m.role === 'me' ? TEXT[lang].me : TEXT[lang].side}: ${m.text}`)
266 if (lines.length === 0) {
267 $.ui.toast(TEXT[lang].nothingToSync)
268 return
269 }
270 let body = lines.join('\n\n')
271 if (body.length > SYNC_CHARS) body = `${TEXT[lang].omitted}\n${body.slice(-SYNC_CHARS)}`
272 const appended = await $.session.append({
273 message: {
274 type: 'user',
275 content: [
276 {
277 type: 'text',
278 text: `${TEXT[lang].syncHeader}\n\n${body}`,
279 },
280 ],
281 },
282 })
283 if ('deny' in appended && appended.deny !== undefined) {
284 $.ui.toast(TEXT[lang].syncFailed(String(appended.deny)))
285 return
286 }
287 await add($, { role: 'note', text: TEXT[lang].syncedNote })
288 $.ui.toast(TEXT[lang].synced)
289}
290
291async function refork($: EngineInterface) {
292 await update($, agentId, () => null)
293 await setIdle($)
294 await add($, { role: 'note', text: TEXT[lang].reforked })
295}
296
297function describeTool(e: unknown): string {
298 const call = e as { tool?: unknown; command?: unknown; file_path?: unknown; pattern?: unknown; url?: unknown }
299 const detail = [call.command, call.file_path, call.pattern, call.url].find(v => typeof v === 'string')
300 const short = typeof detail === 'string' ? ` ${detail.replace(/\s+/g, ' ').slice(0, 48)}` : ''
301 return `${String(call.tool)}${short}`
302}
303
304export const register: Register = (on, options) => {
305 lang = (options as { language?: string } | undefined)?.language === 'zh' ? 'zh' : 'en'
306 const t = TEXT[lang]
307
308 on('session.start', async ($, e, next) => {
309 const started = await next(e)
310 // A reload must not leave the pane waiting on a reply it can no longer catch.
311 if (await read($, isBusy)) {
312 await setIdle($)
313 await add($, { role: 'note', text: t.reloaded })
314 }
315 // Show where the conversation stands, not its first lines.
316 const count = ((await read($, messages)) ?? []).length
317 if (count > 0) void reveal($, { key: `msg-${count - 1}` }, 'start')
318 // Drives the spinner while the side agent works; idle ticks cost a read.
319 void $.clock.every(120, async () => {
320 if (await read($, isBusy)) await update($, tick, n => ((n ?? 0) + 1) % 1000)
321 })
322 await $.command.register({
323 name: 'side',
324 description: t.command,
325 })
326 return started
327 })
328
329 on('command.run', { command: 'side' }, async $ => {
330 const opened = await $.ui.open({ id: PANE, title: t.title, focus: true })
331 return {
332 text: opened.isPlaced ? t.opened : t.notOpened(String(opened.reason)),
333 }
334 })
335
336 // Show what the side agent is doing while it works.
337 on('tool.call', async ($, e, next) => {
338 const caller = (e as { agentId?: string }).agentId
339 if (caller !== undefined && caller === (await read($, agentId))) {
340 await update($, activity, () => describeTool(e))
341 }
342 return next(e)
343 }).catch(($, e, next) => next(e))
344
345 // A turn of the side agent ended: its answer is the reply in the pane.
346 on('turn.complete', async ($, e, next) => {
347 const result = await next(e)
348 if (e.agentId !== undefined) {
349 const reply =
350 e.answer !== ''
351 ? e.answer
352 : e.isAborted
353 ? t.interrupted
354 : e.reason === 'error'
355 ? t.errored
356 : undefined
357 await deliver($, e.agentId, reply)
358 }
359 return result
360 })
361
362 // A resumed run may end without a turn.complete reaching us: SubagentStop
363 // carries the agent and its last message either way.
364 on('classic.SubagentStop', async ($, e, next) => {
365 const result = await next(e)
366 await deliver($, e.agent_id, e.last_assistant_message)
367 return result
368 }).catch(($, e, next) => next(e))
369
370 // The side agent's notifications never wake the main conversation: its
371 // replies live in the pane. A notification is dropped where it would start
372 // (or join) a main turn, consumed where it is delivered, and cut to one line
373 // in the rare case it is appended anyway.
374 on('prompt.submit', async ($, e, next) => {
375 if (e.origin.kind === 'task-notification' && (await fromSideAgent($, e.text))) {
376 return { drop: t.dropped }
377 }
378 return next(e)
379 }).catch(($, e, next) => next(e))
380
381 on('session.receive', async ($, e, next) => {
382 if (e.agentId === undefined && (await fromSideAgent($, e.text))) {
383 return { consumed: t.consumed }
384 }
385 return next(e)
386 }).catch(($, e, next) => next(e))
387
388 on('session.append', async ($, e, next) => {
389 if (e.agentId !== undefined) return next(e)
390 const content = e.message.content as unknown
391 if (typeof content === 'string') {
392 if (!(await fromSideAgent($, content))) return next(e)
393 return next({ ...e, message: { ...e.message, content: shorten(content) } as typeof e.message })
394 }
395 if (!Array.isArray(content)) return next(e)
396 const blocks = content as { type: string; text?: string }[]
397 const texts = blocks.filter(b => b.type === 'text' && typeof b.text === 'string').map(b => b.text as string)
398 if (!(await fromSideAgent($, texts.join(' ')))) return next(e)
399 const shortened = blocks.map(b => (b.type === 'text' && typeof b.text === 'string' ? { ...b, text: shorten(b.text) } : b))
400 return next({ ...e, message: { ...e.message, content: shortened } as typeof e.message })
401 }).catch(($, e, next) => next(e))
402
403 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
404 const { Box, Text, Markdown, Input, Button } = $.ui.resolve(e)
405 const list = (await read($, messages)) ?? []
406 const id = await read($, agentId)
407 const busy = await read($, isBusy)
408 const since = await read($, busySince)
409 const now = await read($, activity)
410 const typed = await read($, draft)
411 const frame = busy ? await read($, tick) : 0
412
413 const columns = Math.max(24, e.props.bodyColumns)
414 const isWide = columns >= 56
415 const shown = list.slice(-SHOWN)
416 const first = list.length - shown.length
417 const hasReplies = list.some(m => m.role === 'side')
418 const seconds = busy && since !== null ? Math.max(0, Math.round((Date.now() - since) / 1000)) : 0
419 const state = busy
420 ? { color: 'warning', label: t.answering }
421 : id === null
422 ? { color: 'inactive', label: t.unforked }
423 : { color: 'success', label: t.ready }
424
425 return (
426 <Box flexDirection="column">
427 <Box flexDirection="row" justifyContent="space-between">
428 <Box flexDirection="row">
429 <Text color="claude" bold>
430 {t.title}
431 </Text>
432 {id !== null && <Text color="subtle">{` ${id.slice(0, 8)}`}</Text>}
433 </Box>
434 <Text color={state.color}>{state.label}</Text>
435 </Box>
436 <Text color="subtle">{'─'.repeat(columns)}</Text>
437
438 {list.length === 0 ? (
439 <Box flexDirection="column" marginTop={1} paddingX={1}>
440 <Text bold>{t.introTitle}</Text>
441 <Box flexDirection="column" marginTop={1}>
442 {t.intro.map(line => (
443 <Text color="subtle">{line}</Text>
444 ))}
445 </Box>
446 </Box>
447 ) : (
448 first > 0 && <Text color="subtle" italic>{t.folded(first)}</Text>
449 )}
450
451 {shown.map((m, i) => (
452 // Keyed by place in the whole list, so reveal() can scroll to one.
453 <Box key={`msg-${first + i}`} flexDirection="column" marginTop={1}>
454 {m.role === 'me' ? (
455 <Box flexDirection="row">
456 <Text color="suggestion" bold>
457 {'❯ '}
458 </Text>
459 <Box flexDirection="column" flexShrink={1}>
460 <Text>{m.text}</Text>
461 </Box>
462 </Box>
463 ) : m.role === 'side' ? (
464 <Box flexDirection="column">
465 <Box flexDirection="row">
466 <Text color="claude" bold>
467 {t.title}
468 </Text>
469 <Text color="subtle">{` ${clockTime(m.at)}`}</Text>
470 </Box>
471 <Box flexDirection="column" paddingLeft={2}>
472 <Markdown text={m.text} />
473 </Box>
474 </Box>
475 ) : (
476 <Text color="subtle" italic>{` · ${m.text}`}</Text>
477 )}
478 </Box>
479 ))}
480
481 {busy && (
482 <Box flexDirection="row" marginTop={1}>
483 <Text color="claude">{`${SPINNER[(frame ?? 0) % SPINNER.length]} `}</Text>
484 <Text color="subtle" wrap="truncate-end">
485 {`${t.thinking} · ${seconds}s${now === '' ? '' : ` · ${now}`}`}
486 </Text>
487 </Box>
488 )}
489
490 <Box marginTop={1} borderStyle="round" borderColor={busy ? 'inactive' : 'promptBorder'} paddingX={1}>
491 <Input
492 key="ask"
493 label="❯"
494 placeholder={busy ? t.waitPlaceholder : id === null ? t.firstPlaceholder : t.placeholder}
495 value={typed ?? ''}
496 submitLabel={t.send}
497 autoFocus
498 onInput={value => {
499 void update($, draft, () => value)
500 }}
501 onSubmit={value => {
502 void ask($, value)
503 }}
504 />
505 </Box>
506 <Box flexDirection={isWide ? 'row' : 'column'} justifyContent="space-between">
507 <Text color="subtle">{isWide ? t.hintWide : t.hint}</Text>
508 <Box flexDirection="row" gap={1}>
509 <Button
510 key="sync"
511 label={t.syncButton}
512 variant={hasReplies ? 'primary' : 'secondary'}
513 onPress={() => syncToMain($)}
514 />
515 <Button key="refork" label={t.reforkButton} onPress={() => refork($)} />
516 </Box>
517 </Box>
518 </Box>
519 )
520 })
521}
522types/index.d.ts 18 lines1export type SideMessage = { role: 'me' | 'side' | 'note'; text: string; at?: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'vkazas-side': {
6 messages: SideMessage[]
7 agentId: string | null
8 // Every agent the pane forked, so their notifications are known after a re-fork.
9 spawned: string[]
10 isBusy: boolean
11 busySince: number | null
12 activity: string
13 draft: string
14 tick: number
15 }
16 }
17}
18