A live side panel of every subagent, and /boost: a watcher subagent that follows the in-progress turn and launches read-only helper subagents to speed it up

A Claude Code plugin marketplace with three mods.
| Plugin | What it does |
|---|---|
prompt-enhance | End a prompt with ++ and press Enter: instead of sending it, Claude rewrites it into a sharper prompt and puts it back in the prompt bar for you to review and send. |
session-boost | /subagents opens a live side panel of every subagent, with a one-click cheaper-model suggestion per row. /boost (mid-turn) starts a watcher that launches read-only helper subagents. Subagents run on the chat's own model by default. |
conversations | /conversations lists this project's past conversations by title; click one to resume it. |
None of the plugins ship commands/, skills/ or agents/ folders. Each one is a single hooks module (hooks/hooks.json → hooks/register.ts(x)) that registers its commands, panels, tools and agents when a session starts.
What it adds
prompt.submit hook for prompts typed in the prompt bar. No slash commands.How to use it
++, e.g. fix the flaky login test ++.How it rewrites
/model shows it), at high effort, with a 120 s timeout.If something goes wrong
++ on its own is dropped with Nothing to enhance.What it adds
| Item | Kind | What it does |
|---|---|---|
/subagents | Command | Opens the Subagents side panel. |
/boost | Command | Starts a watcher that launches read-only helper subagents for the turn in progress. |
/boost stop | Command | Stops a running boost. |
| Subagent model default | agent.spawn hook | Subagents run on the chat's own model unless the caller names one. |
session-boost:watcher | Agent (internal) | The /boost watcher. Hidden from the main model. |
session-boost:helper | Agent (internal) | A read-only helper started by the watcher. Hidden from the main model. |
mcp__session-boost__progress | Tool (internal) | Lets the watcher wait for new main-chat activity. Only the watcher may call it. |
mcp__session-boost__spawn_helper | Tool (internal) | Lets the watcher queue a helper. Only the watcher may call it. |
The Subagents panel (/subagents)
The panel opens by itself at the start of an interactive session; /subagents reopens it. It refreshes every 2 seconds and shows one row per subagent:
● running, ○ pending, waiting or idle, ✓ completed, ✗ failed or killed), the description, the agent type, a [watcher] / [helper] tag for boost agents, and the model it runs on.Cheaper-model suggestions
When a cheaper model suits a row's job (Haiku 5.5 for lookup, Sonnet 5.5 for coding, Opus 5.5 for reasoning), the row shows a ▲ use <model> button with the price and the saving, e.g. $0.10/$0.50, 98% less. It blinks while the subagent is still running.
✓ <model>.✓ again to undo; that kind follows the chat's model again.Which model subagents run on
By default every subagent runs on the chat's own model, even when its agent definition pins one (a built-in's haiku, say). A model the caller names explicitly, or a pick you made in the panel, wins. Forks and workflow agents are left alone.
Boost (/boost)
/boost. (With nothing in progress it tells you so.)[session-boost] message and a toast appears. The main chat uses it if it helps./boost stop ends it early: no new helpers, queued ones are dropped, running ones finish and report.Helpers can use Read, Grep, Glob, Bash, WebFetch and WebSearch, and are told never to modify anything (no edits, commits, checkouts, installs). Their answers are capped at about 300 words with file:line citations.
What it adds
/conversations: a command that opens the Past conversations panel.How to use it
/conversations. The panel opens focused on the newest conversation./resume <id> for that conversation.What it lists
~/.claude/projects/<project>/ (or under $CLAUDE_CONFIG_DIR if set), newest first, up to 60.Where titles come from, in order:
/rename.Titles are cached per transcript and only worked out again when the transcript changes, so the panel fills at once on later opens.
Inside Claude Code, add the marketplace once per machine:
/plugin marketplace add nadesh00/setup-code
Then install the plugins you want:
/plugin install prompt-enhance@setup-code
/plugin install session-boost@setup-code
/plugin install conversations@setup-code
Run /reload-plugins (or start a new session) if Claude Code asks you to.
Plugins here carry no version, so an update is any new commit. To get updates automatically, run /plugin, open the Marketplaces tab, select setup-code and choose Enable auto-update (it is off by default for marketplaces you add). To update by hand: /plugin marketplace update setup-code, then update each plugin from the Installed tab.
Each plugin is a hooks module under plugins/<name>/hooks/. Check one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>hooks/register.tsx 521 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Boost, HelperRow, Suggestion } from '../types'
5import {
6 BLINK_MS, cheaperOf, classifyJob, describeActivity, describeOption, harderJob, finalAnswer, HELPER_SYSTEM, HELPER_TOOLS, HELPER_TYPE, helperDelivery, KEEP_FINISHED,
7 mainDigest, type MainEvent, MAX_RUNNING_HELPERS, oneLine, PANE, POLL_MS, PROGRESS_TOOL, PROGRESS_WAIT_MS,
8 SPAWN_TOOL, spawnKind, summarizeCall, recommendedModel, TERMINAL, WATCHER_SYSTEM, WATCHER_TYPE, watcherPrompt,
9} from './core'
10
11const OFF: Boost = { status: 'off', watcherId: null, helpers: [] }
12
13const agents = atom({ plugin: 'session-boost', key: 'agents' } as const, [])
14const boost = atom({ plugin: 'session-boost', key: 'boost' } as const, OFF)
15/** Each subagent's model as its spawn resolved it, by agent id. */
16const models = atom({ plugin: 'session-boost', key: 'models' } as const, {})
17/** The overspend cue's phase: flipped every BLINK_MS while a live row has one. */
18const blink = atom({ plugin: 'session-boost', key: 'blink' } as const, false)
19/** The person's picks, by spawn kind (`Explore:lookup`) → model id; mirrored in the store across sessions. */
20const prefs = atom({ plugin: 'session-boost', key: 'prefs' } as const, {})
21
22type HelperTask = { key: string; description: string; prompt: string }
23
24/** What this module has seen since it loaded; a reload starts it over. */
25type Live = {
26 /** The main chat's turn. */
27 main: { isActive: boolean; text: string }
28 events: MainEvent[]
29 /** The first event the watcher has not been given yet. */
30 cursor: number
31 waiters: Array<() => void>
32 /** Helpers the watcher asked for, waiting for a running seat. */
33 queue: HelperTask[]
34 /** Agents already read once finished: their transcripts are not read again. */
35 settled: Set<string>
36 isPolling: boolean
37 tasks: number
38 /** Helpers whose answer reached the main chat: delivered once, by turn.complete or the poll. */
39 delivered: Set<string>
40 /** Polls in a row a helper was seen ended without its turn.complete, or the watcher not listed. */
41 strikes: Map<string, number>
42}
43
44function wake(live: Live) {
45 const ready = live.waiters
46 live.waiters = []
47 for (const resolve of ready) resolve()
48}
49
50/** The seq the next main-chat event gets. */
51function nextSeq(live: Live): number {
52 return (live.events.at(-1)?.seq ?? -1) + 1
53}
54
55function record(live: Live, text: string) {
56 live.events = [...live.events, { seq: nextSeq(live), text }].slice(-200)
57 wake(live)
58}
59
60export const register: Register = on => {
61 const live: Live = {
62 main: { isActive: false, text: '' },
63 events: [],
64 cursor: 0,
65 waiters: [],
66 queue: [],
67 settled: new Set(),
68 isPolling: false,
69 tasks: 0,
70 delivered: new Set(),
71 strikes: new Map(),
72 }
73
74 on('session.start', async ($, e, next) => {
75 const saved = (await $.store.get('prefs').catch(() => undefined)) as Record<string, string> | undefined
76 if (saved) await update($, prefs, () => saved)
77 await Promise.all([
78 $.command.register({ name: 'subagents', description: 'Show every subagent live in a side panel', immediate: true }),
79 $.command.register({
80 name: 'boost',
81 description: 'Start a watcher that launches read-only helper subagents for the turn in progress',
82 argumentHint: '[stop]',
83 immediate: true,
84 }),
85 $.tool.register({
86 name: 'progress',
87 description: 'session-boost watcher only: waits briefly for new activity in the main chat, then returns it with the helpers\' status and results, and whether to stop.',
88 inputSchema: { type: 'object', properties: {} },
89 isDeferred: false,
90 }),
91 $.tool.register({
92 name: 'spawn_helper',
93 description: `session-boost watcher only: launches a read-only helper subagent (at most ${MAX_RUNNING_HELPERS} run at once; more are queued).`,
94 inputSchema: {
95 type: 'object',
96 properties: {
97 description: { type: 'string', description: 'A few words naming the task' },
98 prompt: { type: 'string', description: 'The complete, self-contained task for the helper' },
99 },
100 required: ['description', 'prompt'],
101 },
102 isDeferred: false,
103 }),
104 $.agent.register({
105 name: 'watcher',
106 description: 'session-boost watcher (started by /boost only)',
107 prompt: WATCHER_SYSTEM,
108 tools: [PROGRESS_TOOL, SPAWN_TOOL, 'Read', 'Grep', 'Glob'],
109 }),
110 $.agent.register({
111 name: 'helper',
112 description: 'session-boost read-only helper (started by the watcher only)',
113 prompt: HELPER_SYSTEM,
114 tools: HELPER_TOOLS,
115 }),
116 ])
117 // All background work (the panel, launching helpers) runs from this timer on the session's
118 // own $, so no hook steps past a helper and no helper is a child of the watcher.
119 $.clock.every(POLL_MS, () => void poll($, live))
120 $.clock.every(BLINK_MS, () => void flip($))
121 if (e.isInteractive) void $.ui.open({ id: PANE, title: 'Subagents' })
122
123 return next(e)
124 })
125
126 // Every subagent runs on the chat's own model unless its caller named one: an agent
127 // definition's pinned model (a built-in's `haiku`, say) never decides it.
128 on('agent.spawn', async ($, e, next) => {
129 const isPinnable = !e.fork && e.workflow === undefined
130 // The person's pick for this kind of subagent, never pricier than the chat's own.
131 const picked = (await read($, prefs))[spawnKind(e.subagentType, e.description).key]
132 const asked = e.model ?? (isPinnable ? (picked ? cheaperOf(e.parentModel, picked) : e.parentModel) : undefined)
133 const result = await next(asked === e.model ? e : { ...e, model: asked })
134 const model = result.deny === undefined ? (result.model || asked) : undefined
135 if (result.agentId !== undefined && model) {
136 const agentId = result.agentId
137 await update($, models, (m): Record<string, string> => ({ ...m, [agentId]: model }))
138 }
139
140 return result
141 }).catch(($, e, next) => next(e)) // a failing hook never blocks a spawn
142
143 // The two agent types are the mod's own: the main model never delegates to them.
144 on('agent.offer', { agent: 'session-boost:watcher' }, () => ({ isOffered: false })).catch(() => ({ isOffered: false }))
145 on('agent.offer', { agent: 'session-boost:helper' }, () => ({ isOffered: false })).catch(() => ({ isOffered: false }))
146
147 on('turn.start', async ($, e, next) => {
148 // A continuation turn has no text of its own: the task is still the last one typed.
149 live.main = { isActive: true, text: e.text || live.main.text }
150 record(live, `Main turn started${e.text ? `: ${oneLine(e.text, 200)}` : ''}`)
151
152 return next(e)
153 })
154
155 // Every loop's end: the main turn's, the watcher's, a helper's (its answer is its turn.complete).
156 on('turn.complete', async ($, e, next) => {
157 if (e.agentId === undefined) {
158 live.main = { ...live.main, isActive: false }
159 record(live, 'Main turn ended')
160 return next(e)
161 }
162 const state = await read($, boost)
163 const helper = state.helpers.find(h => h.id === e.agentId)
164 if (helper?.status === 'running') await settleHelper($, live, helper, e.answer, e.reason !== 'answer')
165 if (e.agentId === state.watcherId && state.status !== 'off') {
166 live.queue = []
167 await update($, boost, (b): Boost => ({ ...b, status: 'stopping', helpers: b.helpers.filter(h => h.status !== 'queued') }))
168 }
169
170 return next(e)
171 })
172
173 // The main chat's progress, as the watcher reads it.
174 on('tool.call', async ($, e, next) => {
175 if (e.agentId !== undefined || e.tool === PROGRESS_TOOL || e.tool === SPAWN_TOOL) return next(e)
176 const call = summarizeCall(e.tool, e as unknown as Record<string, unknown>)
177 record(live, `Main chat started ${call}`)
178 const result = await next(e)
179 if ('deny' in result && result.deny !== undefined) record(live, `Main chat's ${call} was denied`)
180 else if ('isError' in result && result.isError) record(live, `Main chat's ${call} failed`)
181
182 return result
183 }).catch(($, e, next) => next(e)) // a failing recorder never blocks the main chat's tool
184
185 on('command.run', { command: 'subagents' }, async $ => {
186 const opened = await $.ui.open({ id: PANE, title: 'Subagents' })
187
188 return { text: opened.isPlaced ? 'Subagents panel opened.' : `Subagents panel waits: ${opened.reason}` }
189 })
190
191 on('command.run', { command: 'boost' }, async ($, e) => {
192 const state = await read($, boost)
193 if (e.args.trim() === 'stop') {
194 if (state.status === 'off') return { text: 'No boost is running.' }
195 live.queue = []
196 await update($, boost, (b): Boost => ({ ...b, status: 'stopping', helpers: b.helpers.filter(h => h.status !== 'queued') }))
197 wake(live)
198 return { text: 'Boost stopping: the watcher ends at its next check; running helpers finish and report.' }
199 }
200 if (state.status !== 'off') return { text: 'Boost is already running. /boost stop ends it.' }
201 if (!live.main.isActive) return { text: 'Nothing in progress to boost: run /boost while a turn is working.' }
202
203 // Claimed before the spawn: a second /boost waits it out, and the watcher's first call is let in.
204 await update($, boost, (): Boost => ({ ...OFF, status: 'starting' }))
205 // No model named: the agent.spawn hook puts it on the chat's own (`$.agent.spawn` takes only aliases).
206 const messages = await $.session.messages()
207 live.cursor = nextSeq(live)
208 const spawned = await $.agent.spawn({
209 subagentType: WATCHER_TYPE,
210 description: 'Boost watcher',
211 prompt: watcherPrompt(live.main.text, mainDigest(messages)),
212 })
213 if (spawned.deny !== undefined || spawned.agentId === undefined) {
214 await update($, boost, () => OFF)
215 return { text: `Could not start the boost watcher: ${spawned.deny ?? 'no agent started'}` }
216 }
217 const watcherId = spawned.agentId
218 await update($, boost, (b): Boost => ({ ...b, status: b.status === 'starting' ? 'watching' : b.status, watcherId }))
219 void $.ui.open({ id: PANE, title: 'Subagents' })
220
221 return { text: 'Boost started: a watcher follows this turn and launches read-only helpers. /boost stop ends it.' }
222 })
223
224 on('tool.call', { tool: 'mcp__session-boost__progress' }, async ($, e) => {
225 if (!(await isWatcher($, e.agentId))) return { deny: 'Only the session-boost watcher calls this tool.' }
226 const state = await read($, boost)
227 if (state.status !== 'stopping' && live.main.isActive && live.cursor >= nextSeq(live)) {
228 await new Promise<void>(resolve => {
229 const done = () => {
230 live.waiters = live.waiters.filter(waiter => waiter !== early)
231 resolve()
232 }
233 const timer = $.clock.after(PROGRESS_WAIT_MS, done)
234 const early = () => {
235 timer.cancel()
236 resolve()
237 }
238 live.waiters.push(early)
239 })
240 }
241 const fresh = live.events.filter(event => event.seq >= live.cursor).map(event => event.text)
242 live.cursor = nextSeq(live)
243 const now = await read($, boost)
244 const stop = now.status === 'stopping' || now.status === 'off' || !live.main.isActive
245
246 return {
247 result: {
248 stop,
249 ...(stop ? { reason: live.main.isActive ? 'The person stopped the boost.' : 'The main turn ended.' } : {}),
250 mainChat: fresh.length > 0 ? fresh : ['(no new activity)'],
251 helpers: now.helpers.map(({ description, status }) => ({ description, status })),
252 runningLimit: MAX_RUNNING_HELPERS,
253 },
254 }
255 }).catch(() => ({ result: { stop: false, mainChat: ['(progress check failed; call again)'], helpers: [] } }))
256
257 on('tool.call', { tool: 'mcp__session-boost__spawn_helper' }, async ($, e) => {
258 if (!(await isWatcher($, e.agentId))) return { deny: 'Only the session-boost watcher calls this tool.' }
259 const state = await read($, boost)
260 if (state.status === 'stopping' || state.status === 'off') return { deny: 'The boost is stopping: no new helpers.' }
261 const input = e as unknown as Record<string, unknown>
262 live.tasks += 1
263 const task: HelperTask = {
264 key: `h${live.tasks}`,
265 description: String(input.description ?? 'helper'),
266 prompt: String(input.prompt ?? ''),
267 }
268 if (task.prompt.trim() === '') return { deny: 'A helper needs a prompt.' }
269 // The row first, then the queue: a poll never takes a task whose row is not there yet.
270 await update($, boost, (b): Boost => ({ ...b, helpers: [...b.helpers, { key: task.key, description: task.description, status: 'queued' }] }))
271 live.queue.push(task)
272 // Seats ahead of this one: the running and the already queued.
273 const ahead = state.helpers.filter(h => h.status === 'running' || h.status === 'queued').length
274 // The next poll launches it, on the session's own $, never this hook's.
275
276 return { result: { status: ahead < MAX_RUNNING_HELPERS ? 'starting' : 'queued', description: task.description } }
277 }).catch(() => ({ deny: 'spawn_helper failed; try again or carry on without it.' }))
278
279 on('ui.render', { component: 'Pane', requestId: 'subagents' }, async ($, e) => {
280 const { Box, Button, Text } = $.ui.resolve(e)
281 const [rows, state, isLit, picks] = await Promise.all([read($, agents), read($, boost), read($, blink), read($, prefs)])
282 const helpers = state.helpers
283 const count = (status: HelperRow['status']) => helpers.filter(h => h.status === status).length
284
285 return (
286 <Box flexDirection="column" width={e.props.bodyColumns}>
287 {state.status !== 'off' && (
288 <Box marginBottom={1}>
289 <Text color="magenta" wrap="truncate-end">
290 Boost {state.status}: {count('running')} running, {count('queued')} queued, {count('done')} done
291 {count('failed') > 0 ? `, ${count('failed')} failed` : ''}
292 </Text>
293 </Box>
294 )}
295 {rows.length === 0 && <Text dimColor>No subagents yet.</Text>}
296 {rows.map(row => {
297 const isLive = !TERMINAL.has(row.status)
298 const mark = row.status === 'completed' ? '✓' : TERMINAL.has(row.status) ? '✗' : row.status === 'running' ? '●' : '○'
299 const indent = row.parentId ? ' ' : ''
300 const suggestion = row.suggestion
301 const isPicked = suggestion !== undefined && picks[suggestion.kind] === suggestion.id
302 return (
303 <Box key={row.id} flexDirection="column" marginBottom={1}>
304 <Box flexDirection="row">
305 <Box flexGrow={1} flexShrink={1}>
306 <Text bold={isLive} dimColor={!isLive} wrap="truncate-end">
307 {indent}{mark} {row.description}
308 <Text dimColor>{` ${row.type}${row.role ? ` [${row.role}]` : ''}${row.model ? ` · ${row.model}` : ''}`}</Text>
309 </Text>
310 </Box>
311 {suggestion && (
312 <Button key={`use-${row.id}`} onPress={() => togglePick($, suggestion)}>
313 {/* Blinks while the subagent runs and nothing is picked yet (highlight on and off, same width). */}
314 <Text color={isPicked ? 'green' : 'yellow'} bold inverse={!isPicked && isLive && isLit}>
315 {isPicked ? ` ✓ ${suggestion.label} ` : ` ▲ use ${suggestion.label} `}
316 </Text>
317 </Button>
318 )}
319 </Box>
320 <Text dimColor wrap="truncate-end">
321 {indent}{' '}{row.activity}
322 </Text>
323 {row.job && (
324 <Text color={suggestion && !isPicked ? 'green' : undefined} dimColor={!suggestion || isPicked} wrap="truncate-end">
325 {indent}{' '}
326 {!suggestion
327 ? `${row.job} job · already on the cheapest model that suits it`
328 : isPicked
329 ? `${row.job} job · future ${suggestion.kindLabel} start on ${suggestion.label} · click ✓ to undo`
330 : `${row.job} job · ${suggestion.label} costs ${suggestion.detail} · click ▲ to start future ${suggestion.kindLabel} on it`}
331 </Text>
332 )}
333 </Box>
334 )
335 })}
336 </Box>
337 )
338 })
339}
340
341/** The watcher's own calls: by id once known, by type while its spawn is still resolving. */
342async function isWatcher($: EngineInterface, agentId: string | undefined): Promise<boolean> {
343 if (agentId === undefined) return false
344 const state = await read($, boost)
345 if (state.watcherId === agentId) return true
346 if (state.status !== 'starting') return false
347 const listed = await $.agent.list()
348
349 return listed.some(agent => agent.id === agentId && agent.type === WATCHER_TYPE)
350}
351
352/** Re-reads every agent for the panel and fills free helper seats. */
353async function poll($: EngineInterface, live: Live) {
354 if (live.isPolling) return
355 live.isPolling = true
356 try {
357 const [listed, parentModel, spawnedOn] = await Promise.all([$.agent.list(), $.session.model(), read($, models)])
358 const state = await read($, boost)
359 const before = await read($, agents)
360 const old = new Map(before.map(row => [row.id, row]))
361 const helperIds = new Set(state.helpers.flatMap(h => (h.id ? [h.id] : [])))
362
363 const rows: AgentRow[] = []
364 for (const agent of listed) {
365 const was = old.get(agent.id)
366 const isLive = !TERMINAL.has(agent.status)
367 let activity = was?.activity ?? 'starting…'
368 let job = was?.job
369 if (isLive || !live.settled.has(agent.id)) {
370 const found = await $.session.messages({ agentId: agent.id })
371 if (Array.isArray(found)) {
372 activity = isLive ? describeActivity(found) : `${agent.status}: ${oneLine(finalAnswer(found), 120)}`
373 const tools = found.flatMap(message => message.toolUses.map(use => use.tool))
374 job = classifyJob(agent.type, agent.description, tools)
375 }
376 if (!isLive) live.settled.add(agent.id)
377 }
378 job ??= classifyJob(agent.type, agent.description, [])
379 const model = spawnedOn[agent.id]
380 // One suggestion: the cheapest model that suits both what it does and what its kind's spawns
381 // read as (so a pick never puts future harder jobs on too weak a model), below what it runs on
382 // (one spawned before the mod loaded is taken to run on the chat's).
383 const kind = spawnKind(agent.type, agent.description)
384 const runsOn = model ?? parentModel
385 const option = recommendedModel(runsOn, harderJob(job, kind.job))
386 const suggestion: Suggestion | undefined = option && {
387 id: option.id,
388 label: option.label,
389 detail: describeOption(option, runsOn).replace(/^[^(]*\(|\)$/g, ''),
390 kind: kind.key,
391 kindLabel: kind.label,
392 }
393 const role = agent.id === state.watcherId ? 'watcher' : helperIds.has(agent.id) ? 'helper' : undefined
394 rows.push({
395 id: agent.id,
396 description: agent.description,
397 type: agent.type,
398 status: agent.status,
399 activity,
400 ...(agent.parentId ? { parentId: agent.parentId } : {}),
401 ...(role ? { role } : {}),
402 ...(model ? { model } : {}),
403 job,
404 ...(suggestion ? { suggestion } : {}),
405 })
406 }
407
408 const active = rows.filter(row => !TERMINAL.has(row.status))
409 const done = rows.filter(row => TERMINAL.has(row.status)).slice(-KEEP_FINISHED)
410 const next = [...active, ...done]
411 if (JSON.stringify(next) !== JSON.stringify(before)) await update($, agents, () => next)
412
413 // A helper ended (killed, say) with no turn.complete: after a second look, the poll settles it.
414 for (const helper of state.helpers) {
415 if (helper.status !== 'running' || helper.id === undefined) continue
416 const agent = listed.find(one => one.id === helper.id)
417 if (agent !== undefined && !TERMINAL.has(agent.status)) {
418 live.strikes.delete(helper.id)
419 continue
420 }
421 if (strike(live, helper.id) < 2) continue
422 const found = await $.session.messages({ agentId: helper.id })
423 const answer = Array.isArray(found) ? finalAnswer(found) : ''
424 await settleHelper($, live, helper, answer, agent?.status !== 'completed')
425 }
426
427 await fillSeats($, live)
428
429 // The boost is over once the watcher has ended (or gone unlisted twice) and no helper is left.
430 const after = await read($, boost)
431 const watcher = after.watcherId === null ? undefined : listed.find(agent => agent.id === after.watcherId)
432 if (after.watcherId !== null && watcher !== undefined) live.strikes.delete(after.watcherId)
433 const isWatcherGone =
434 after.watcherId !== null &&
435 (watcher === undefined ? strike(live, after.watcherId) >= 2 : TERMINAL.has(watcher.status))
436 if (isWatcherGone && after.status === 'watching') {
437 live.queue = []
438 await update($, boost, (b): Boost => ({ ...b, status: 'stopping', helpers: b.helpers.filter(h => h.status !== 'queued') }))
439 }
440 const settled = await read($, boost)
441 const isHelping = settled.helpers.some(h => h.status === 'running' || h.status === 'queued')
442 if (settled.status === 'stopping' && !isHelping && (settled.watcherId === null || isWatcherGone)) {
443 await update($, boost, () => OFF)
444 }
445 } finally {
446 live.isPolling = false
447 }
448}
449
450function strike(live: Live, id: string): number {
451 const count = (live.strikes.get(id) ?? 0) + 1
452 live.strikes.set(id, count)
453
454 return count
455}
456
457/** A helper's end: its row marked, its answer handed to the main chat, once. */
458async function settleHelper($: EngineInterface, live: Live, helper: HelperRow, answer: string, isFailed: boolean) {
459 if (helper.id === undefined || live.delivered.has(helper.id)) return
460 live.delivered.add(helper.id)
461 live.strikes.delete(helper.id)
462 await update($, boost, (b): Boost => ({
463 ...b,
464 helpers: b.helpers.map(h => (h.key === helper.key ? { ...h, status: isFailed ? 'failed' : 'done' } : h)),
465 }))
466 const text = helperDelivery(helper.description, answer.trim() === '' ? '(no answer)' : answer.trim(), isFailed)
467 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }).catch(() => undefined)
468 $.ui.toast(`Boost helper ${isFailed ? 'failed' : 'finished'}: ${helper.description}`)
469 wake(live)
470}
471
472/** A click on a row's suggestion: future subagents of its kind start on that model; a second click undoes it. */
473async function togglePick($: EngineInterface, suggestion: Suggestion) {
474 const next = await update($, prefs, (picks): Record<string, string> => {
475 const { [suggestion.kind]: current, ...rest } = picks
476 return current === suggestion.id ? rest : { ...rest, [suggestion.kind]: suggestion.id }
477 })
478 await $.store.set('prefs', next)
479 $.ui.toast(
480 next[suggestion.kind] === suggestion.id
481 ? `Future ${suggestion.kindLabel} will start on ${suggestion.label}`
482 : `Future ${suggestion.kindLabel} follow the chat's model again`,
483 )
484}
485
486/** Flips the overspend cue's phase, only while some live row shows one (no redraws otherwise). */
487async function flip($: EngineInterface) {
488 const [rows, picks] = await Promise.all([read($, agents), read($, prefs)])
489 const isNeeded = rows.some(
490 row => row.suggestion !== undefined && picks[row.suggestion.kind] !== row.suggestion.id && !TERMINAL.has(row.status),
491 )
492 const isLit = await read($, blink)
493 if (isNeeded || isLit) await update($, blink, () => isNeeded && !isLit)
494}
495
496async function fillSeats($: EngineInterface, live: Live) {
497 const state = await read($, boost)
498 if (state.status !== 'watching' && state.status !== 'starting') return
499 let running = state.helpers.filter(h => h.status === 'running').length
500 while (running < MAX_RUNNING_HELPERS && live.queue.length > 0) {
501 const task = live.queue.shift() as HelperTask
502 const spawned = await $.agent.spawn({
503 subagentType: HELPER_TYPE,
504 description: task.description,
505 prompt: task.prompt,
506 })
507 const started: HelperRow =
508 spawned.deny === undefined && spawned.agentId !== undefined
509 ? { key: task.key, id: spawned.agentId, description: task.description, status: 'running' }
510 : { key: task.key, description: task.description, status: 'failed' }
511 // By key, and kept even if a stop cleared the queued row meanwhile: a started helper is always tracked.
512 await update($, boost, (b): Boost => ({
513 ...b,
514 helpers: b.helpers.some(h => h.key === task.key)
515 ? b.helpers.map(h => (h.key === task.key ? started : h))
516 : [...b.helpers, started],
517 }))
518 if (started.status === 'running') running += 1
519 }
520}
521hooks/core.ts 231 lines1import type { SessionMessage } from 'claude-code'
2
3export const PLUGIN = 'session-boost'
4export const PANE = 'subagents'
5export const WATCHER_TYPE = `${PLUGIN}:watcher`
6export const HELPER_TYPE = `${PLUGIN}:helper`
7export const PROGRESS_TOOL = `mcp__${PLUGIN}__progress`
8export const SPAWN_TOOL = `mcp__${PLUGIN}__spawn_helper`
9
10/** How often the panel re-reads the agents. */
11export const POLL_MS = 2000
12/** How long `progress` waits for new main-chat activity: under the 10 s hook budget. */
13export const PROGRESS_WAIT_MS = 7000
14export const MAX_RUNNING_HELPERS = 3
15/** Finished agents kept on the panel. */
16export const KEEP_FINISHED = 12
17const RESULT_CHARS = 4000
18
19export const TERMINAL = new Set(['completed', 'failed', 'killed'])
20
21/** What a helper may call: reading, searching and running checks; never an edit or a further agent. */
22export const HELPER_TOOLS = ['Read', 'Grep', 'Glob', 'Bash', 'WebFetch', 'WebSearch']
23
24export type MainEvent = { seq: number; text: string }
25
26export const WATCHER_SYSTEM = `You are the session-boost watcher. The person's main Claude Code chat is in the middle of a task. You follow its progress and launch read-only helper subagents that make it faster or better. You never edit files and never talk to the person.
27
28Loop:
291. Call ${PROGRESS_TOOL}. It waits a few seconds for new activity and returns what the main chat did since your last call, the helpers' status and results, and whether to stop.
302. Decide whether a helper is a clear win: independent work the main chat will need soon that can run in parallel, such as finding every call site or usage, running the test suite or build and reporting failures, reviewing the changes made so far for bugs, or reading docs/code the main chat is about to need. Never launch a helper that duplicates what the main chat or another helper is already doing. When in doubt, don't.
313. To launch one, call ${SPAWN_TOOL} with a short description and a complete, self-contained prompt (the helper sees nothing of the conversation): name files, symbols and commands, and say what to report. Helpers are read-only.
324. Go back to 1. Stop (end your turn with a one-line summary) as soon as ${PROGRESS_TOOL} returns stop: true.
33
34Helpers' results are delivered to the main chat automatically; do not relay them.`
35
36export const HELPER_SYSTEM = `You are a read-only helper launched by session-boost to speed up the person's main Claude Code chat, which is working in this repository at the same time.
37
38- Never modify anything: no file edits or writes, no git commits, checkouts, stashes or resets, no installs, no commands that write to the working tree. Reading files, searching, and running tests, builds, linters or type-checkers that don't write to the repo are fine.
39- Do exactly the task you were given, then stop.
40- Your final message is delivered to the main chat as-is: lead with the answer, cite file:line for every claim about code, mark anything you could not confirm, and keep it under 300 words.`
41
42/** One line for a tool call: the tool and its most telling argument. */
43export function summarizeCall(tool: string, input: Record<string, unknown>): string {
44 for (const key of ['description', 'file_path', 'path', 'pattern', 'command', 'url', 'query', 'prompt']) {
45 const value = input[key]
46 if (typeof value === 'string' && value.trim() !== '') return `${tool} ${oneLine(value, 80)}`
47 }
48
49 return tool
50}
51
52/** What an agent is doing now, from its transcript: its in-flight tool call, else its last text. */
53export function describeActivity(messages: readonly SessionMessage[]): string {
54 for (let i = messages.length - 1; i >= 0; i--) {
55 const message = messages[i]
56 if (!message || message.role !== 'assistant') continue
57 const open = message.toolUses.findLast(use => use.result === undefined && use.text === undefined)
58 if (open) return `▸ ${summarizeCall(open.tool, open.input)}`
59 const last = message.toolUses.at(-1)
60 if (message.text.trim() !== '') return oneLine(message.text, 120)
61 if (last) return `✓ ${summarizeCall(last.tool, last.input)}`
62 }
63
64 return 'starting…'
65}
66
67/** The agent's final answer: its last assistant text. */
68export function finalAnswer(messages: readonly SessionMessage[]): string {
69 const last = messages.findLast(m => m.role === 'assistant' && m.text.trim() !== '')
70 const text = last?.text.trim() ?? '(no answer)'
71
72 return text.length > RESULT_CHARS ? `${text.slice(0, RESULT_CHARS)}…` : text
73}
74
75/** The main chat so far, for the watcher's opening prompt. */
76export function mainDigest(messages: readonly SessionMessage[], rows = 12): string {
77 return messages
78 .slice(-rows)
79 .map(m => {
80 const calls = m.toolUses.map(use => ` - ${summarizeCall(use.tool, use.input)}`).join('\n')
81 const text = m.text.trim() === '' ? '' : oneLine(m.text, 600)
82 return `${m.role === 'user' ? 'User' : 'Assistant'}: ${text}${calls ? `\n${calls}` : ''}`
83 })
84 .join('\n')
85}
86
87export function watcherPrompt(task: string, digest: string): string {
88 return [
89 'The person just asked to boost the task their main chat is working on.',
90 '',
91 '<task>',
92 task || '(the turn started without a typed prompt; infer the task from the conversation)',
93 '</task>',
94 '',
95 '<conversation_so_far>',
96 digest || '(nothing yet)',
97 '</conversation_so_far>',
98 '',
99 `Start your loop by calling ${PROGRESS_TOOL}.`,
100 ].join('\n')
101}
102
103export function helperDelivery(description: string, answer: string, isFailed: boolean): string {
104 return [
105 `[session-boost] A read-only helper ${isFailed ? 'failed' : 'finished'}: ${description}.`,
106 'Use what helps the current task; ignore it otherwise.',
107 '',
108 answer,
109 ].join('\n')
110}
111
112export function oneLine(text: string, max: number): string {
113 const line = text.replace(/\s+/g, ' ').trim()
114
115 return line.length > max ? `${line.slice(0, max - 1)}…` : line
116}
117
118/** A model the panel can suggest: its tier (what jobs it suits) and its price, $ per MTok. */
119export type ModelOption = { id: string; label: string; tier: number; input: number; output: number }
120
121/**
122 * The current generation, most capable first (prices as of 2026-10-06; older models of each
123 * family cost the same or more, so they are never the cheaper suggestion).
124 * Tier: 3 = hardest reasoning and design, 2 = everyday coding, 1 = search, lookup and summaries.
125 */
126export const MODELS: readonly ModelOption[] = [
127 { id: 'claude-fable-5-1', label: 'Fable 5.1', tier: 4, input: 10, output: 50 },
128 { id: 'claude-opus-5-5', label: 'Opus 5.5', tier: 3, input: 4, output: 20 },
129 { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5', tier: 2, input: 2, output: 10 },
130 { id: 'claude-haiku-5-5', label: 'Haiku 5.5', tier: 1, input: 0.1, output: 0.5 },
131]
132
133/** Prices of the families' older models, so a parent on one of them still compares right. */
134const OLDER: Record<string, { input: number; output: number }> = {
135 'fable-5': { input: 10, output: 50 },
136 'mythos-5': { input: 10, output: 50 },
137 'opus-5': { input: 5, output: 25 },
138 'opus-4': { input: 5, output: 25 },
139 'sonnet-5': { input: 2, output: 10 },
140 'sonnet-4': { input: 3, output: 15 },
141 'haiku-4': { input: 1, output: 5 },
142}
143
144/** A model name as the session or a spawn spells it (`claude-opus-5-5`, `opus`, `Opus 5.5 (1M)`) to its price. */
145export function priceOf(name: string | undefined): { input: number; output: number } | undefined {
146 if (!name) return undefined
147 const lower = name.toLowerCase()
148 const family = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'].find(f => lower.includes(f))
149 if (!family) return undefined
150 const current = MODELS.find(m => m.id.includes(family === 'mythos' ? 'fable' : family))
151 const version = /(\d+)(?:[-.](\d+))?/.exec(lower.slice(lower.indexOf(family)))
152 if (!version) return current
153 const exact = MODELS.find(m => m.id === `claude-${family}-${version[1]}${version[2] ? `-${version[2]}` : ''}`)
154
155 return exact ?? OLDER[`${family}-${version[1]}`] ?? current
156}
157
158/** A blended $ per MTok, output weighted as agents write about a third as much as they read. */
159function cost(price: { input: number; output: number }): number {
160 return price.input * 0.75 + price.output * 0.25
161}
162
163const LOOKUP_TYPES = new Set(['explore', 'claude-code-guide', 'statusline-setup'])
164const REASONING_TYPES = new Set(['plan'])
165const REASONING_WORDS = /\b(review|design|architect|plan|diagnos|debug|security|audit|root cause|trade-?off|refactor)/i
166const LOOKUP_WORDS = /\b(find|search|locate|list|look ?up|check|docs?|research|read|summar|verify|where)/i
167const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
168
169/** What kind of job a subagent does: by its type, its description, then the tools it has used. */
170export function classifyJob(type: string, description: string, tools: readonly string[]): 'lookup' | 'coding' | 'reasoning' {
171 const kind = type.toLowerCase()
172 if (REASONING_TYPES.has(kind) || REASONING_WORDS.test(description)) return 'reasoning'
173 if (tools.some(tool => WRITE_TOOLS.has(tool))) return 'coding'
174 if (LOOKUP_TYPES.has(kind) || LOOKUP_WORDS.test(description)) return 'lookup'
175 if (tools.length > 0 && tools.every(tool => /^(Read|Grep|Glob|WebFetch|WebSearch|ToolSearch|mcp__)/.test(tool))) return 'lookup'
176
177 return 'coding'
178}
179
180const NEEDED_TIER = { lookup: 1, coding: 2, reasoning: 3 } as const
181
182/** Models that suit the job and cost less than the chat's model, most capable first. */
183export function cheaperModels(parentModel: string | undefined, job: 'lookup' | 'coding' | 'reasoning'): ModelOption[] {
184 const parent = priceOf(parentModel)
185 if (!parent) return []
186
187 return MODELS.filter(m => m.tier >= NEEDED_TIER[job] && cost(m) < cost(parent))
188}
189
190/** "Sonnet 5.5 ($2/$10, 50% less)": what the panel shows for one suggestion. */
191export function describeOption(option: ModelOption, parentModel: string | undefined): string {
192 const parent = priceOf(parentModel)
193 const saving = parent ? Math.round((1 - cost(option) / cost(parent)) * 100) : undefined
194 const money = (n: number) => (n < 1 ? `$${n.toFixed(2)}` : `$${n}`)
195
196 return `${option.label} (${money(option.input)}/${money(option.output)}${saving === undefined ? '' : `, ${saving}% less`})`
197}
198
199/** How often the overspend cue flips between highlighted and plain. */
200export const BLINK_MS = 500
201
202/**
203 * The model to suggest instead of the one a subagent runs on: the cheapest that suits its job
204 * and costs less. Undefined when it already runs on the cheapest capable model (or the price is unknown).
205 */
206export function recommendedModel(runsOn: string | undefined, job: 'lookup' | 'coding' | 'reasoning'): ModelOption | undefined {
207 return cheaperModels(runsOn, job).at(-1)
208}
209
210export type Job = 'lookup' | 'coding' | 'reasoning'
211
212/** What a spawn can be told apart by before it runs: its type and the job its description reads as. */
213export function spawnKind(type: string, description: string): { key: string; job: Job; label: string } {
214 const job = classifyJob(type, description, [])
215
216 return { key: `${type}:${job}`, job, label: `${type} ${job} jobs` }
217}
218
219/** The more demanding of two jobs. */
220export function harderJob(a: Job, b: Job): Job {
221 return NEEDED_TIER[a] >= NEEDED_TIER[b] ? a : b
222}
223
224/** The cheaper of two models (the first when a price is unknown). */
225export function cheaperOf(a: string, b: string): string {
226 const pa = priceOf(a)
227 const pb = priceOf(b)
228
229 return pa && pb && cost(pb) < cost(pa) ? b : a
230}
231types/index.d.ts 55 lines1export type AgentRole = 'watcher' | 'helper'
2
3/** One subagent as the panel draws it. */
4export type AgentRow = {
5 id: string
6 description: string
7 type: string
8 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
9 /** What it is doing now: its in-flight tool call, else its last line of text. */
10 activity: string
11 parentId?: string
12 role?: AgentRole
13 /** The model it runs on, as its spawn resolved it (absent for one spawned before the mod loaded). */
14 model?: string
15 /** What kind of job it is doing, read from its type, description and the tools it used. */
16 job?: 'lookup' | 'coding' | 'reasoning'
17 /** The one cheaper model the panel offers for it, absent when it already runs on the cheapest that suits. */
18 suggestion?: Suggestion
19}
20
21/** A cheaper model for a kind of subagent: what the row's button offers. */
22export type Suggestion = {
23 /** The model's id, what a spawn runs on (`claude-haiku-5-5`). */
24 id: string
25 /** `Haiku 5.5` */
26 label: string
27 /** Its price against the model the subagent runs on: `$0.10/$0.50, 98% less`. */
28 detail: string
29 /** The kind of subagent a click sets it for: its type and the job its spawn reads as (`Explore:lookup`). */
30 kind: string
31 /** That kind in words: `Explore lookup jobs`. */
32 kindLabel: string
33}
34
35export type HelperRow = {
36 /** Unique per spawn_helper call: what the queue and the state row are matched by. */
37 key: string
38 /** Absent while queued. */
39 id?: string
40 description: string
41 status: 'queued' | 'running' | 'done' | 'failed'
42}
43
44export type Boost = {
45 status: 'off' | 'starting' | 'watching' | 'stopping'
46 watcherId: string | null
47 helpers: HelperRow[]
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'session-boost': { agents: AgentRow[]; boost: Boost; models: Record<string, string>; blink: boolean; prefs: Record<string, string> }
53 }
54}
55