SLOPSHOPPER

session-boost

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

paneguardcommandtoasttooltimer
v?no licenseupdated 2026-10-11nadesh00/setup-code/plugins/session-boost
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-boost
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /subagents │ ⎿ session-boost: Subagents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
No subagents yet.
README

setup-code

A Claude Code plugin marketplace with three mods.

PluginWhat it does
prompt-enhanceEnd 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.

Plugins

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.

prompt-enhance

What it adds

  • A prompt.submit hook for prompts typed in the prompt bar. No slash commands.

How to use it

  1. Type a prompt and end it with a space and ++, e.g. fix the flaky login test ++.
  2. Press Enter. The prompt is not sent; the status line shows Enhancing prompt….
  3. The rewritten prompt lands in the prompt bar. If the bar is empty or still holds your original, it is replaced; if you started typing something else, the rewrite is appended after it.
  4. Review it, edit if you like, and press Enter to send.

How it rewrites

  • Uses the model the chat is on now (as /model shows it), at high effort, with a 120 s timeout.
  • Gets the working directory and the last 6 messages of the conversation, only to resolve references like "that bug" or "this file". It never adds tasks from them.
  • Keeps your intent, language, tone, code and paths. Short prompts stay short; a clear prompt comes back nearly unchanged.
  • For a change task it adds a one-line finish line ("Done means: …"); for a long task with a real decision point, "Stop and ask me only if …"; for research, a note to mark anything unconfirmed.
  • Removes "think step by step", ALL-CAPS / "IMPORTANT" emphasis and "You are an expert…" preambles.

If something goes wrong

  • ++ on its own is dropped with Nothing to enhance.
  • If the model doesn't answer, your original prompt is put back in the bar and a toast says why.
  • If the hook itself fails, your prompt is sent as normal.

session-boost

What it adds

ItemKindWhat it does
/subagentsCommandOpens the Subagents side panel.
/boostCommandStarts a watcher that launches read-only helper subagents for the turn in progress.
/boost stopCommandStops a running boost.
Subagent model defaultagent.spawn hookSubagents run on the chat's own model unless the caller names one.
session-boost:watcherAgent (internal)The /boost watcher. Hidden from the main model.
session-boost:helperAgent (internal)A read-only helper started by the watcher. Hidden from the main model.
mcp__session-boost__progressTool (internal)Lets the watcher wait for new main-chat activity. Only the watcher may call it.
mcp__session-boost__spawn_helperTool (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:

  • A status mark (● 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.
  • What it is doing now: its in-flight tool call, or its last line of text. Finished agents show their final answer.
  • The kind of job it reads as: lookup, coding or reasoning, judged from its type, its description and the tools it has used.
  • The last 12 finished agents are kept below the running ones.

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.

  • Click it and future subagents of that kind (agent type + job, e.g. Explore lookup jobs) start on that model. The button turns to ✓ <model>.
  • Click ✓ again to undo; that kind follows the chat's model again.
  • Picks are saved across sessions and are never pricier than the chat's own model.
  • Rows already on the cheapest suitable model say so instead.

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)

  1. While Claude is working on a turn, run /boost. (With nothing in progress it tells you so.)
  2. A watcher subagent starts. It follows what the main chat does and, when a clear win shows up, launches read-only helpers: finding call sites, running the tests or build, reviewing changes so far, reading docs the main chat will need.
  3. At most 3 helpers run at once; more are queued. The panel shows a line like Boost watching: 2 running, 1 queued, 3 done.
  4. When a helper finishes, its answer is added to the main chat as a [session-boost] message and a toast appears. The main chat uses it if it helps.
  5. The boost ends by itself when the main turn ends. /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.

conversations

What it adds

  • /conversations: a command that opens the Past conversations panel.

How to use it

  1. Run /conversations. The panel opens focused on the newest conversation.
  2. Each row shows a title, how long ago it was last active and how many prompts you typed in it.
  3. Click a row (or press Enter on it) to run /resume <id> for that conversation.

What it lists

  • This project's transcripts from ~/.claude/projects/<project>/ (or under $CLAUDE_CONFIG_DIR if set), newest first, up to 60.
  • The current session and conversations with no typed prompt are left out.

Where titles come from, in order:

  1. Your own title, set with /rename.
  2. Claude Code's own title or summary for the conversation.
  3. A 3–7 word title written by Haiku from your first 6 prompts.
  4. Your first prompt, shortened.

Titles are cached per transcript and only worked out again when the transcript changes, so the panel fills at once on later opens.

Install

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.

Updates

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.

Developing

Each plugin is a hooks module under plugins/<name>/hooks/. Check one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>
Source 3 files
hooks/register.tsx 521 lines
1import { 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}
521
hooks/core.ts 231 lines
1import 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}
231
types/index.d.ts 55 lines
1export 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