SLOPSHOPPER

subagent-tuner

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…

newpanebandguardtoastprocess
★ 2v0.1.7MITupdated 2026-10-15Kruhlikau/subagent-tuner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-tuner
› fix the failing auth test and add an audit log call ⏺ 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 Tuner [ *Manual ][ Auto ] · default [ rec. ]/[ rec. ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Tuner [ *Manual ][ Auto ] · default [ rec. ]/[ rec. ] ⟨Claude Code's own drawing⟩
README

subagent-tuner

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.

License: MIT Version Claude Code Plugin scan Validate and test

<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.

Install

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.

What you see

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.

The recommendation

For built-in agents (general-purpose, Explore, Plan and the like):

Task kind (from the subagent's type, else its description)ModelEffort
review, audit, verify, adversarial, securityopushigh
implement, build, fixsonnetmedium
explore, search, researchhaikulow

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.

Where it stays out of the way

  • claude -p and SDK hosts: nothing is changed, so scripted runs and evals measure what they ask for.
  • Forks: never touched; a fork ignores model and effort anyway.
  • VS Code extension: it can't show the band there, so it never asks and behaves as Auto.
  • Custom agents: with no saved default their model and effort are left to the agent's own definition.
  • Manual: a call that already set both its model and its effort goes through as set.

What it can read, write and run

  • Reads each Agent call's subagent type, description, model and effort, and the length of its prompt, and which agent types Claude Code offers and whether each is built in (never the agent definitions). It never sends anything anywhere: no network calls.
  • Changes only the model and effort fields of Agent calls.
  • Stores the mode and the saved default in Claude Code's own plugin store.
  • Runs 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.
  • Writes an empty sentinel file to .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.

Requirements

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.

Troubleshooting

  • Dialogs instead of the pane: the terminal is narrower than 144 columns (widen it or zoom out), or the plugin's .waits/ folder cannot be written (the toast says why).
  • Subagents still on Opus: you are in Manual and the agent set the model itself, or Auto has no saved default for the model. Switch to Auto and set the default.
  • An update didn't change anything: run the update commands above. Claude Code runs the installed copy from its cache, keyed by version.
  • Two bands or two toasts: the mod is loaded twice, for example installed and also passed with --plugin-dir. Keep one.

Develop

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.

License

MIT

Source 8 files
hooks/register.tsx 774 lines
1import { 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}
774
hooks/dialog.ts 133 lines
1import 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}
133
hooks/pane-view.tsx 105 lines
1import 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}
105
hooks/recommend.ts 135 lines
1import 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'}`
135
hooks/state.ts 64 lines
1import 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) })
64
hooks/stack.ts 61 lines
1// 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
61
hooks/view.tsx 122 lines
1import 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}
122
types/index.d.ts 45 lines
1// 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