Shows the session's topics and a per-session color above the prompt, so side-by-side sessions are easy to tell apart

This repository is a Claude Code plugin marketplace named rotem-mods. It holds three mods (plugins that add panes, bands, and commands to Claude Code).
| Mod | What it does |
|---|---|
context-usage | Shows how full the context window of the session is, in a band above the prompt. The /context-bar command toggles the detail view. |
session-topics | Shows the topics of the session and a color for each session above the prompt. You can tell side-by-side sessions apart. |
orchestrator | Makes the session delegate work to subagents. A subagent runs on Haiku for simple, repetitive tasks and on Sonnet for hard or long tasks, never on Opus or Fable. The orchestrator splits work into tasks of about 150K tokens of subagent context each. The details button in its band lists the running and past subagents. Use /orchestrator on, /orchestrator off, or /orchestrator status. |
/plugin marketplace add rotembm12/claude-mods
/plugin install context-usage@rotem-mods
/plugin install session-topics@rotem-mods
/plugin install orchestrator@rotem-mods
To get new versions of the mods, run this command:
/plugin marketplace update rotem-modshooks/register.tsx 223 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Saved } from '../types'
5
6const topics = atom({ plugin: 'session-topics', key: 'topics' } as const, [])
7const seed = atom({ plugin: 'session-topics', key: 'seed' } as const, null)
8const isMinimized = atom({ plugin: 'session-topics', key: 'isMinimized' } as const, false)
9
10const STORE_KEY = 'recent'
11const KEEP_SESSIONS = 50
12const MAX_TOPICS = 5
13const MINI_WIDTH = 24
14const RESUME_MESSAGES = 8
15
16// Readable on a dark or a light theme. Each session gets one, picked by its id,
17// so two sessions on the same subject still look different.
18const PALETTE = ['#e06c75', '#e5c07b', '#98c379', '#56b6c2', '#61afef', '#c678dd', '#d19a66', '#ff79c6']
19
20const SYSTEM = `You keep a short list of the topics a person worked on in one Claude Code session. The list helps them tell several terminals apart at a glance.
21
22Rules:
23- Each topic is 1 to 4 words and names the concrete subject (a feature, file, bug, page or tool). No trailing punctuation.
24- Keep the existing topics, in order. If the new messages continue the latest topic, you may sharpen its wording.
25- Add a topic at the end only when the messages start a clearly different subject.
26- Keep at most ${MAX_TOPICS} topics. Past that, merge the oldest two.
27- Messages with no subject ("yes", "continue", "thanks") leave the list as it is.
28- Write each topic in English, whatever language the messages use.
29
30Reply with the JSON array of strings only, oldest topic first.`
31
32function colorFor(id: string): string {
33 let hash = 2166136261
34 for (let i = 0; i < id.length; i++) hash = Math.imul(hash ^ id.charCodeAt(i), 16777619)
35 return PALETTE[(hash >>> 0) % PALETTE.length] ?? '#61afef'
36}
37
38function clip(text: string, width: number): string {
39 if (width <= 0) return ''
40 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
41}
42
43// A slash command counts by its arguments alone: `/goal build X` is about X.
44// The desktop puts a system reminder in front of the first prompt; it is no topic.
45function subjectOf(text: string): string {
46 const trimmed = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
47 return trimmed.startsWith('/') ? trimmed.replace(/^\/\S*\s*/, '').trim() : trimmed
48}
49
50function seedOf(subject: string): string {
51 return clip(subject.split(/\s+/).slice(0, 6).join(' '), 40)
52}
53
54function parseTopics(reply: string): string[] | null {
55 const start = reply.indexOf('[')
56 const end = reply.lastIndexOf(']')
57 if (start < 0 || end <= start) return null
58 try {
59 const value: unknown = JSON.parse(reply.slice(start, end + 1))
60 if (!Array.isArray(value)) return null
61 const list = value
62 .filter((t): t is string => typeof t === 'string')
63 .map(t => clip(t.trim().replace(/[.!]+$/, ''), 40))
64 .filter(t => t.length > 0)
65 .slice(-MAX_TOPICS)
66 return list.length > 0 ? list : null
67 } catch {
68 return null
69 }
70}
71
72async function ask($: EngineInterface, current: string[], messages: string[]): Promise<string[] | null> {
73 const prompt = [
74 `Topics so far, oldest first: ${JSON.stringify(current)}`,
75 'Newest messages from the person, oldest first:',
76 ...messages.map(m => `<message>\n${m.slice(0, 1500)}\n</message>`),
77 'Reply with the updated JSON array.',
78 ].join('\n\n')
79 const reply = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt, maxTokens: 200, timeoutMs: 30_000 })
80 return reply.isAnswered ? parseTopics(reply.text) : null
81}
82
83async function loadAll($: EngineInterface): Promise<Record<string, Saved>> {
84 const value = await $.store.get(STORE_KEY)
85 return value !== null && typeof value === 'object' ? (value as Record<string, Saved>) : {}
86}
87
88// Kept per session id, so a resumed session shows its topics with no model call.
89async function save($: EngineInterface, list: string[]) {
90 const all = await loadAll($)
91 all[await $.session.id()] = { topics: list, at: await $.clock.now() }
92 const newest = Object.entries(all)
93 .sort((a, b) => b[1].at - a[1].at)
94 .slice(0, KEEP_SESSIONS)
95 await $.store.set(STORE_KEY, Object.fromEntries(newest))
96}
97
98// Module variables: a reload drops them, and the topics in $.state stay.
99let queue: string[] = []
100let isBusy = false
101let generation = 0
102
103async function drain($: EngineInterface) {
104 if (isBusy) return
105 isBusy = true
106 try {
107 while (queue.length > 0) {
108 const started = generation
109 const batch = queue.splice(0, queue.length)
110 const list = await ask($, await read($, topics), batch)
111 // A /clear while the model answered: that answer is about the old session.
112 if (list && started === generation) {
113 await update($, topics, () => list)
114 await save($, list)
115 }
116 }
117 } catch {
118 // A failed call leaves the topics as they were; the next prompt tries again.
119 } finally {
120 isBusy = false
121 }
122}
123
124// The model call runs on a timer of its own, so no prompt waits for it.
125function enqueue($: EngineInterface, messages: string[]) {
126 queue.push(...messages)
127 $.clock.after(0, () => void drain($))
128}
129
130async function reset($: EngineInterface) {
131 generation += 1
132 queue = []
133 await update($, topics, () => [])
134 await update($, seed, () => null)
135}
136
137export const register: Register = on => {
138 on('session.start', async ($, e, next) => {
139 const result = await next(e)
140 // A reload fires session.start again; the session's topics are still in $.state.
141 if ((await read($, topics)).length > 0) return result
142
143 const saved = (await loadAll($))[await $.session.id()]
144 if (saved && saved.topics.length > 0) {
145 await update($, topics, () => saved.topics)
146 return result
147 }
148
149 const earlier = (await $.session.messages())
150 .filter(m => m.role === 'user' && !m.text.trimStart().startsWith('<'))
151 .map(m => subjectOf(m.text))
152 .filter(s => s.length > 0)
153 if (earlier.length > 0) enqueue($, earlier.slice(-RESUME_MESSAGES))
154
155 return result
156 })
157
158 // Only what the person typed: a plugin's or a peer's prompt is no topic of theirs.
159 on('prompt.submit', async ($, e, next) => {
160 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
161 const subject = subjectOf(e.text)
162 if (subject.length > 0) {
163 if ((await read($, seed)) === null) await update($, seed, () => seedOf(subject))
164 enqueue($, [subject])
165 }
166 }
167
168 return next(e)
169 }).catch(($, e, next) => next(e))
170
171 // No session.start follows a /clear, so the list starts over here.
172 on('session.end', async ($, e, next) => {
173 if (e.reason === 'clear') await reset($)
174
175 return next(e)
176 })
177
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 // What other plugins drew in the band: drawn under this row, never replaced.
180 const below = await next(e)
181 if (e.props.hasSurvey) return below
182
183 const { Box, Button, Text } = $.ui.resolve(e)
184 const list = await read($, topics)
185 const first = await read($, seed)
186 const minimized = await read($, isMinimized)
187 const color = colorFor(await $.session.id())
188
189 const hasTopics = list.length > 0
190 const current = list[list.length - 1] ?? first ?? 'new session'
191 const earlier = list.slice(0, -1).reverse().join(' · ')
192
193 // The toggle, the dot and their spaces take four cells.
194 const room = e.props.bodyColumns - 4
195 const head = clip(current, minimized ? Math.min(MINI_WIDTH, room) : room)
196 const tail = minimized || earlier.length === 0 ? '' : clip(` · ${earlier}`, room - head.length)
197
198 const row = (
199 <Box>
200 <Button
201 key="topics-toggle"
202 plain
203 dimColor
204 label={minimized ? '▸' : '▾'}
205 onPress={() => update($, isMinimized, v => !v)}
206 />
207 <Text color={color}> ● </Text>
208 <Text color={color} bold={hasTopics} dimColor={!hasTopics}>
209 {head}
210 </Text>
211 {tail.length > 0 && <Text dimColor>{tail}</Text>}
212 </Box>
213 )
214
215 return below.type === 'engine' ? row : (
216 <Box flexDirection="column">
217 {row}
218 {below}
219 </Box>
220 )
221 })
222}
223types/index.d.ts 8 lines1export type Saved = { topics: string[]; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-topics': { topics: string[]; seed: string | null; isMinimized: boolean }
6 }
7}
8