Picks the model and reasoning effort of every subagent: a recommendation by task kind, a band above the prompt, and a saved default that wins. Needs Claude…

A Claude Code mod that picks the model and reasoning effort of every subagent, so a review runs on Opus and a file search doesn't.
<img src="docs/media/demo.gif" alt="Recording from a real Claude Code session: in Manual a pick pane asks before the subagent runs; in Auto the saved default sonnet / high replaces the opus the agent asked for" width="900">
<sub>Recorded from a real Claude Code 2.1.295 session on Windows 11 with the mod installed from this repo (frames cropped, captions added). The band, full size:</sub>
<img src="docs/media/band.png" alt="The tuner's band above the prompt: Tuner, Manual, Auto (active), default sonnet / high, Reset" width="696">
When the agent starts a subagent, the tuner settles its model and effort: by asking you, by a recommendation for the kind of task, or by your saved default.
Inside Claude Code (2.1.295 or later):
/plugin install subagent-tuner --marketplace Kruhlikau/subagent-tuner
Answer y to add the marketplace and pick the user scope. It starts working in that session.
/plugin marketplace add Kruhlikau/subagent-tuner
/plugin install subagent-tuner@subagent-tuner
claude plugin install subagent-tuner --marketplace Kruhlikau/subagent-tuner
git clone https://github.com/Kruhlikau/subagent-tuner
claude --plugin-dir ./subagent-tuner
Update with /plugin marketplace update subagent-tuner, then /plugin update subagent-tuner@subagent-tuner. Remove with /plugin uninstall subagent-tuner@subagent-tuner.
The band above the prompt holds the mode and your saved default. Click Manual or Auto to switch, click the model or the effort to cycle it (rec. means "use the recommendation"), Reset to clear the default. Both are kept across sessions.
Manual (the default after install) asks before a subagent starts. On a terminal 144 columns wide or more the question is a pane beside the transcript:
general-purpose · explore wide
Model: haiku ▾ Effort: low ▾
r: Run d: Run and save as default c: Cancel
On a narrower terminal it is a short chain of dialogs. A dismissed question starts the subagent with what was shown; nothing is ever held back.
Manual is the mode right after install, so every subagent launch asks first. The pane goes ahead with the live selection after ten minutes without an answer; the dialogs wait until you answer or dismiss them. Switch to Auto before you leave a session to run on its own.
Auto never asks. Your saved default wins, even over a model or effort the agent set itself (say, a rule in CLAUDE.md that makes it pass model: "opus"). A field left at rec. keeps what the call set, else takes the recommendation (for a custom agent, see below: none). A toast says what was applied: Subagent (overridden) -> sonnet/high.
For built-in agents (general-purpose, Explore, Plan and the like):
| Task kind (from the subagent's type, else its description) | Model | Effort |
|---|---|---|
| review, audit, verify, adversarial, security | opus | high |
| implement, build, fix | sonnet | medium |
| explore, search, research | haiku | low |
A prompt longer than 4,000 characters gets one step more effort. Any other task gets no recommendation and inherits the session's model and effort.
A custom agent is left to its own definition. The mod sees only the Agent call, not the agent's definition file, so a model or effort set in its frontmatter is invisible to it. It does see which agent types Claude Code offers, and whether each is built in. For an agent you or a plugin defined, with no saved default, it recommends nothing, so a custom code-reviewer pinned to sonnet stays on sonnet: Auto leaves the call untouched, and Manual's pane starts on inherit. It does not read the pinned value, so the pane shows inherit, not sonnet. A saved default still applies to it, and so does a model or effort you pick in Manual's pane. Built-in agents such as general-purpose get the recommendation above. Claude Code can announce a type after the call has been made; if the type then turns out to be built in, Auto still recommends the model for it (not the effort, which the spawn cannot take), so a late announcement costs only the effort recommendation.
claude -p and SDK hosts: nothing is changed, so scripted runs and evals measure what they ask for.model and effort fields of Agent calls.sh (macOS, Linux) or powershell.exe (Windows), only to wait for your answer in the pane: once to check which shell exists, then per open pane one process to remove the probe file below and one to wait..waits/ in the plugin's own folder when you answer in the pane; the waiting process deletes it. Before a pane opens it writes and removes an empty .probe file there, to be sure the folder takes a write.Claude Code 2.1.295 or later (mods). Tested on 2.1.295, 2.1.296 and 2.1.300 on macOS and on Windows 11 with Git for Windows' default settings.
.waits/ folder cannot be written (the toast says why).--plugin-dir. Keep one.git clone https://github.com/Kruhlikau/subagent-tuner && cd subagent-tuner
claude plugin validate --strict .
claude plugin test .
claude --plugin-dir .
hooks/register.tsx holds every call on $; the other modules are pure. The tests run against a fake $ in hooks/harness.ts. Once Claude Code has loaded the mod, .claude-plugin/types/ holds the API types and npx tsc -p . type-checks it. Bump version in .claude-plugin/plugin.json with every change: installed copies are cached by version.
hooks/register.tsx 774 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, MatchedHook, Register } from 'claude-code'
3
4import type {
5 SubagentTunerChoice as Choice,
6 SubagentTunerMode as Mode,
7 SubagentTunerPending as Pending,
8 SubagentTunerQueue,
9 SubagentTunerSettings,
10} from '../types'
11import {
12 EFFORT_OPTIONS,
13 MODEL_OPTIONS,
14 MORE_EFFORT_OPTIONS,
15 MORE_MODEL_OPTIONS,
16 fieldQuestion,
17 headerFor,
18 mainDialog,
19 readField,
20 readMain,
21} from './dialog'
22import { INHERIT_VALUE, paneView } from './pane-view'
23import {
24 FORK_TYPE,
25 INHERIT,
26 describeChoice,
27 autoPatch,
28 hasOpenField,
29 hasOwnDefinition,
30 isJudgedByName,
31 presetFor,
32 recommend,
33 resolveDefault,
34} from './recommend'
35import {
36 DEFAULT_MODE,
37 STORE_MODE,
38 STORE_SAVED,
39 asDefault,
40 cycleField,
41 parseChoice,
42 parseMode,
43 patched,
44 withPending,
45 without,
46} from './state'
47import type { Field } from './state'
48import { rowsLeft, stacked } from './stack'
49import { MIN_COLUMNS, bandView } from './view'
50import type { Actions } from './view'
51
52/** How long after a launch a stalled save of the default is waited for. */
53export const SAVE_WAIT_MS = 2_000
54
55/** The longest a pick pane is waited for: `$.process.run` takes ten minutes at most (d.ts). */
56export const PANE_WAIT_MS = 600_000
57
58/** Every pane of the tuner starts with this, so a reload can find the stale ones. */
59export const PANE_PREFIX = 'subagent-tuner-'
60
61const PANE_ROWS = 9
62
63const settings = atom(
64 { plugin: 'subagent-tuner', key: 'settings' } as const,
65 { mode: DEFAULT_MODE, saved: null, hydrated: false } satisfies SubagentTunerSettings,
66)
67
68const queue = atom({ plugin: 'subagent-tuner', key: 'queue' } as const, {
69 items: [],
70} satisfies SubagentTunerQueue)
71
72type Host = EngineInterface
73type AgentHook = MatchedHook<'tool.call', { readonly tool: 'Agent' }>
74type AgentCall = Parameters<AgentHook>[1]
75type AgentNext = Parameters<AgentHook>[2]
76type Patch = Partial<Pick<AgentCall, 'model' | 'effort'>>
77
78// This module holds every call on `$`: the loader follows `$` only within
79// one file, so the siblings are pure (logic, the dialog's words, the view).
80
81/** What the store holds; a bad stored value counts as unset. */
82async function stored($: Host) {
83 const [mode, saved] = await Promise.all([$.store.get(STORE_MODE), $.store.get(STORE_SAVED)])
84
85 // An empty default (both fields inherited) is no default.
86 return { mode: parseMode(mode, DEFAULT_MODE), saved: asDefault(parseChoice(saved)) }
87}
88
89// Loaded once: the reducer keeps what is already there, so a load that lost a
90// race to a write (or to another load) changes nothing.
91async function ensureHydrated($: Host): Promise<void> {
92 if ((await read($, settings)).hydrated) {
93 return
94 }
95
96 const loaded = await stored($)
97
98 await update($, settings, current => (current.hydrated ? current : { ...loaded, hydrated: true }))
99}
100
101// Store writes are versioned per key, never queued behind one another. Each
102// click bumps the version and writes the state as it stands when the write
103// runs; a write that lands after a newer click writes again, so an earlier
104// $.store.set that finishes late cannot leave the store disagreeing with the
105// band, and one that never finishes holds nothing back. The click waits for its
106// write only for SAVE_WAIT_MS and says when a save is stuck. A store that
107// refuses a write is said, since the setting would otherwise vanish quietly; a
108// state that cannot be read rejects the click that asked. (The versions are a
109// module variable: a hot reload resets them, which only drops the repair of a
110// write already in flight.)
111const versions = new Map<string, number>()
112const WHAT: Record<string, string> = { [STORE_MODE]: 'the mode', [STORE_SAVED]: 'the default' }
113
114async function persistLatest($: Host, key: string, current: () => Promise<unknown>): Promise<void> {
115 versions.set(key, (versions.get(key) ?? 0) + 1)
116
117 const write = (async (): Promise<void> => {
118 let written = -1
119
120 while (written !== versions.get(key)) {
121 written = versions.get(key) ?? 0
122
123 const value = await current()
124
125 try {
126 await $.store.set(key, value)
127 } catch (error) {
128 $.ui.toast(`Subagent tuner: could not save (${error instanceof Error ? error.message : 'store error'})`)
129
130 return
131 }
132 }
133 })()
134 const stop = new AbortController()
135 const isStuck = await Promise.race([
136 write.then(() => false),
137 $.clock.sleep(SAVE_WAIT_MS, { signal: stop.signal }).then(
138 () => true,
139 () => false,
140 ),
141 ])
142
143 stop.abort()
144
145 if (isStuck) {
146 // Whatever the write does later, the click has been answered.
147 write.catch(() => undefined)
148 $.ui.toast(`Subagent tuner: saving ${WHAT[key] ?? 'a setting'} is taking too long`)
149 }
150}
151
152async function setMode($: Host, mode: Mode): Promise<void> {
153 await ensureHydrated($)
154 await update($, settings, current => ({ ...current, mode }))
155 await persistLatest($, STORE_MODE, async () => (await read($, settings)).mode)
156}
157
158async function changeSaved($: Host, change: (saved: Choice | null) => Choice | null): Promise<void> {
159 await ensureHydrated($)
160 await update($, settings, current => ({ ...current, saved: asDefault(change(current.saved)) }))
161 await persistLatest($, STORE_SAVED, async () => (await read($, settings)).saved)
162}
163
164const actionsFor = ($: Host): Actions => ({
165 setMode: mode => setMode($, mode),
166 cycle: (field: Field) => changeSaved($, saved => cycleField(saved, field)),
167 reset: () => changeSaved($, () => null),
168})
169
170// The call's model and effort are the tool's own literal unions; the tuner
171// holds them as plain strings it checked against the same names.
172const asPatch = (patch: { model?: string; effort?: string }): Patch => patch as Patch
173const asCall = (call: object): AgentCall => call as AgentCall
174
175/** The call with exactly the selection's model and effort. */
176function selected(e: AgentCall, choice: Choice): object {
177 const { model: _model, effort: _effort, ...rest } = e
178
179 return {
180 ...rest,
181 ...(choice.model === null ? {} : { model: choice.model }),
182 ...(choice.effort === null ? {} : { effort: choice.effort }),
183 }
184}
185
186// Manual: the selection is the whole answer, so the call's own value is replaced
187// by it, and inherit removes the field from the call.
188function applySelection($: Host, e: AgentCall, next: AgentNext, choice: Choice, label: string) {
189 if (choice.model !== (e.model ?? null) || choice.effort !== (e.effort ?? null)) {
190 $.ui.toast(`${label} -> ${describeChoice(choice)}`)
191 }
192
193 return next(asCall(selected(e, choice)))
194}
195
196// Auto: the saved default outvotes the call (a rule that makes the agent pass
197// `model: "opus"` included), so the toast says where the call's own choice was
198// overridden.
199function applyAuto($: Host, e: AgentCall, next: AgentNext, saved: Choice | null, rec: Choice) {
200 const patch = autoPatch(e, saved, rec)
201 const result = { model: patch.model ?? e.model ?? null, effort: patch.effort ?? e.effort ?? null }
202 const isOverride =
203 (patch.model !== undefined && e.model !== undefined && patch.model !== e.model) ||
204 (patch.effort !== undefined && e.effort !== undefined && patch.effort !== e.effort)
205
206 if (result.model !== (e.model ?? null) || result.effort !== (e.effort ?? null)) {
207 $.ui.toast(`${isOverride ? 'Subagent (overridden)' : 'Subagent'} -> ${describeChoice(result)}`)
208 }
209
210 return next({ ...e, ...asPatch(patch) })
211}
212
213// A person is asked only where the band can be drawn too (the terminal and the
214// desktop): on another surface (vscode, mobile) they could not change the mode or
215// the default and would be asked on every call.
216async function canAsk($: Host): Promise<boolean> {
217 return (await $.session.surfaces()).some(surface => surface === 'terminal' || surface === 'desktop')
218}
219
220// A -p run or an SDK host draws nothing: nobody there chose a mode or a default,
221// and a model changed behind its back would skew what the run measures (evals).
222async function isHeadless($: Host): Promise<boolean> {
223 return (await $.session.surfaces()).length === 0
224}
225
226const tuneAgent: AgentHook = async ($, e, next) => {
227 // A fork ignores model and effort.
228 if (e.subagent_type === FORK_TYPE || (await isHeadless($))) {
229 return next(e)
230 }
231
232 await ensureHydrated($)
233
234 const { mode, saved } = await read($, settings)
235 // A custom agent's own definition may pin a model and an effort, unseen here.
236 const rec = hasOwnDefinition(e.subagent_type, offers) ? INHERIT : recommend(e)
237
238 if (mode === 'auto' || !(await canAsk($))) {
239 if (isJudgedByName(e.subagent_type, offers)) {
240 judgedByName.set(e.tool_use_id, e)
241 }
242
243 return applyAuto($, e, next, saved, rec)
244 }
245
246 // Manual asks only about what the call left open.
247 if (!hasOpenField(e)) {
248 return next(e)
249 }
250
251 const fallback = resolveDefault(saved, rec)
252
253 // The pane where it can be seated unasked (wide terminals); the engine's own
254 // chain of the engine's own dialogs, where it cannot.
255 const preset = presetFor(e, fallback)
256 const viaPane = await askInPane($, e, next.signal, preset)
257 let reading: Outcome
258
259 if (viaPane.kind === 'unavailable') {
260 // A pane that is not placed (a narrow terminal) is the normal case: dialogs, unannounced.
261 if (viaPane.reason !== NOT_PLACED) {
262 $.ui.toast(`Subagent tuner: pane unavailable: ${viaPane.reason}`)
263 }
264
265 reading = await askChain($, e, next.signal, preset)
266 } else {
267 reading = viaPane
268 }
269
270 // An interrupted dispatch has no one left to tell, and nothing to start.
271 if (next.signal.aborted) {
272 return { deny: 'interrupted' }
273 }
274
275 // A dismissed or failed question must not hold the subagent back.
276 if (reading.kind === 'unanswered') {
277 return applySelection($, e, next, reading.choice ?? preset, 'Subagent (no answer)')
278 }
279
280 if (reading.kind === 'unreadable') {
281 $.ui.toast(reading.problem)
282
283 return { deny: 'could not read the answer; nothing was started' }
284 }
285
286 if (reading.kind === 'cancel') {
287 return { deny: 'cancelled by user' }
288 }
289
290 return reading.kind === 'save'
291 ? launchAndSave($, next, asCall(selected(e, reading.choice)), reading.choice)
292 : next(asCall(selected(e, reading.choice)))
293}
294
295type Outcome =
296 | { kind: 'run' | 'save'; choice: Choice }
297 | { kind: 'cancel' }
298 /** Nothing usable came back: the launch goes on, with the selection if there was one. */
299 | { kind: 'unanswered'; choice?: Choice }
300 | { kind: 'unreadable'; problem: string }
301
302/** The pane route could not be used: why, in a few words. */
303type Unavailable = { kind: 'unavailable'; reason: string }
304
305const NOT_PLACED = 'not placed'
306
307// Where the pane cannot be seated, a chain of the engine's own dialogs (`$.ui.ask`:
308// a `$` call, so the hook's time budget does not count the waits). The first
309// carries the selection in its labels and what to do; Change walks the field
310// dialogs and comes back to it with the new values, until Run, Run and save, or
311// Cancel. A dismissed or refused dialog fails open to the selection as it stands.
312async function ask1($: Host, question: string, options: readonly string[], header: string): Promise<string | undefined> {
313 try {
314 return await $.ui.ask(question, { options, header })
315 } catch {
316 return undefined
317 }
318}
319
320// One field's dialog; text typed under Other that names no value is asked once
321// more, then the previous value stays. `undefined`: the dialog was dismissed.
322async function askField($: Host, call: AgentCall, field: Field, previous: string | null): Promise<string | null | undefined> {
323 const header = field === 'model' ? 'Model' : 'Effort'
324 const options = field === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS
325
326 for (let attempt = 0; attempt < 2; attempt += 1) {
327 const answer = await ask1($, fieldQuestion(call, field), options, header)
328
329 if (answer === undefined) {
330 return undefined
331 }
332
333 const value = readField(field, answer)
334
335 if (value === 'more') {
336 const rest = await ask1($, fieldQuestion(call, field), field === 'model' ? MORE_MODEL_OPTIONS : MORE_EFFORT_OPTIONS, header)
337
338 if (rest === undefined) {
339 return undefined
340 }
341
342 const picked = readField(field, rest)
343
344 if (picked !== undefined && picked !== 'more') {
345 return picked
346 }
347 } else if (value !== undefined) {
348 return value
349 }
350 }
351
352 return previous
353}
354
355async function askChain($: Host, call: AgentCall, signal: AbortSignal, preset: Choice): Promise<Outcome> {
356 let current: Choice = { ...preset }
357
358 chain: for (;;) {
359 // An interrupted dispatch asks nothing more.
360 if (signal.aborted) {
361 return { kind: 'unanswered', choice: current }
362 }
363
364 const dialog = mainDialog(call, current)
365 const answer = await ask1($, dialog.question, dialog.options, 'Subagent')
366
367 if (answer === undefined) {
368 return { kind: 'unanswered', choice: current }
369 }
370
371 const said = readMain(dialog, answer)
372
373 if (said.kind === 'run' || said.kind === 'save') {
374 return { kind: said.kind, choice: current }
375 }
376
377 if (said.kind === 'typed') {
378 return { kind: 'run', choice: said.choice }
379 }
380
381 if (said.kind === 'cancel') {
382 return { kind: 'cancel' }
383 }
384
385 if (said.kind === 'unreadable') {
386 return said
387 }
388
389 // A field dialog dismissed discards the whole change: back to the first
390 // dialog with the selection as it was when Change was chosen.
391 let changed = current
392
393 for (const field of ['model', 'effort'] as const) {
394 if (signal.aborted) {
395 return { kind: 'unanswered', choice: current }
396 }
397
398 const value = await askField($, call, field, changed[field])
399
400 if (value === undefined) {
401 continue chain
402 }
403
404 changed = { ...changed, [field]: value }
405 }
406
407 current = changed
408 }
409}
410
411// One pane for each launch, so each launch is bound to its pane by the pane's
412// id and several at once are tabs the person moves between (the engine shows
413// one). What the pane's selectors hold is kept here too, beside the state they
414// draw from: every read of one dispatch sees one moment, the one before the pane
415// opened, so the hook cannot read the selection back from the state. A reload
416// drops this, and the hook it served with it.
417type Session = {
418 title: string
419 choice: Choice
420 /** What the person (or the dispatch) decided; absent while it waits. */
421 decision?: 'run' | 'save' | 'cancel' | 'abort'
422 sentinel: string
423}
424
425const sessions = new Map<string, Session>()
426
427const paneIdFor = (toolUseId: string): string => `${PANE_PREFIX}${toolUseId.replace(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 64)
428
429const pendingFor = (call: AgentCall, id: string, preset: Choice): Pending => ({
430 id,
431 subagentType: call.subagent_type ?? 'general-purpose',
432 description: call.description,
433 model: preset.model,
434 effort: preset.effort,
435 isCallSet: call.model !== undefined || call.effort !== undefined,
436})
437
438const removePending = ($: Host, id: string): Promise<unknown> => update($, queue, q => without(q, id))
439
440async function pickValue($: Host, id: string, field: Field, value: string): Promise<void> {
441 const session = sessions.get(id)
442 const after = await update($, queue, q => patched(q, id, { [field]: value === INHERIT_VALUE ? null : value }))
443 const entry = after.items.find(one => one.id === id)
444
445 if (session !== undefined && entry !== undefined) {
446 session.choice = { model: entry.model, effort: entry.effort }
447 }
448}
449
450const shortReason = (error: unknown): string => (error instanceof Error ? error.message : 'error').slice(0, 60)
451
452type Shell = 'sh' | 'powershell.exe'
453
454// The wait runs in whichever of these starts first: sh on macOS and Linux,
455// powershell.exe on Windows, where Git puts no sh, mktemp or touch on the PATH.
456const SHELLS: readonly Shell[] = ['sh', 'powershell.exe']
457
458const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command'] as const
459
460const probeArgv = (shell: Shell): string[] => (shell === 'sh' ? ['sh', '-c', 'exit 0'] : [...POWERSHELL, 'exit 0'])
461
462// The waiting child polls for the sentinel and removes it once seen, so a
463// finished wait leaves nothing behind.
464const waitArgv = (shell: Shell, sentinel: string): string[] =>
465 shell === 'sh'
466 ? ['sh', '-c', 'while [ ! -e "$1" ]; do sleep 0.2; done; rm -f -- "$1"', 'sh', sentinel]
467 : [
468 ...POWERSHELL,
469 `$p='${sentinel.replace(/'/g, "''")}'; while (-not (Test-Path -LiteralPath $p)) { Start-Sleep -Milliseconds 200 }; Remove-Item -LiteralPath $p -Force`,
470 ]
471
472// Removes the probe file the waits folder was tried with.
473const removeArgv = (shell: Shell, path: string): string[] =>
474 shell === 'sh'
475 ? ['sh', '-c', 'rm -f -- "$1"', 'sh', path]
476 : [...POWERSHELL, `Remove-Item -LiteralPath '${path.replace(/'/g, "''")}' -Force -ErrorAction SilentlyContinue`]
477
478// Found once per load (register resets it): a reload may find another host.
479let waitShell: Promise<Shell | undefined> | undefined
480
481function shellToWaitIn($: Host): Promise<Shell | undefined> {
482 waitShell ??= (async () => {
483 for (const shell of SHELLS) {
484 try {
485 await $.process.run(probeArgv(shell))
486
487 return shell
488 } catch {
489 // Not on this host: try the next.
490 }
491 }
492
493 return undefined
494 })()
495
496 return waitShell
497}
498
499// Ends the wait: records the decision once, then makes the sentinel appear.
500async function finish($: Host, id: string, decision: 'run' | 'save' | 'cancel' | 'abort'): Promise<void> {
501 const session = sessions.get(id)
502
503 if (session === undefined || session.decision !== undefined) {
504 return
505 }
506
507 session.decision = decision
508
509 try {
510 await $.fs.write(session.sentinel, '')
511 } catch {
512 $.ui.toast('Subagent tuner: could not release the pane; your choice applies when its wait ends')
513 }
514}
515
516// The hook's own time budget stops while a `$` call is in flight but not for an
517// await on a promise, so the wait is a child process that ends when the
518// sentinel appears. The sentinel lives in the plugin's own folder, named for the
519// call and the moment, so no two waits share one. A wait that ends without a
520// decision (the ten minutes ran out) is no answer; one that ends with a decision
521// recorded (the sentinel could not be written) honours it.
522async function askInPane($: Host, call: AgentCall, signal: AbortSignal, preset: Choice): Promise<Outcome | Unavailable> {
523 if (signal.aborted) {
524 return { kind: 'unanswered' }
525 }
526
527 const shell = await shellToWaitIn($)
528
529 if (shell === undefined) {
530 return { kind: 'unavailable', reason: 'no shell to wait in' }
531 }
532
533 // Aborted while the shell was found: nothing was made, nothing to undo.
534 if (signal.aborted) {
535 return { kind: 'unanswered' }
536 }
537
538 const id = paneIdFor(call.tool_use_id)
539 const entry = pendingFor(call, id, preset)
540 const sentinel = `${$.plugin.root}/.waits/${id}-${await $.clock.now()}`
541
542 // A folder that takes no write (read-only install, full disk) could never end
543 // the wait: the dialogs answer at once instead of a pane that holds the launch
544 // for ten minutes. The probe is a sibling the wait does not look for.
545 try {
546 await $.fs.write(`${sentinel}.probe`, '')
547 } catch (error) {
548 return { kind: 'unavailable', reason: `waits folder not writable: ${shortReason(error)}` }
549 }
550
551 await $.process.run(removeArgv(shell, `${sentinel}.probe`)).catch(() => undefined)
552
553 // Aborted while the probe ran: nothing is open yet.
554 if (signal.aborted) {
555 return { kind: 'unanswered' }
556 }
557
558 const session: Session = {
559 choice: { model: entry.model, effort: entry.effort },
560 sentinel,
561 title: headerFor(call).slice(0, 40),
562 }
563 const onAbort = (): void => {
564 finish($, id, 'abort').catch(() => undefined)
565 }
566
567 sessions.set(id, session)
568 signal.addEventListener('abort', onAbort)
569
570 try {
571 await update($, queue, q => withPending(q, entry))
572
573 const opened = await $.ui.open({
574 id,
575 title: session.title,
576 focus: true,
577 closeOnEscape: true,
578 holdToasts: true,
579 rows: PANE_ROWS,
580 })
581 const pane = (await $.ui.panes()).find(one => one.id === id)
582
583 // A pane that waits undrawn (a narrow terminal) cannot be answered.
584 if (!opened.isPlaced || pane === undefined || !pane.isPlaced) {
585 return { kind: 'unavailable', reason: NOT_PLACED }
586 }
587
588 if (!pane.isFocused) {
589 $.ui.toast('Subagent tuner: press ctrl+x then Tab to reach the pane')
590 }
591
592 try {
593 await $.process.run(waitArgv(shell, session.sentinel), { timeoutMs: PANE_WAIT_MS })
594 } catch {
595 // The wait ran out: the person's live selection stands, not the preset. A
596 // decision recorded all the same (the sentinel could not be written) stands too.
597 if (session.decision === undefined || session.decision === 'abort') {
598 return { kind: 'unanswered', choice: session.choice }
599 }
600 }
601
602 return session.decision === 'run' || session.decision === 'save'
603 ? { kind: session.decision, choice: session.choice }
604 : session.decision === 'cancel'
605 ? { kind: 'cancel' }
606 : { kind: 'unanswered' }
607 } catch (error) {
608 return { kind: 'unavailable', reason: shortReason(error) }
609 } finally {
610 signal.removeEventListener('abort', onAbort)
611 sessions.delete(id)
612 // Nothing is left behind on any path: the entry, the pane (the wait removed
613 // the sentinel). (A hot reload while the pane is open skips this and leaves
614 // the queue item behind; that is live-only, not testable here, and UNVERIFIED.)
615 await removePending($, id).catch(() => undefined)
616 await $.ui.close({ id }).catch(() => undefined)
617 }
618}
619
620// The person's choice starts the subagent whatever becomes of the save: the
621// save runs beside the launch (and says when it is stuck), and a failure of it
622// is said rather than lost.
623async function launchAndSave($: Host, next: AgentNext, chosen: Parameters<AgentNext>[0], choice: Choice) {
624 const saving = changeSaved($, () => choice).catch((error: unknown) => {
625 $.ui.toast(`Subagent tuner: could not save the default (${error instanceof Error ? error.message : 'error'})`)
626 })
627 const ran = await next(chosen)
628
629 await saving
630
631 return ran
632}
633
634// What the engine offered, per agent type: true for a built-in. Raised when the listing is built, so
635// before the model can name a type at dispatch. Reset by register, like waitShell.
636const offers = new Map<string, boolean>()
637
638// The calls judged by name alone (their offer had not come) that Auto let through untouched, by
639// tool_use_id: the spawn names who provides the type, which the call could not know.
640const judgedByName = new Map<string, AgentCall>()
641
642export const register: Register = on => {
643 waitShell = undefined
644 offers.clear()
645 judgedByName.clear()
646
647 // Only observes: the offer goes on as it came, whatever the record does.
648 on('agent.offer', (_$, e, next) => {
649 try {
650 // `source` tells a built-in from a user's agent of its name; any doubt counts as custom.
651 offers.set(e.agent, e.source === 'built-in' && e.provider.plugin === 'engine' && e.provider.tier === 'core')
652 } catch {
653 // Unrecorded, the type is judged by its name.
654 }
655
656 return next(e)
657 }).catch(($, e, next) => next(e))
658
659 // The spawn tells a type the engine provides (a built-in) from a user's: where the offer came after
660 // the call, the recommendation the call went without applies now. The spawn takes a model only.
661 on('agent.spawn', async ($, e, next) => {
662 const call = judgedByName.get(e.tool_use_id)
663
664 judgedByName.delete(e.tool_use_id)
665
666 if (call === undefined || e.provider.plugin !== 'engine' || e.provider.tier !== 'core' || offers.get(e.subagentType) === false) {
667 return next(e)
668 }
669
670 const { saved } = await read($, settings)
671 const { model } = autoPatch(call, saved, recommend(call))
672
673 if (model === undefined || model === e.model) {
674 return next(e)
675 }
676
677 $.ui.toast(`Subagent model -> ${model}`)
678
679 return next({ ...e, model })
680 }).catch(($, e, next) => next(e))
681
682 on('session.start', async ($, e, next) => {
683 await ensureHydrated($)
684
685 // A pane a previous copy of this module left open has no one waiting on it.
686 try {
687 for (const pane of await $.ui.panes()) {
688 if (pane.id.startsWith(PANE_PREFIX)) {
689 await $.ui.close({ id: pane.id })
690 }
691 }
692 } catch {
693 // No panes to clear.
694 }
695
696 return next(e)
697 })
698
699 // A broken tuner must never keep a subagent from starting: whatever fails,
700 // the call goes on exactly as the model made it.
701 on('tool.call', { tool: 'Agent' }, tuneAgent).catch(($, e, next) => next(e))
702
703 // The person closing a pane (Escape, the close mark) cancels its launch.
704 on('ui.close', async ($, e, next) => {
705 const session = sessions.get(e.id)
706
707 if (e.origin.kind === 'person' && session !== undefined) {
708 await finish($, e.id, 'cancel')
709 }
710
711 return next(e)
712 })
713
714 // The pane's own body: the selectors and the buttons for the launch it holds.
715 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
716 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
717 return next(e)
718 }
719
720 const entry = (await read($, queue)).items.find(one => one.id === e.requestId)
721
722 if (entry === undefined) {
723 return next(e)
724 }
725
726 return paneView({
727 ui: $.ui.resolve(e),
728 columns: e.props.bodyColumns,
729 entry,
730 actions: {
731 pick: (id, field, value) => pickValue($, id, field, value),
732 run: id => finish($, id, 'run'),
733 save: id => finish($, id, 'save'),
734 cancel: id => finish($, id, 'cancel'),
735 },
736 })
737 })
738
739 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
740 // The band is raised on the terminal and the desktop only. Narrower than the
741 // smallest layout nothing fits, so nothing is drawn.
742 if (
743 e.props.hasSurvey ||
744 e.props.bodyColumns < MIN_COLUMNS ||
745 (e.surface !== 'terminal' && e.surface !== 'desktop')
746 ) {
747 return next(e)
748 }
749
750 const current = await read($, settings)
751 // Before the first load the state holds only the initial values: show
752 // what the store holds rather than a default that is not the person's.
753 const { mode, saved } = current.hydrated ? current : await stored($)
754
755 // The site draws one tree: ours goes above whatever the plugins beneath drew, and they size
756 // themselves to the rows the band leaves. A plugin beneath that fails never costs the band.
757 let below: unknown
758
759 try {
760 below = await next({ ...e, props: { ...e.props, maxRows: rowsLeft(e.props.maxRows) } })
761 } catch {
762 below = undefined
763 }
764
765 const ui = $.ui.resolve(e)
766
767 return stacked(
768 ui,
769 bandView({ ui, columns: e.props.bodyColumns, mode, saved, actions: actionsFor($) }),
770 below,
771 )
772 })
773}
774hooks/dialog.ts 133 lines1import type { SubagentTunerChoice as Choice } from '../types'
2import type { Field } from './state'
3import { EFFORTS, MODELS, describeChoice } from './recommend'
4import type { AgentRequest } from './recommend'
5
6// The native dialogs, asked in a chain where the pick pane cannot be seated. The
7// engine's dialog takes at most four options and compares labels exactly.
8
9export const CHANGE_LABEL = 'Change model or effort'
10export const CANCEL_LABEL = 'Cancel'
11export const MORE_MODEL_LABEL = 'fable or inherit…'
12export const MORE_EFFORT_LABEL = 'xhigh, max or inherit…'
13const INHERIT_NAME = 'inherit'
14
15/** What Run says where the selection inherits both fields although the call set one. */
16export const INHERITED_TEXT = 'with inherited model and effort'
17
18// Each field dialog holds at most four options (the engine's limit): the fourth
19// opens the rest, so that every value, inherit included, can be clicked.
20export const MODEL_OPTIONS: readonly string[] = ['haiku', 'sonnet', 'opus', MORE_MODEL_LABEL]
21export const MORE_MODEL_OPTIONS: readonly string[] = ['fable', INHERIT_NAME]
22export const EFFORT_OPTIONS: readonly string[] = ['low', 'medium', 'high', MORE_EFFORT_LABEL]
23export const MORE_EFFORT_OPTIONS: readonly string[] = ['xhigh', 'max', INHERIT_NAME]
24
25const DESCRIPTION_CHARS = 60
26const TYPE_CHARS = 30
27
28
29const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
30
31const agentOf = (call: AgentRequest): string => clip(call.subagent_type ?? 'general-purpose', TYPE_CHARS)
32
33export const headerFor = (call: AgentRequest): string => `${agentOf(call)} · ${clip(call.description ?? '', DESCRIPTION_CHARS)}`
34
35// The selection is the whole answer in Manual: a value the call set is only its
36// preset. An empty selection leaves the call as made only where it set nothing.
37export const shows = (call: AgentRequest, choice: Choice): string => {
38 if (choice.model !== null || choice.effort !== null) {
39 return describeChoice(choice)
40 }
41
42 return call.model === undefined && call.effort === undefined ? 'as the model asked' : INHERITED_TEXT
43}
44
45/** The first dialog: run with what is selected, save it, change it, or cancel. */
46export type MainDialog = {
47 question: string
48 run: string
49 /** Absent only where nothing is selected: there is no default to keep. */
50 save: string | null
51 options: readonly string[]
52}
53
54export function mainDialog(call: AgentRequest, current: Choice): MainDialog {
55 const run = `Run ${shows(call, current)}`
56 const save = current.model !== null || current.effort !== null ? `${run} + save default` : null
57
58 return {
59 question: `Run ${agentOf(call)}: ${clip(call.description ?? '', DESCRIPTION_CHARS)}?`,
60 run,
61 save,
62 options: [run, ...(save === null ? [] : [save]), CHANGE_LABEL, CANCEL_LABEL],
63 }
64}
65
66export const fieldQuestion = (call: AgentRequest, field: Field): string =>
67 `${field === 'model' ? 'Model' : 'Effort'} for ${agentOf(call)}?`
68
69/** `model/effort` or `model effort` typed under Other, each checked against the known names. */
70export function parseChoiceText(text: string): Choice | null {
71 const match = /^\s*([a-z]+)\s*[/\s]\s*([a-z]+)\s*$/i.exec(text)
72 const model = match?.[1]?.toLowerCase()
73 const effort = match?.[2]?.toLowerCase()
74
75 if (model === undefined || effort === undefined) {
76 return null
77 }
78
79 const isKnown = (MODELS as readonly string[]).includes(model) && (EFFORTS as readonly string[]).includes(effort)
80
81 return isKnown ? { model, effort } : null
82}
83
84export type MainReading =
85 | { kind: 'run' | 'save' | 'change' | 'cancel' }
86 | { kind: 'typed'; choice: Choice }
87 /** Neither an option nor a model/effort: nothing is started. */
88 | { kind: 'unreadable'; problem: string }
89
90/** Labels are compared exactly; free text is the dialog's Other. */
91export function readMain(dialog: MainDialog, text: string): MainReading {
92 if (text === dialog.run) {
93 return { kind: 'run' }
94 }
95
96 if (dialog.save !== null && text === dialog.save) {
97 return { kind: 'save' }
98 }
99
100 if (text === CHANGE_LABEL) {
101 return { kind: 'change' }
102 }
103
104 if (text === CANCEL_LABEL) {
105 return { kind: 'cancel' }
106 }
107
108 const typed = parseChoiceText(text)
109
110 return typed !== null
111 ? { kind: 'typed', choice: typed }
112 : { kind: 'unreadable', problem: `Subagent tuner: could not read "${clip(text, 30)}" as model/effort; nothing was started` }
113}
114
115/**
116 * A value of one field from a field dialog, or text typed under Other that names
117 * one (or inherit). `undefined` where it names none; `'more'` where the dialog's
118 * fourth option was chosen.
119 */
120export function readField(field: Field, text: string): string | null | 'more' | undefined {
121 if (text === (field === 'model' ? MORE_MODEL_LABEL : MORE_EFFORT_LABEL)) {
122 return 'more'
123 }
124
125 const name = text.trim().toLowerCase()
126
127 if (name === INHERIT_NAME) {
128 return null
129 }
130
131 return ((field === 'model' ? MODELS : EFFORTS) as readonly string[]).includes(name) ? name : undefined
132}
133hooks/pane-view.tsx 105 lines1import type { Elements } from 'claude-code'
2
3import type { SubagentTunerPending as Pending } from '../types'
4import { EFFORTS, MODELS } from './recommend'
5import { INHERITED_TEXT } from './dialog'
6import type { Field } from './state'
7
8export const INHERIT_VALUE = 'inherit'
9
10/** From this width the Model and Effort selectors share a row. */
11export const SELECTORS_FROM = 52
12/** The three buttons share a row from this width, or from the width their labels need, if more. */
13export const BUTTONS_FROM = 50
14const BUTTONS_GAP = 2
15
16export type PaneActions = {
17 pick: (id: string, field: Field, value: string) => unknown
18 run: (id: string) => unknown
19 save: (id: string) => unknown
20 cancel: (id: string) => unknown
21}
22
23export type PaneData = {
24 ui: Elements['terminal'] | Elements['desktop']
25 columns: number
26 entry: Pending
27 actions: PaneActions
28}
29
30const clip = (text: string, max: number): string =>
31 max <= 0 ? '' : text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
32
33const LABEL: Record<Field, string> = { model: 'Model', effort: 'Effort' }
34
35/** `<agent type> · <description>`, clipped to the room. */
36export const headerLine = (entry: Pending, columns: number): string =>
37 clip(`${entry.subagentType} · ${entry.description}`, columns)
38
39function selector(data: PaneData, field: Field) {
40 const { Select } = data.ui
41 const current = data.entry[field] ?? INHERIT_VALUE
42
43 // The preset (the call's value, else the default or recommendation) is just
44 // the value the selector starts on: no mark, and every field is editable.
45 return (
46 <Select
47 key={field}
48 label={LABEL[field]}
49 options={[...(field === 'model' ? MODELS : EFFORTS), INHERIT_VALUE].map(value => ({ value }))}
50 value={current}
51 onSelect={value => data.actions.pick(data.entry.id, field, value)}
52 />
53 )
54}
55
56// Plain Buttons draw their hotkey (`r: Run`).
57// Inheriting both fields drops what the call set: Run says so.
58const runLabel = (entry: Pending): string =>
59 entry.model === null && entry.effort === null && entry.isCallSet ? `Run ${INHERITED_TEXT}` : 'Run'
60
61type ButtonKey = 'run' | 'save' | 'cancel'
62
63const buttonSpecs = (entry: Pending): { key: ButtonKey; hotkey: string; label: string }[] => [
64 { key: 'run', hotkey: 'r', label: runLabel(entry) },
65 { key: 'save', hotkey: 'd', label: 'Run and save as default' },
66 { key: 'cancel', hotkey: 'c', label: 'Cancel' },
67]
68
69// The width of the row: each plain Button draws `<hotkey>: <label>`, plus the gaps.
70const buttonsRowWidth = (entry: Pending): number => {
71 const specs = buttonSpecs(entry)
72
73 return specs.reduce((sum, one) => sum + one.hotkey.length + 2 + one.label.length, 0) + BUTTONS_GAP * (specs.length - 1)
74}
75
76function buttons(data: PaneData) {
77 const { Button } = data.ui
78 const { entry, actions } = data
79
80 return buttonSpecs(entry).map(({ key, hotkey, label }) => (
81 <Button key={key} plain hotkey={hotkey} label={label} autoFocus={key === 'run' ? true : undefined} onPress={() => actions[key](entry.id)} />
82 ))
83}
84
85export function paneView(data: PaneData) {
86 const { Box, Text } = data.ui
87 const selectors = [selector(data, 'model'), selector(data, 'effort')]
88
89 return (
90 <Box flexDirection="column">
91 <Text>{headerLine(data.entry, data.columns)}</Text>
92 {data.columns >= SELECTORS_FROM ? (
93 <Box gap={3}>{selectors}</Box>
94 ) : (
95 <Box flexDirection="column">{selectors}</Box>
96 )}
97 {data.columns >= Math.max(BUTTONS_FROM, buttonsRowWidth(data.entry)) ? (
98 <Box gap={BUTTONS_GAP}>{buttons(data)}</Box>
99 ) : (
100 <Box flexDirection="column">{buttons(data)}</Box>
101 )}
102 </Box>
103 )
104}
105hooks/recommend.ts 135 lines1import type { SubagentTunerChoice as Choice } from '../types'
2
3export const MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
4export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
5
6export type Model = (typeof MODELS)[number]
7export type Effort = (typeof EFFORTS)[number]
8
9/** A prompt longer than this asks for more thought than its task kind implies. */
10export const LONG_PROMPT_CHARS = 4000
11
12/** The agent type the engine runs as a fork: it ignores model and effort. */
13export const FORK_TYPE = 'fork'
14
15/** No recommendation: the subagent inherits the parent's model and effort. */
16export const INHERIT: Choice = { model: null, effort: null }
17
18type Rule = { kind: string; pattern: RegExp; model: Model; effort: Effort }
19
20// Ordered by what a miss costs: a review that ran on a cheap model is worse
21// than an implementation that ran on a dear one, so review wins a tie. Each
22// pattern opens on a word boundary, and `fix` closes on one so that "fixture"
23// is not a fix while fixed, hotfix and bugfix are.
24const RULES: readonly Rule[] = [
25 { kind: 'review', pattern: /\b(review|audit|verif|adversar|secur)/i, model: 'opus', effort: 'high' },
26 { kind: 'implement', pattern: /\b(implement|build|(hot|bug)?fix(es|ed|ing)?\b)/i, model: 'sonnet', effort: 'medium' },
27 { kind: 'explore', pattern: /\b(explor|search|research)/i, model: 'haiku', effort: 'low' },
28]
29
30/** The agent types the engine ships: a name the offers never raised is still one of these or a custom agent. */
31const BUILT_IN_TYPES: readonly string[] = ['general-purpose', 'Explore', 'Plan', 'claude', 'statusline-setup', 'claude-code-guide']
32
33/** Per agent type the engine offered: true when it provided it itself (a built-in). */
34export type OfferCache = ReadonlyMap<string, boolean>
35
36/**
37 * True where the agent type may carry a definition of its own. The tuner never
38 * sees a definition's `model` or `effort`, so for such a type it recommends
39 * nothing: a pinned model must not be replaced by a keyword guess. The offers
40 * are the evidence; a type no offer was seen for is judged by its name (see
41 * `isJudgedByName`; the spawn may tell better, for the engine's offer can come late).
42 */
43export const isJudgedByName = (subagentType: string | undefined, offers: OfferCache): boolean =>
44 subagentType !== undefined && !offers.has(subagentType) && !BUILT_IN_TYPES.includes(subagentType)
45
46export function hasOwnDefinition(subagentType: string | undefined, offers: OfferCache): boolean {
47 if (subagentType === undefined) {
48 return false
49 }
50
51 const isBuiltIn = offers.get(subagentType)
52
53 return isBuiltIn === undefined ? !BUILT_IN_TYPES.includes(subagentType) : !isBuiltIn
54}
55
56export type AgentRequest = {
57 subagent_type?: string
58 description?: string
59 prompt?: string
60 model?: string
61 effort?: string
62}
63
64const firstMatch = (text: string): Rule | undefined => RULES.find(rule => rule.pattern.test(text))
65
66export function bumpEffort(effort: Effort): Effort {
67 const next = EFFORTS[EFFORTS.indexOf(effort) + 1]
68
69 return next ?? effort
70}
71
72/**
73 * The agent type names the job more reliably than the free-text description,
74 * so it is tried first.
75 */
76export function recommend(request: AgentRequest): Choice {
77 const rule = firstMatch(request.subagent_type ?? '') ?? firstMatch(request.description ?? '')
78
79 if (rule === undefined) {
80 return INHERIT
81 }
82
83 const isLong = (request.prompt ?? '').length > LONG_PROMPT_CHARS
84
85 return { model: rule.model, effort: isLong ? bumpEffort(rule.effort) : rule.effort }
86}
87
88/**
89 * What the tuner applies without asking: the saved default, each field of it
90 * falling back to the recommendation where the default leaves it inherited.
91 */
92export const resolveDefault = (saved: Choice | null, rec: Choice): Choice =>
93 saved === null
94 ? rec
95 : { model: saved.model ?? rec.model, effort: saved.effort ?? rec.effort }
96
97/** True while the call leaves the model or the effort for the tuner to settle. */
98export const hasOpenField = (request: AgentRequest): boolean =>
99 request.model === undefined || request.effort === undefined
100
101/**
102 * The fields the choice supplies, each only where the call left it unset: what
103 * the call set itself is never overridden.
104 */
105export function patchFor(request: AgentRequest, choice: Choice): { model?: string; effort?: string } {
106 return {
107 ...(request.model === undefined && choice.model !== null ? { model: choice.model } : {}),
108 ...(request.effort === undefined && choice.effort !== null ? { effort: choice.effort } : {}),
109 }
110}
111
112/**
113 * Auto: each field the saved default sets replaces what the call set; a field it
114 * leaves inherited keeps what the call set, else takes the recommendation.
115 */
116export function autoPatch(request: AgentRequest, saved: Choice | null, rec: Choice): { model?: string; effort?: string } {
117 return {
118 ...patchFor(request, resolveDefault(saved, rec)),
119 ...(saved?.model == null ? {} : { model: saved.model }),
120 ...(saved?.effort == null ? {} : { effort: saved.effort }),
121 }
122}
123
124/**
125 * Manual mode's starting selection, per field: what the call set, else the
126 * default or recommendation. The person may change every field of it.
127 */
128export const presetFor = (request: AgentRequest, fallback: Choice): Choice => ({
129 model: request.model ?? fallback.model,
130 effort: request.effort ?? fallback.effort,
131})
132
133export const describeChoice = (choice: Choice): string =>
134 `${choice.model ?? 'inherit'}/${choice.effort ?? 'inherit'}`
135hooks/state.ts 64 lines1import type {
2 SubagentTunerChoice as Choice,
3 SubagentTunerMode as Mode,
4 SubagentTunerPending as Pending,
5 SubagentTunerQueue as Queue,
6} from '../types'
7import { EFFORTS, MODELS } from './recommend'
8
9// Pure parsers and reducers. The module that hooks events holds every call on
10// `$` and declares the atoms: the plugin loader follows `$`, and reads an
11// atom's reference, only within one file.
12
13export const DEFAULT_MODE: Mode = 'manual'
14export const STORE_MODE = 'mode'
15export const STORE_SAVED = 'saved'
16
17export type Field = 'model' | 'effort'
18
19export const parseMode = (value: unknown, fallback: Mode): Mode =>
20 value === 'manual' || value === 'auto' ? value : fallback
21
22const isOneOf = (list: readonly string[], value: unknown): boolean =>
23 value === null || (typeof value === 'string' && list.includes(value))
24
25/** A stored value that is not a choice over the known names counts as unset. */
26export function parseChoice(value: unknown): Choice | null {
27 if (typeof value !== 'object' || value === null) {
28 return null
29 }
30
31 const { model, effort } = value as Record<string, unknown>
32
33 if (!isOneOf(MODELS, model) || !isOneOf(EFFORTS, effort)) {
34 return null
35 }
36
37 return { model: model as string | null, effort: effort as string | null }
38}
39
40/** A choice with neither field set is no default at all. */
41export const asDefault = (choice: Choice | null): Choice | null =>
42 choice !== null && (choice.model !== null || choice.effort !== null) ? choice : null
43
44/** The next value of a field in its cycle: none, then each name in turn. */
45export function cycled(current: string | null, names: readonly string[]): string | null {
46 const at = current === null ? -1 : names.indexOf(current)
47
48 return names[at + 1] ?? null
49}
50
51export function cycleField(saved: Choice | null, field: Field): Choice | null {
52 const base = saved ?? { model: null, effort: null }
53
54 return asDefault({ ...base, [field]: cycled(base[field], field === 'model' ? MODELS : EFFORTS) })
55}
56
57export const withPending = (q: Queue, item: Pending): Queue => ({ items: [...q.items, item] })
58
59export const patched = (q: Queue, id: string, patch: Partial<Pending>): Queue => ({
60 items: q.items.map(one => (one.id === id ? { ...one, ...patch } : one)),
61})
62
63export const without = (q: Queue, id: string): Queue => ({ items: q.items.filter(one => one.id !== id) })
64hooks/stack.ts 61 lines1// The tuner's band over the tree a plugin beneath drew for the same site. Pure: no `$`.
2//
3// The AbovePrompt site draws one tree, so a hook that never calls `next(e)` hides every plugin beneath it.
4// `next(e)` resolves to what those draw; the band is returned above it. (Adapted from session-healthbar's
5// hooks/pure/stack.ts: plugins cannot import each other, so the few lines needed are copied here.)
6import type { Elements, RenderElement } from 'claude-code'
7
8type Els = Pick<Elements['terminal'] | Elements['desktop'], 'Box'>
9
10/** The rows the band takes: it is one row (`height={1}` in view.tsx, held to it by a test). */
11export const BAND_ROWS = 1
12
13/** What `next(e)` gave, when it is a tree we can stack on: an object that names an element type. */
14export const isElement = (x: unknown): x is RenderElement =>
15 typeof x === 'object' && x !== null && typeof (x as { type?: unknown }).type === 'string'
16
17const childrenOf = (el: RenderElement): readonly unknown[] => {
18 const kids = (el as { children?: unknown }).children
19
20 return Array.isArray(kids) ? kids.flat(Infinity) : []
21}
22
23const lineCount = (s: string): number => s.split('\n').length
24
25/** The text a Text node holds: its strings, and the strings of the inline elements inside it. */
26const textOf = (n: unknown): string => {
27 if (typeof n === 'string') return n
28 if (typeof n === 'number') return String(n)
29
30 return isElement(n) ? childrenOf(n).map(textOf).join('') : ''
31}
32
33/**
34 * How many rows a tree takes, estimated from its shape alone: a Text is one row plus one per newline; a
35 * column Box the sum of its children, a row Box the tallest; any other element (a Button, an Input) one row.
36 * Borders, padding and wrapping are not counted, so this can undercount.
37 */
38export const rowsOf = (tree: unknown): number => {
39 if (typeof tree === 'string') return lineCount(tree)
40 if (typeof tree === 'number') return 1
41 if (!isElement(tree)) return 1
42 if (tree.type === 'Text') return lineCount(textOf(tree))
43 if (tree.type !== 'Box') return 1
44
45 const rows = childrenOf(tree).map(rowsOf)
46 const direction = String((tree.props as { flexDirection?: unknown } | undefined)?.flexDirection)
47 const isColumn = direction === 'column' || direction === 'column-reverse'
48
49 return isColumn ? rows.reduce((a, b) => a + b, 0) : Math.max(0, ...rows)
50}
51
52/** Whether `next(e)` gave a tree with something to draw: an empty Box is as good as nothing. */
53export const hasRows = (below: unknown): below is RenderElement => isElement(below) && rowsOf(below) > 0
54
55/** The rows the plugins beneath may take: what the site has, less the band's own; at least one. */
56export const rowsLeft = (maxRows: number): number => Math.max(1, maxRows - BAND_ROWS)
57
58/** The band over the tree beneath, one column; the band alone when nothing to draw came back. */
59export const stacked = ({ Box }: Els, band: RenderElement, below: unknown): RenderElement =>
60 hasRows(below) ? Box({ flexDirection: 'column', children: [band, below] }) : band
61hooks/view.tsx 122 lines1import type { Elements } from 'claude-code'
2
3import type { SubagentTunerChoice as Choice, SubagentTunerMode as Mode } from '../types'
4import type { Field } from './state'
5
6export type Actions = {
7 setMode: (mode: Mode) => unknown
8 cycle: (field: Field) => unknown
9 reset: () => unknown
10}
11
12export type BandData = {
13 ui: Elements['terminal'] | Elements['desktop']
14 columns: number
15 mode: Mode
16 saved: Choice | null
17 actions: Actions
18}
19
20/** Marks the active mode in words, for where colour alone would not tell. */
21export const ACTIVE_MARK = '*'
22/** Trails a plain (unbracketed) cycler, which would otherwise look like text. */
23export const CYCLER_MARK = '>'
24
25type Layout = {
26 lead: string
27 manual: string
28 between: string
29 isBracketed: boolean
30 isShort: boolean
31 reset: string | null
32}
33
34/**
35 * Richest first, each giving up something other than the mode's words before the
36 * next: the word `default`, then brackets and the full effort name, then the
37 * lead and Reset. `Man` is the last resort.
38 */
39const LAYOUTS: readonly Layout[] = [
40 { lead: 'Tuner ', manual: 'Manual', between: ' · default ', isBracketed: true, isShort: false, reset: 'Reset' },
41 { lead: 'Tuner ', manual: 'Manual', between: ' · ', isBracketed: true, isShort: false, reset: '×' },
42 { lead: 'Tuner ', manual: 'Manual', between: ' ', isBracketed: false, isShort: true, reset: '×' },
43 { lead: '', manual: 'Manual', between: ' ', isBracketed: false, isShort: true, reset: null },
44 { lead: '', manual: 'Man', between: ' ', isBracketed: false, isShort: true, reset: null },
45]
46
47/** The width of the smallest layout in its worst case; narrower than this nothing is drawn. */
48export const MIN_COLUMNS = 31
49
50const NONE_LABEL = 'rec.'
51
52const nameOf = (value: string | null, isShort: boolean): string =>
53 value === null ? NONE_LABEL : isShort && value === 'medium' ? 'med' : value
54
55const cyclerLabel = (layout: Layout, value: string | null): string =>
56 `${nameOf(value, layout.isShort)}${layout.isBracketed ? '' : CYCLER_MARK}`
57
58// A bracketed Button is its label plus 4 cells, a plain one its label alone
59// (reference.md:127); the active mode carries one more cell for its mark.
60const buttonWidth = (label: string, isBracketed: boolean): number => label.length + (isBracketed ? 4 : 0)
61
62/** The cells the layout needs for what is to be drawn now: the labels it holds, not the worst they could be. */
63function neededWidth(layout: Layout, mode: Mode, saved: Choice | null): number {
64 const manual = `${mode === 'manual' ? ACTIVE_MARK : ''}${layout.manual}`
65 const auto = `${mode === 'auto' ? ACTIVE_MARK : ''}Auto`
66 const cyclers =
67 buttonWidth(cyclerLabel(layout, saved?.model ?? null), layout.isBracketed) +
68 1 +
69 buttonWidth(cyclerLabel(layout, saved?.effort ?? null), layout.isBracketed)
70 const reset = layout.reset === null || saved === null ? 0 : 1 + buttonWidth(layout.reset, layout.isBracketed)
71
72 return layout.lead.length + buttonWidth(manual, true) + buttonWidth(auto, true) + layout.between.length + cyclers + reset
73}
74
75/** The richest layout whose needed width fits; the smallest where none does. */
76const layoutFor = (columns: number, mode: Mode, saved: Choice | null): Layout =>
77 LAYOUTS.find(layout => neededWidth(layout, mode, saved) <= columns) ?? LAYOUTS[LAYOUTS.length - 1]!
78
79export function bandView({ ui, columns, mode, saved, actions }: BandData) {
80 const { Box, Button, Text } = ui
81 const layout = layoutFor(columns, mode, saved)
82 const plain = layout.isBracketed ? undefined : true
83 const field = (name: Field) => (
84 <Button
85 key={`default-${name}`}
86 label={cyclerLabel(layout, saved?.[name] ?? null)}
87 plain={plain}
88 onPress={() => actions.cycle(name)}
89 />
90 )
91
92 return (
93 <Box height={1} overflow="hidden">
94 {layout.lead === '' ? null : <Text dimColor>{layout.lead}</Text>}
95 <Box>
96 <Button
97 key="mode-manual"
98 label={mode === 'manual' ? `${ACTIVE_MARK}${layout.manual}` : layout.manual}
99 variant={mode === 'manual' ? 'primary' : undefined}
100 onPress={() => actions.setMode('manual')}
101 />
102 <Button
103 key="mode-auto"
104 label={mode === 'auto' ? `${ACTIVE_MARK}Auto` : 'Auto'}
105 variant={mode === 'auto' ? 'primary' : undefined}
106 onPress={() => actions.setMode('auto')}
107 />
108 </Box>
109 <Text dimColor>{layout.between}</Text>
110 {field('model')}
111 <Text dimColor>/</Text>
112 {field('effort')}
113 {layout.reset === null || saved === null ? null : (
114 <Box>
115 <Text> </Text>
116 <Button key="reset" label={layout.reset} plain={plain} onPress={() => actions.reset()} />
117 </Box>
118 )}
119 </Box>
120 )
121}
122types/index.d.ts 45 lines1// The contract of the subagent-tuner plugin: the state its band and its pick
2// pane draw from. A model or effort is null where the subagent inherits the
3// parent's.
4
5export type SubagentTunerMode = 'manual' | 'auto'
6
7export type SubagentTunerChoice = {
8 model: string | null
9 effort: string | null
10}
11
12export type SubagentTunerSettings = {
13 mode: SubagentTunerMode
14 /** The saved default; null while none is set. */
15 saved: SubagentTunerChoice | null
16 /** True once the persisted mode and default were loaded into this state. */
17 hydrated: boolean
18}
19
20/** A subagent launch waiting for the person in its pane. */
21export type SubagentTunerPending = {
22 /** The pane's id: one pane for each launch. */
23 id: string
24 subagentType: string
25 description: string
26 /** The current selection, which Run applies; it starts at the preset. */
27 model: string | null
28 effort: string | null
29 /** True where the call itself set a model or an effort (the preset may differ from it). */
30 isCallSet: boolean
31}
32
33export type SubagentTunerQueue = {
34 items: SubagentTunerPending[]
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'subagent-tuner': {
40 settings: SubagentTunerSettings
41 queue: SubagentTunerQueue
42 }
43 }
44}
45