Mod: classifies each prompt (cheap model) and tells Claude which agent/model fits; band with off switch

Mod. A cheap classifier tags each prompt (search / implement / design / review / trivial) and adds a hidden hint naming the agent to use. Shows route: <label> in the status line and an Off button above the prompt (remembered across sessions).
Options: enabled (true), minChars (40). Costs one small model call per prompt; routing rules live in the hey-you-do-it skill.
Besides agent routes, three labels attach skill hints: destructive-or-ambiguous (follow ask-dont-guess), compare-or-explain-flow (follow show-dont-tell), second-opinion (follow phone-a-friend / ask-the-audience). This is how skills get triggered on implicit requests.
The chip reads ✦ design → spy [Off] (label, then who gets the work). Small chips from different plugins share one row above the prompt instead of stacking.
hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { RANK, chipKey, injectChips, isEmpty } from './chips'
5
6const route = atom({ plugin: 'matchmaker', key: 'route' } as const, null)
7const isOff = atom({ plugin: 'matchmaker', key: 'isOff' } as const, false)
8
9export const LABELS = [
10 'search',
11 'implement',
12 'design',
13 'review',
14 'trivial',
15 'destructive-or-ambiguous',
16 'compare-or-explain-flow',
17 'second-opinion',
18] as const
19export const HINT: Record<string, string> = {
20 search: 'Route: read/search-heavy. Delegate the sweep to the `scout` agent (haiku); take back a summary. See skill hey-you-do-it.',
21 implement: 'Route: clear implementation. Use `engineer` (sonnet) if the change spans several files.',
22 design: 'Route: ambiguous/cross-cutting. Plan with `spy` (opus) first, then implement.',
23 review: 'Route: review. Use `sniper` (opus) on the diff.',
24 trivial: 'Route: trivial. Do it inline; no subagent.',
25 'destructive-or-ambiguous':
26 'Route: this request may be destructive, irreversible or ambiguous. If the ask-dont-guess skill is available, follow it: list assumptions and confirm with the user (mcp__your-call__ask) before acting.',
27 'compare-or-explain-flow':
28 'Route: comparison or structure. If the show-dont-tell skill is available, follow it: answer with a chart or diagram (a fenced viz block) rather than prose alone.',
29 'second-opinion':
30 'Route: the user wants a second opinion. If the phone-a-friend or ask-the-audience skill is available, follow it.',
31}
32
33/** Who the hint sends the work to, for the chip. */
34export const AGENT: Record<string, string> = {
35 search: 'scout',
36 implement: 'engineer',
37 design: 'spy',
38 review: 'sniper',
39 trivial: 'inline',
40 'destructive-or-ambiguous': 'ask first',
41 'compare-or-explain-flow': 'chart it',
42 'second-opinion': 'second opinion',
43}
44
45export const chipText = (label: string): string => `✦ ${label} → ${AGENT[label] ?? label}`
46
47export const shouldClassify = (text: string, hasOrigin: boolean, isOffNow: boolean, minChars = 40): boolean => {
48 const t = text.trim()
49 return !hasOrigin && !isOffNow && !t.startsWith('/') && t.length >= minChars
50}
51
52export const register: Register = (on, options) => {
53 const minChars = typeof options.minChars === 'number' ? options.minChars : 40
54
55 on('session.start', async ($, e, next) => {
56 const saved = await $.store.get('isOff')
57 await update($, isOff, () => saved === true || options.enabled === false)
58 return next(e)
59 })
60
61 on('prompt.submit', async ($, e, next) => {
62 const t = e.text.trim()
63
64 if (!shouldClassify(t, e.origin !== undefined, await read($, isOff), minChars)) return next(e)
65
66 const label = await $.model.classify(t, LABELS).catch(() => undefined)
67
68 if (!label) return next(e)
69
70 await update($, route, () => label)
71 $.ui.status(`✦ ${label}`)
72
73 return next({ ...e, context: [...(e.context ?? []), HINT[label] ?? ''] })
74 })
75
76 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77 const r = await read($, route)
78 const off = await read($, isOff)
79
80 if (e.props.hasSurvey || (r === null && !off)) return next(e)
81
82 const { Box, Text, Button } = $.ui.resolve(e)
83 const rest = await next(e)
84 const chip = (
85 <Box key={chipKey(RANK.route, 'route')}>
86 <Text key="mm" color={off ? undefined : 'magenta'} dimColor={off}>
87 {off ? '✦ matchmaker off ' : `${chipText(r ?? '')} `}
88 </Text>
89 <Button
90 key="toggle"
91 label={off ? 'On' : 'Off'}
92 onPress={async () => {
93 const v = await update($, isOff, x => !x)
94 await $.store.set('isOff', v)
95 }}
96 />
97 </Box>
98 )
99 const joined = injectChips(rest, chip)
100
101 if (joined) return joined as never
102 if (isEmpty(rest)) return <Box key="chips" flexWrap="wrap">{chip}</Box>
103
104 return (
105 <Box flexDirection="column">
106 <Box key="chips" flexWrap="wrap">{chip}</Box>
107 {rest}
108 </Box>
109 )
110 })
111}
112hooks/chips.ts 42 lines1// Chip row: small status chips from different plugins share one row above the prompt.
2// - The row is a Box with props.key === 'chips' (wrapping, so a narrow terminal gets a second line
3// instead of cut-off buttons).
4// - Each chip is a Box keyed `chip:<rank>:<name>`; chips sort by rank so the order is the same
5// whichever plugin happens to draw first: gate 5, context 10, route 20, result 30.
6// Plain data in, plain data out, so it works on whatever `next(e)` returns.
7export type Node = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
8
9export const CHIPS_KEY = 'chips'
10export const GAP = ' '
11export const RANK = { gate: 5, context: 10, route: 20, result: 30 } as const
12
13export const chipKey = (rank: number, name: string): string => `chip:${String(rank).padStart(3, '0')}:${name}`
14
15const isNode = (n: unknown): n is Node => typeof n === 'object' && n !== null
16
17const keyOf = (n: unknown): string => (isNode(n) && typeof n.props?.key === 'string' ? n.props.key : '')
18
19/** Chips in rank order with a gap between each. */
20export const arrange = (chips: unknown[]): unknown[] =>
21 [...chips]
22 .sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
23 .flatMap((c, i) => (i === 0 ? [c] : [GAP, c]))
24
25/** Add `chip` to the chips row inside `node`; undefined when there is none. */
26export const injectChips = (node: unknown, chip: unknown): Node | undefined => {
27 if (!isNode(node)) return undefined
28 if (node.props?.key === CHIPS_KEY) {
29 const existing = (node.children ?? []).filter(c => c !== GAP)
30 return { ...node, children: arrange([...existing, chip]) }
31 }
32 const kids = node.children ?? []
33 for (let i = kids.length - 1; i >= 0; i--) {
34 const hit = injectChips(kids[i], chip)
35 if (hit) return { ...node, children: kids.map((c, j) => (j === i ? hit : c)) }
36 }
37 return undefined
38}
39
40/** True when the band beneath drew nothing. */
41export const isEmpty = (node: unknown): boolean => node == null || (isNode(node) && (node.children ?? []).length === 0)
42types/index.d.ts 8 lines1export type Route = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'matchmaker': { route: Route; isOff: boolean }
6 }
7}
8