SLOPSHOPPER

prompt-pilot

Picks the model and effort for each prompt (you confirm), and upgrades substantial prompts through a short question panel before they run

newpanebandcommandtoaststatus
v0.1.0MITupdated 2026-10-15Fox1304/FT_Claude_PromptUpgrader
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-pilot
│ ┃ prompt-upgrader ✕ › fix the failing auth test and add an audit log call │ ┃ No prompt is being upgraded. Long new │ ┃ prompts open this panel; start one with ?? ⏺ Read(src/auth.ts) │ ┃ to ask for it, or with !! to skip it. ⎿ 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 │ │ › /chooser │ ⎿ prompt-pilot: Model chooser: off in every session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prompt-upgrader
No prompt is being upgraded. Long new prompts open this panel; start one with ?? to ask for it, or with !! to skip it.
README

Claude Prompt Upgrader

A Claude Code mod that works on every prompt you send, in two parts you can switch on and off separately:

  • Prompt upgrader: when a prompt asks for something substantial but leaves things open, it is held for a moment while a small model reads it. Clear prompts go through untouched. Otherwise a panel asks you up to three short questions, then offers a rewritten prompt you can launch, edit, copy or drop in favour of your original.
  • Model chooser: picks the model (Opus, Sonnet, Haiku) and the effort (Medium, High, Extra, Max) that fit each prompt, and asks you to confirm when it matters.

The two live in one mod because the upgrader must run first and hand the chooser the model it settled on.

The mod's plugin name is prompt-pilot.

What it does

Prompt upgrader

  • Runs on prompts of 12 words or more that are not an answer to a question Claude just asked, and have no attachment.
  • A small model (Haiku) reads the prompt with the start of your project's CLAUDE.md as context. If the prompt is clear, it runs as typed, with « ✓ Your prompt is clear ».
  • If not, the prompt is held and the Prompt upgrader pane opens:
  • up to 3 questions, each with ready answers (●/○) or « Or type your own answer… », plus « Anything to add? »;
  • Improve: Sonnet rewrites the prompt with your answers and proposes a model and effort;
  • then Launch on the proposed model, change the model or effort, « Ask for a change », Copy, Edit in box (puts it back in your prompt box), Send original, or Cancel (your prompt comes back to the box).

Model chooser

  • Rules first (English and French keywords: shipping, planning, quick checks, status pings, long engineering prompts…), and a quick Haiku read only when the rules are unsure.
  • Asks you to confirm for substantial prompts, and for short ones only when the pick would change. Go-aheads (« yes », « continue », « vas-y ») are never asked.
  • After 5 kept recommendations in a row for one kind of prompt, it stops asking for that kind (« · auto » in its band). /chooser reset makes it ask again.
  • Mid-conversation, switching to another model re-reads the whole conversation without the prompt cache. When that is costly, it offers Switch, Stay (cheapest) or New conversation (with recap). A long conversation (over about 150k tokens) never moves.
  • A band above the prompt shows the current pick and, on a subscription plan, how much of the 5-hour and weekly windows you have used. Toasts warn at 80 % (configurable) and 95 %.

Tags

Put them at the start or the end of a prompt; they are removed before it is sent.

TagEffect
!!no upgrader for this prompt
??the upgrader even for a short prompt
!opus !sonnet !haikuforce the model
!medium !high !extra !maxforce the effort

Requirements

  • Claude Code 2.1.288 or later (terminal, or the desktop app's Code tab).
  • Mods (plugin hooks modules) enabled for your account. They are an early-access feature that Anthropic switches on per account. When the switch is off, Claude Code still installs the mod but does not load it.
  • Access to the models it uses: by default claude-opus-5-5, claude-sonnet-5-5 and claude-haiku-5-5 (change them in the options). The upgrader's reads and rewrites run on your own account, like any other Claude Code request.
  • macOS for one fallback only: when the app cannot put text in your prompt box or clipboard, the mod uses pbcopy. Elsewhere that one fallback is skipped.
  • No other dependency: no npm install, no build step.

Install

In a terminal Claude Code session (the desktop app's Code tab does not offer this command), type:

/plugin install prompt-pilot --marketplace Fox1304/FT_Claude_PromptUpgrader

Answer y to add the marketplace, then pick the user scope so it loads in every project. The mod is active at once in that session, and in every session started afterwards, including the desktop app.

Or from a shell, with the same result:

claude plugin marketplace add Fox1304/FT_Claude_PromptUpgrader
claude plugin install prompt-pilot@claude-prompt-upgrader --scope user

To remove it: /plugin uninstall prompt-pilot, or claude plugin uninstall prompt-pilot@claude-prompt-upgrader.

Run it from a clone instead

git clone https://github.com/Fox1304/FT_Claude_PromptUpgrader.git
claude --plugin-dir ./FT_Claude_PromptUpgrader

To load it in every session without installing it, add the clone's absolute path to env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.

Usage

Type prompts as usual. Then:

  • /upgrader on, /upgrader off: switch the prompt upgrader on or off in every session.
  • /chooser on, /chooser off: the same for the model chooser; /chooser reset makes it ask for confirmation again.
  • Esc or Cancel in the upgrader pane gives you your prompt back, untouched.

Options

Set them in /config (or under pluginConfigs in ~/.claude/settings.json):

OptionDefaultWhat it does
opusModelclaude-opus-5-5the model id used for Opus picks
sonnetModelclaude-sonnet-5-5the model id used for Sonnet picks and rewrites
haikuModelclaude-haiku-5-5the model id used for Haiku picks and for reading prompts
cacheMinutes60how long a model's prompt cache counts as warm after its last answer
usageWarnPercent80plan usage share that triggers a warning

Your data

The mod keeps its switches and what it learned (kept recommendations, the average cost of a turn per model and effort) in Claude Code's own plugin store (~/.claude/plugins/store/). It sends nothing anywhere except to Claude, through your own Claude Code session.

Development

claude plugin validate .
claude plugin test .

claude plugin test runs the tests/*.test.ts(x) files against Claude Code's own engine (it needs mods enabled). Claude Code lays the API typings in .claude-plugin/types/ the first time it loads the mod; tsc -p . type-checks it after that.

PathWhat it holds
hooks/register.tsxthe hooks: prompt interception, the upgrader pane, the chooser's confirm and band
hooks/decide.tspure logic: rules, tags, switch costs, trust and turn-cost learning
hooks/prompts.tsthe instructions the small models read
types/index.d.tsthe shapes of the mod's state
tests/engine tests, with world.ts standing in for the store, the clock and the models

Licence

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 1016 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register, RenderSurface } from 'claude-code'
3
4import type { Effort, ModelName, Pick, SwitchCost, TrustEntry, TurnCost, Upgrade, UpgradeQuestion, UsageWindow } from '../types'
5import {
6  EFFORTS,
7  EFFORT_LABEL,
8  TRUST_AFTER,
9  MODELS,
10  MODEL_LABEL,
11  answersText,
12  costKey,
13  costText,
14  estimateTurn,
15  isContinuation,
16  isTrusted,
17  keepIfTooLong,
18  keepStronger,
19  kindKey,
20  kindOf,
21  label,
22  meter,
23  neighbours,
24  parseClassify,
25  parsePickWords,
26  parseRead,
27  parseRewrite,
28  parseTags,
29  recordAnswer,
30  recordTurnCost,
31  rulePick,
32  samePick,
33  switchCost,
34  tierOf,
35  wordCount,
36} from './decide'
37import type { Read } from './decide'
38import { CLASSIFY_SYSTEM, READ_SYSTEM, RECAP_PROMPT, REWRITE_SYSTEM } from './prompts'
39
40const PLUGIN = 'prompt-pilot'
41const PANE = 'prompt-upgrader'
42const PANE_TITLE = '✨ Prompt upgrader'
43const MAX_QUESTIONS = 3
44const READ_TIMEOUT_MS = 10_000
45const CLASSIFY_TIMEOUT_MS = 6_000
46const WRITE_TIMEOUT_MS = 60_000
47const HANDOFF_MS = 15 * 60_000
48const RULES_CHARS = 2_500
49
50const active = atom({ plugin: 'prompt-pilot', key: 'active' } as const, null)
51const upgrade = atom({ plugin: 'prompt-pilot', key: 'upgrade' } as const, null)
52const busy = atom({ plugin: 'prompt-pilot', key: 'busy' } as const, '')
53const usage = atom({ plugin: 'prompt-pilot', key: 'usage' } as const, [])
54const cache = atom({ plugin: 'prompt-pilot', key: 'cache' } as const, {})
55const handoff = atom({ plugin: 'prompt-pilot', key: 'handoff' } as const, null)
56const turnStart = atom({ plugin: 'prompt-pilot', key: 'turnStart' } as const, null)
57const chooserOn = atom({ plugin: 'prompt-pilot', key: 'chooserOn' } as const, true)
58const upgraderOn = atom({ plugin: 'prompt-pilot', key: 'upgraderOn' } as const, true)
59
60/** Model ids, cache lifetime and warning level, from the mod's settings. */
61const config = {
62  ids: { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' } as Record<ModelName, string>,
63  cacheMs: 60 * 60_000,
64  warnAt: 80,
65}
66
67const MODEL_COLOR: Record<ModelName, string> = { opus: 'claude', sonnet: 'suggestion', haiku: 'success' }
68
69/** Prompts that come from elsewhere than the person (notifications, peers, other plugins) run on what runs now. */
70const NOT_THE_PERSON = new Set<PromptOrigin['kind']>([
71  'task-notification',
72  'scheduled-trigger',
73  'peer',
74  'peer-send-message',
75  'projects-relay',
76  'channel',
77  'coordinator',
78  'observer',
79  'observer-activity',
80  'auto-continuation',
81  'plugin',
82  'slack-ping',
83])
84
85const cap = (text: string) => (text ? text[0]?.toUpperCase() + text.slice(1) : text)
86const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
87
88// ── Reading the session ────────────────────────────────────────────────────
89
90type Conversation = { lastUser: string; lastAssistant: string; hasHistory: boolean; contextTokens: number }
91
92const conversation = async ($: EngineInterface): Promise<Conversation> => {
93  const listed = await $.session.messages()
94  const messages = Array.isArray(listed) ? listed : []
95  const said = messages.filter(one => one.text.trim())
96  const lastAssistant = [...said].reverse().find(one => one.role === 'assistant')?.text ?? ''
97  const lastUser = [...said].reverse().find(one => one.role === 'user')?.text ?? ''
98  const used = await $.session.usage()
99
100  return { lastUser, lastAssistant, hasHistory: messages.some(one => one.role === 'assistant'), contextTokens: used.context.tokens ?? 0 }
101}
102
103/** What runs now: the last pick, else the session's own model (at High). */
104const currentPick = async ($: EngineInterface): Promise<Pick> => {
105  const pick = await read($, active)
106  if (pick) return pick
107
108  return { model: tierOf(await $.session.model()) ?? 'opus', effort: 'high', reason: 'session model', by: 'kept' }
109}
110
111const projectRules = async ($: EngineInterface) => {
112  const root = await $.session.root()
113  for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
114    try {
115      const text = await $.fs.read(`${root}/${name}`)
116      if (text.trim()) return clip(text.trim(), RULES_CHARS)
117    } catch {
118      // no rules file there
119    }
120  }
121
122  return ''
123}
124
125const setActive = async ($: EngineInterface, pick: Pick) => {
126  await update($, active, () => pick)
127  $.ui.status(`◆ ${label(pick)} · ${pick.reason}`)
128}
129
130const setBusy = async ($: EngineInterface, text: string) => {
131  await update($, busy, () => text)
132}
133
134// ── The models' reads ──────────────────────────────────────────────────────
135
136const contextBlock = (conv: Conversation) =>
137  [conv.lastUser ? `Previous request: ${clip(conv.lastUser, 500)}` : '', conv.lastAssistant ? `Claude's last answer: ${clip(conv.lastAssistant, 900)}` : '']
138    .filter(Boolean)
139    .join('\n')
140
141/** Haiku reads a substantial prompt: clear or not, the questions worth asking, and the pick. */
142const readPrompt = async ($: EngineInterface, text: string, conv: Conversation, fallback: Pick): Promise<Read | undefined> => {
143  const rules = await projectRules($)
144  const reply = await $.model.complete({
145    model: 'haiku',
146    system: READ_SYSTEM(MAX_QUESTIONS),
147    prompt: [
148      { text: rules ? `Project rules (start of CLAUDE.md):\n${rules}` : 'This project has no rules file.', cache: true },
149      { text: `${contextBlock(conv)}\n\nPrompt to triage:\n<<<\n${clip(text, 6000)}\n>>>` },
150    ],
151    maxTokens: 900,
152    effort: 'low',
153    timeoutMs: READ_TIMEOUT_MS,
154  })
155  if (!reply.isAnswered) {
156    $.ui.log(`prompt-pilot: the read failed (${reply.reason})`, { to: 'debug' })
157
158    return undefined
159  }
160
161  return parseRead(reply.text, fallback, MAX_QUESTIONS)
162}
163
164/** The chooser's pick: the rules when they are sure, else a quick Haiku read. */
165const choosePick = async ($: EngineInterface, text: string, hasImages: boolean, conv: Conversation): Promise<Pick> => {
166  const ruled = rulePick(text, hasImages)
167  if (ruled.isConfident || wordCount(text) < 5) return ruled.pick
168  await setBusy($, 'Choosing a model…')
169  try {
170    const reply = await $.model.complete({
171      model: 'haiku',
172      system: CLASSIFY_SYSTEM,
173      prompt: `${contextBlock(conv)}\n\nPrompt:\n<<<\n${clip(text, 4000)}\n>>>`,
174      maxTokens: 120,
175      effort: 'low',
176      timeoutMs: CLASSIFY_TIMEOUT_MS,
177    })
178
179    return (reply.isAnswered ? parseClassify(reply.text, ruled.pick) : undefined) ?? ruled.pick
180  } finally {
181    await setBusy($, '')
182  }
183}
184
185// ── Asking the person ──────────────────────────────────────────────────────
186
187/** One question in the engine's own dialog; the label chosen, the words typed under Other, or undefined when dismissed. */
188const askOne = async ($: EngineInterface, question: string, header: string, options: string[]) => {
189  try {
190    const answer = await $.ui.ask(question, { options, header })
191
192    return answer.trim() || undefined
193  } catch (error) {
194    $.ui.log(`prompt-pilot: the confirm dialog closed without an answer (${String(error)})`, { to: 'debug' })
195
196    return undefined
197  }
198}
199
200/** `agreed`: the plain confirm's answer kept the recommendation (true), chose otherwise (false), or said nothing (undefined). */
201type Decision = { kind: 'run'; pick: Pick; agreed?: boolean } | { kind: 'new' }
202
203/** A choice's label with what one turn on it usually costs: "Opus · High (recommended) · ≈3%". */
204const priced = (text: string, pick: { model: ModelName; effort: Effort }, costs: Record<string, TurnCost>) => {
205  const cost = costText(estimateTurn(costs, pick))
206
207  return cost ? `${text} · ${cost}` : text
208}
209
210/** The confirm step: a plain confirm, or, when a model switch would re-read the conversation, three choices. */
211const confirmPick = async (
212  $: EngineInterface,
213  pick: Pick,
214  current: Pick | null,
215  cost: SwitchCost,
216  isClear: boolean,
217  costs: Record<string, TurnCost>,
218): Promise<Decision> => {
219  const typed = (answer: string): Pick => {
220    const words = parsePickWords(answer, pick)
221    if (!words) $.ui.toast(`Didn't catch “${clip(answer, 30)}”: running on ${label(pick)}.`)
222
223    return words ? { ...words, reason: pick.reason, by: 'you' } : pick
224  }
225  if (cost.kind === 'costly' && current) {
226    const stay: Pick = { model: current.model, effort: pick.effort, reason: pick.reason, by: 'you' }
227    const from = MODEL_LABEL[current.model]
228    const question = `${label(pick)} fits this better (${pick.reason}), but switching from ${from} re-reads ${cost.tokens} tokens without the cache. Switch?`
229    const options = [priced(`Switch to ${label(pick)}`, pick, costs), priced(`Stay on ${label(stay)} (cheapest)`, stay, costs), 'New conversation (with recap)']
230    const answer = await askOne($, question, 'Switch model', options)
231    if (answer === undefined || answer === options[0]) return { kind: 'run', pick: answer ? { ...pick, by: 'you' } : pick }
232    if (answer === options[1]) return { kind: 'run', pick: stay }
233    if (answer === options[2]) return { kind: 'new' }
234
235    return { kind: 'run', pick: typed(answer) }
236  }
237  const { up, down } = neighbours(pick)
238  const choices: { pick: { model: ModelName; effort: Effort }; text: string }[] = [{ pick, text: priced(`${label(pick)} (recommended)`, pick, costs) }]
239  if (up) choices.push({ pick: up, text: priced(`${label(up)} (stronger)`, up, costs) })
240  if (down) choices.push({ pick: down, text: priced(`${label(down)} (lighter)`, down, costs) })
241  const notes = [isClear ? '✓ Your prompt is clear.' : '', cost.kind === 'cheap' ? `${cost.short}.` : ''].filter(Boolean).join(' ')
242  const answer = await askOne($, `${notes ? `${notes} ` : ''}${cap(pick.reason)}: run it on ${label(pick)}?`, 'Model', choices.map(one => one.text))
243  if (answer === undefined) return { kind: 'run', pick }
244  const chosen = choices.find(one => one.text === answer || label(one.pick) === answer)
245  if (chosen) {
246    const isKept = chosen.pick === pick
247
248    return {
249      kind: 'run',
250      pick: { model: chosen.pick.model, effort: chosen.pick.effort, reason: pick.reason, by: isKept ? pick.by : 'you', ...(pick.kind ? { kind: pick.kind } : {}) },
251      agreed: isKept,
252    }
253  }
254  const words = typed(answer)
255
256  return { kind: 'run', pick: words, agreed: samePick(words, pick) }
257}
258
259// ── Learning from the person ───────────────────────────────────────────────
260
261const loadTrust = async ($: EngineInterface) => ((await $.store.get('trust')) ?? {}) as Record<string, TrustEntry>
262const loadCosts = async ($: EngineInterface) => ((await $.store.get('turnCost')) ?? {}) as Record<string, TurnCost>
263
264/** Records whether the person kept the recommendation for this kind; says so the moment the chooser stops asking. */
265const learnAnswer = async ($: EngineInterface, pick: Pick, kept: boolean) => {
266  const kind = kindKey(pick)
267  const now = await $.clock.now()
268  const trust = recordAnswer(await loadTrust($), kind, kept, now)
269  await $.store.set('trust', trust)
270  if (kept && trust[kind]?.streak === TRUST_AFTER)
271    $.ui.toast(`You kept my pick ${TRUST_AFTER} times in a row for “${pick.reason}”: I'll stop asking for that kind of task (/chooser reset to undo).`)
272}
273
274/** A pick forced with a tag on a kind of task the chooser no longer asks about: the question comes back. */
275const forgetTrust = async ($: EngineInterface, kind: string) => {
276  const trust = await loadTrust($)
277  if (!trust[kind]) return
278  await $.store.set('trust', recordAnswer(trust, kind, false, await $.clock.now()))
279}
280
281// ── Clipboard, box, new conversation ──────────────────────────────────────
282
283const copyText = async ($: EngineInterface, text: string, surface?: RenderSurface) => {
284  try {
285    const copied = await $.ui.copy({ text, surface })
286    if (copied.isCopied) return true
287  } catch {
288    // no surface clipboard: the machine's below
289  }
290  try {
291    return (await $.process.run(['pbcopy'], { stdin: text, timeoutMs: 5000 })).exitCode === 0
292  } catch {
293    return false
294  }
295}
296
297/** Puts text in the prompt box, else on the clipboard: where it went. */
298const fillBox = async ($: EngineInterface, text: string, surface?: RenderSurface): Promise<'box' | 'clipboard' | 'nowhere'> => {
299  try {
300    if ((await $.prompt.fill({ text })).isFilled) return 'box'
301  } catch {
302    // no box: the clipboard below
303  }
304
305  return (await copyText($, text, surface)) ? 'clipboard' : 'nowhere'
306}
307
308/** A short recap of this conversation plus the prompt, on the clipboard for a fresh session. */
309const handOffToNewConversation = async ($: EngineInterface, text: string, surface?: RenderSurface) => {
310  await setBusy($, 'Writing a recap for the new conversation…')
311  try {
312    const fork = await $.model.fork({ prompt: RECAP_PROMPT })
313    const recap = fork.isAnswered ? fork.text.trim() : ''
314    const whole = recap ? `Context from an earlier session:\n${recap}\n\n---\n\n${text}` : text
315    const copied = await copyText($, whole, surface)
316
317    return copied
318      ? `Copied your prompt${recap ? ' with a short recap' : ''}: open a new conversation and paste it.`
319      : "Couldn't reach the clipboard: your prompt was not sent."
320  } finally {
321    await setBusy($, '')
322  }
323}
324
325// ── The upgrader's panel ───────────────────────────────────────────────────
326
327const openUpgrade = async ($: EngineInterface, original: string, triage: Read, pick0: Pick, isForced: boolean, chooser: boolean, conv: Conversation) => {
328  const now = await $.clock.now()
329  const from = conv.hasHistory ? await currentPick($) : null
330  const clock = await read($, cache)
331  const first = switchCost(pick0, from, conv.contextTokens, clock, now, config.cacheMs)
332  const pick = keepIfTooLong(isForced ? pick0 : keepStronger(pick0, from, first), from, conv.contextTokens)
333  const state: Upgrade = {
334    id: `u${now}`,
335    stage: 'questions',
336    original,
337    intent: triage.intent,
338    questions: triage.questions,
339    answers: Object.fromEntries(triage.questions.map(question => [question.id, { choice: question.recommended }])),
340    extra: '',
341    improved: '',
342    previous: [],
343    pick,
344    pickTouched: isForced,
345    chipsOpen: false,
346    chooser,
347    from: from && { model: from.model, effort: from.effort },
348    contextTokens: conv.contextTokens,
349    switch: switchCost(pick, from, conv.contextTokens, clock, now, config.cacheMs),
350    error: '',
351    notice: '',
352  }
353  await update($, upgrade, () => state)
354  const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true, closeOnEscape: true })
355  if (opened.isPlaced) return true
356  await update($, upgrade, () => null)
357  await $.ui.close({ id: PANE })
358
359  return false
360}
361
362const changeUpgrade = async ($: EngineInterface, change: (up: Upgrade) => Upgrade) => {
363  await update($, upgrade, up => (up ? change(up) : up))
364}
365
366const choose = async ($: EngineInterface, id: string, choice: number) => {
367  await changeUpgrade($, up => ({ ...up, answers: { ...up.answers, [id]: { choice } } }))
368}
369
370const typeOwn = async ($: EngineInterface, id: string, text: string) => {
371  await changeUpgrade($, up => ({ ...up, answers: { ...up.answers, [id]: { ...up.answers[id], text } } }))
372}
373
374const typeExtra = async ($: EngineInterface, text: string) => {
375  await changeUpgrade($, up => ({ ...up, extra: text }))
376}
377
378const repick = async ($: EngineInterface, change: { model?: ModelName; effort?: Effort }) => {
379  const now = await $.clock.now()
380  const clock = await read($, cache)
381  await changeUpgrade($, up => {
382    const pick: Pick = { ...up.pick, ...change, by: 'you' }
383
384    return { ...up, pick, pickTouched: true, switch: switchCost(pick, up.from, up.contextTokens, clock, now, config.cacheMs) }
385  })
386}
387
388const toggleChips = async ($: EngineInterface) => {
389  await changeUpgrade($, up => ({ ...up, chipsOpen: !up.chipsOpen }))
390}
391
392/** Sonnet writes the improved prompt from the answers (or applies a requested change to it). */
393const writeUpgrade = async ($: EngineInterface, tweak?: string) => {
394  const start = await read($, upgrade)
395  if (!start || start.stage === 'writing') return
396  await update($, upgrade, () => ({ ...start, stage: 'writing' as const, error: '', notice: '' }))
397  const rules = await projectRules($)
398  const change = tweak?.trim()
399  const reply = await $.model.complete({
400    model: 'sonnet',
401    system: REWRITE_SYSTEM(start.pick.model),
402    prompt: [
403      { text: rules ? `Project rules (start of CLAUDE.md):\n${rules}` : 'This project has no rules file.', cache: true },
404      {
405        text: [
406          `Original prompt:\n<<<\n${start.original}\n>>>`,
407          `The developer's answers:\n${answersText(start.questions, start.answers, start.extra)}`,
408          change ? `Your current rewrite:\n<<<\n${start.improved}\n>>>\nApply this change to it: ${change}` : '',
409        ]
410          .filter(Boolean)
411          .join('\n\n'),
412      },
413    ],
414    maxTokens: 3000,
415    effort: 'low',
416    timeoutMs: WRITE_TIMEOUT_MS,
417  })
418  const parsed = reply.isAnswered ? parseRewrite(reply.text, start.pick) : undefined
419  const now = await $.clock.now()
420  const clock = await read($, cache)
421  await update($, upgrade, up => {
422    if (!up || up.id !== start.id) return up
423    if (!parsed) {
424      const why = reply.isAnswered
425        ? 'the answer came back in an unexpected shape'
426        : reply.reason === 'api-error'
427          ? `the API answered ${reply.status ?? 'with an error'}`
428          : reply.reason === 'aborted'
429            ? 'it took too long'
430            : 'the model sent back nothing'
431      return { ...up, stage: 'failed' as const, error: `Couldn't write the improved prompt: ${why}.` }
432    }
433    const fresh = up.pickTouched
434      ? up.pick
435      : keepIfTooLong(keepStronger(parsed.pick, up.from, switchCost(parsed.pick, up.from, up.contextTokens, clock, now, config.cacheMs)), up.from, up.contextTokens)
436
437    return {
438      ...up,
439      stage: 'ready' as const,
440      improved: parsed.prompt,
441      previous: change && up.improved ? [...up.previous, up.improved].slice(-5) : up.previous,
442      pick: fresh,
443      switch: switchCost(fresh, up.from, up.contextTokens, clock, now, config.cacheMs),
444    }
445  })
446}
447
448const undoTweak = async ($: EngineInterface) => {
449  await changeUpgrade($, up => {
450    const last = up.previous[up.previous.length - 1]
451
452    return last === undefined ? up : { ...up, improved: last, previous: up.previous.slice(0, -1), notice: '' }
453  })
454}
455
456const closePane = async ($: EngineInterface) => {
457  await update($, upgrade, () => null)
458  await $.ui.close({ id: PANE })
459}
460
461/** Runs the improved prompt (or the original) here, on the panel's pick. */
462const launch = async ($: EngineInterface, which: 'improved' | 'original') => {
463  const up = await read($, upgrade)
464  if (!up) return
465  const text = which === 'improved' && up.improved ? up.improved : up.original
466  if (up.chooser) await setActive($, up.pick)
467  await closePane($)
468  await $.prompt.submit({ text, asUser: true })
469}
470
471const copyImproved = async ($: EngineInterface, surface: RenderSurface) => {
472  const up = await read($, upgrade)
473  if (!up?.improved) return
474  const copied = await copyText($, up.improved, surface)
475  await changeUpgrade($, one => ({ ...one, notice: copied ? '✓ Copied: paste it into any session.' : "Couldn't reach the clipboard." }))
476}
477
478const editImproved = async ($: EngineInterface, surface: RenderSurface) => {
479  const up = await read($, upgrade)
480  if (!up?.improved) return
481  const now = await $.clock.now()
482  if (up.chooser) await update($, handoff, () => ({ pick: up.pick, until: now + HANDOFF_MS }))
483  await closePane($)
484  const where = await fillBox($, up.improved, surface)
485  $.ui.toast(
486    where === 'box'
487      ? 'The improved prompt is in the box: edit it, then press Enter.'
488      : where === 'clipboard'
489        ? 'Copied the improved prompt: paste it in the box, edit it, then press Enter.'
490        : "Couldn't reach the box or the clipboard.",
491  )
492}
493
494const newConversation = async ($: EngineInterface, surface: RenderSurface) => {
495  const up = await read($, upgrade)
496  if (!up) return
497  const said = await handOffToNewConversation($, up.improved || up.original, surface)
498  await changeUpgrade($, one => ({ ...one, notice: said }))
499}
500
501/** The person closed the panel without running anything: their prompt goes back where they can use it. */
502const cancelUpgrade = async ($: EngineInterface) => {
503  const up = await read($, upgrade)
504  if (!up) return
505  await update($, upgrade, () => null)
506  const where = await fillBox($, up.original)
507  $.ui.toast(
508    where === 'box'
509      ? 'Upgrade cancelled: your prompt is back in the box.'
510      : where === 'clipboard'
511        ? 'Upgrade cancelled: your prompt is on the clipboard.'
512        : 'Upgrade cancelled.',
513  )
514}
515
516const cancelFromPane = async ($: EngineInterface) => {
517  await cancelUpgrade($)
518  await $.ui.close({ id: PANE })
519}
520
521// ── Plan usage ─────────────────────────────────────────────────────────────
522
523const WINDOW_NAME: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
524
525const resetsIn = (resetsAt: string | undefined, now: number) => {
526  if (!resetsAt) return ''
527  const ms = Date.parse(resetsAt) - now
528  if (!Number.isFinite(ms) || ms <= 0) return ''
529  const hours = Math.floor(ms / 3_600_000)
530  const minutes = Math.round((ms % 3_600_000) / 60_000)
531  if (hours >= 24) return ` · resets in ${Math.round(hours / 24)} d`
532
533  return ` · resets in ${hours ? `${hours} h ` : ''}${minutes} min`
534}
535
536const noteUsage = async ($: EngineInterface, limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]) => {
537  const windows: UsageWindow[] = limits
538    .filter(one => WINDOW_NAME[one.kind])
539    .map(one => ({ kind: one.kind, percent: one.percentUsed, ...(one.resetsAt ? { resetsAt: one.resetsAt } : {}) }))
540  await update($, usage, () => windows)
541  const now = await $.clock.now()
542  for (const window of windows) {
543    const level = window.percent >= 95 ? 95 : window.percent >= config.warnAt ? config.warnAt : 0
544    if (!level) continue
545    const key = `${window.kind}:${window.resetsAt ?? ''}:${level}`
546    const warned = ((await $.store.get('warned')) ?? {}) as Record<string, number>
547    if (warned[key]) continue
548    const kept = Object.fromEntries(Object.entries(warned).filter(([, at]) => now - at < 8 * 24 * 3_600_000))
549    await $.store.set('warned', { ...kept, [key]: now })
550    $.ui.toast(`⚠ ${WINDOW_NAME[window.kind]} usage at ${Math.round(window.percent)}%${resetsIn(window.resetsAt, now)}`)
551  }
552}
553
554// ── Switches ───────────────────────────────────────────────────────────────
555
556const setToggle = async ($: EngineInterface, which: 'chooser' | 'upgrader', value: boolean) => {
557  await $.store.set(which === 'chooser' ? 'chooserOn' : 'upgraderOn', value)
558  if (which === 'chooser') {
559    await update($, chooserOn, () => value)
560    if (!value) {
561      await update($, active, () => null)
562      $.ui.status(undefined)
563    }
564  } else await update($, upgraderOn, () => value)
565}
566
567const toggleCommand = async ($: EngineInterface, which: 'chooser' | 'upgrader', args: string) => {
568  const word = args.trim().toLowerCase()
569  if (which === 'chooser' && word === 'reset') {
570    await $.store.set('trust', {})
571
572    return 'Model chooser: it will ask again for every kind of task.'
573  }
574  const now = which === 'chooser' ? await read($, chooserOn) : await read($, upgraderOn)
575  const next = word === 'on' ? true : word === 'off' ? false : word === '' ? !now : now
576  if (next !== now) await setToggle($, which, next)
577  const name = which === 'chooser' ? 'Model chooser' : 'Prompt upgrader'
578  const pick = await read($, active)
579  const extra = which === 'chooser' && next && pick ? ` Now running on ${label(pick)} (${pick.reason}).` : ''
580
581  return `${name}: ${next ? 'on' : 'off'} in every session.${extra}`
582}
583
584// ── The hooks ──────────────────────────────────────────────────────────────
585
586export const register: Register = (on, options) => {
587  for (const model of MODELS) {
588    const id = options[`${model}Model`]
589    if (typeof id === 'string' && id.trim()) config.ids[model] = id.trim()
590  }
591  if (typeof options.cacheMinutes === 'number' && options.cacheMinutes > 0) config.cacheMs = options.cacheMinutes * 60_000
592  if (typeof options.usageWarnPercent === 'number' && options.usageWarnPercent > 0) config.warnAt = options.usageWarnPercent
593
594  on('session.start', async ($, e, next) => {
595    await $.command.register({ name: 'chooser', description: 'Model chooser: picks the model and effort for each prompt (on, off; reset makes it ask again)', argumentHint: '[on|off|reset]', immediate: true })
596    await $.command.register({ name: 'upgrader', description: 'Prompt upgrader: improves substantial prompts with a short question panel (on, off)', argumentHint: '[on|off]', immediate: true })
597    const [chooser, upgrader] = [await $.store.get('chooserOn'), await $.store.get('upgraderOn')]
598    await update($, chooserOn, () => chooser !== false)
599    await update($, upgraderOn, () => upgrader !== false)
600
601    return next(e)
602  })
603
604  on('command.run', { command: 'chooser' }, async ($, e) => ({ text: await toggleCommand($, 'chooser', e.args) }))
605  on('command.run', { command: 'upgrader' }, async ($, e) => ({ text: await toggleCommand($, 'upgrader', e.args) }))
606
607  on('prompt.submit', async ($, e, next) => {
608    if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN) return next(e)
609    if (NOT_THE_PERSON.has(e.origin.kind) || e.turnId !== undefined) return next(e)
610    if (e.text.trimStart().startsWith('/') || /^\s*<[a-z-]+>/.test(e.text)) return next(e)
611    const tags = parseTags(e.text)
612    const pass = () => next(tags.text === e.text ? e : { ...e, text: tags.text })
613    const chooser = await read($, chooserOn)
614    const upgrader = await read($, upgraderOn)
615    if (!chooser && !upgrader) return pass()
616    const text = tags.text
617    const conv = await conversation($)
618    const kind = kindOf(text, conv.lastAssistant)
619    const hasAttachments = (e.attachments?.length ?? 0) > 0
620    const now = await $.clock.now()
621    const current = conv.hasHistory || (await read($, active)) ? await currentPick($) : null
622
623    // An improved prompt the person edited in the box runs on the panel's pick, unasked.
624    const handed = await read($, handoff)
625    if (handed) await update($, handoff, () => null)
626    if (handed && handed.until > now) {
627      if (chooser) await setActive($, handed.pick)
628
629      return pass()
630    }
631
632    const forced: Pick | undefined =
633      tags.model || tags.effort
634        ? { model: tags.model ?? current?.model ?? 'opus', effort: tags.effort ?? (tags.model === 'haiku' ? 'medium' : 'high'), reason: 'your choice', by: 'you' }
635        : undefined
636
637    let triage: Read | undefined
638    if (upgrader && !tags.skipUpgrade && !hasAttachments && (tags.forceUpgrade || kind === 'substantial')) {
639      await setBusy($, 'Reading your prompt…')
640      try {
641        triage = await readPrompt($, text, conv, forced ?? rulePick(text, hasAttachments).pick)
642      } finally {
643        await setBusy($, '')
644      }
645      if (triage && (!triage.isClear || tags.forceUpgrade) && triage.questions.length > 0) {
646        const opened = await openUpgrade($, text, triage, forced ?? triage.pick, !!forced, chooser, conv)
647        if (opened) return { drop: '✨ Prompt upgrader: answer in its panel, then Launch (or Send as is).' }
648      }
649    }
650    if (!chooser) return pass()
651
652    // Go-aheads and status replies keep what runs (the upgrader keeps its own reading of `kind`).
653    const asked = kind === 'ack' || isContinuation(text, conv.lastAssistant) ? 'ack' : kind
654    let pick: Pick
655    if (forced) pick = forced
656    else if (asked === 'ack' && current) pick = { ...current, by: 'kept' }
657    else pick = triage?.pick ?? (await choosePick($, text, hasAttachments, conv))
658    if (forced) {
659      const usual = rulePick(text, hasAttachments).pick
660      if (!samePick(forced, usual)) await forgetTrust($, kindKey(usual))
661    }
662    const clock = await read($, cache)
663    const kept = keepIfTooLong(
664      forced ? pick : keepStronger(pick, current, switchCost(pick, current, conv.contextTokens, clock, now, config.cacheMs)),
665      current,
666      conv.contextTokens,
667    )
668    const cost = switchCost(kept, current, conv.contextTokens, clock, now, config.cacheMs)
669    const mustAsk = !forced && asked !== 'ack' && (asked === 'substantial' || !samePick(kept, current))
670    let final = kept
671    if (mustAsk && cost.kind !== 'costly' && isTrusted(await loadTrust($), kindKey(kept))) final = { ...kept, by: 'trusted' }
672    else if (mustAsk) {
673      const decision = await confirmPick($, kept, current, cost, triage?.isClear === true, await loadCosts($))
674      if (decision.kind === 'new') return { drop: await handOffToNewConversation($, text) }
675      final = decision.pick
676      if (decision.agreed !== undefined) await learnAnswer($, kept, decision.agreed)
677    }
678    await setActive($, final)
679
680    return pass()
681  }).catch(($, e, next) => next(e))
682
683  on('turn.step', async function* ($, e, next) {
684    if (e.agentId !== undefined) return yield* next(e)
685    const pick = await read($, active)
686    if (!pick || !(await read($, chooserOn))) return yield* next(e)
687    const model = tierOf(e.model) === pick.model ? e.model : config.ids[pick.model]
688
689    return yield* next({ ...e, model, effort: pick.effort })
690  })
691
692  on('turn.start', async ($, e, next) => {
693    const started = await next(e)
694    const percent = (await $.session.usage()).rateLimits.find(one => one.kind === 'five_hour')?.percentUsed
695    const pick = (await read($, active)) ?? (await currentPick($))
696    await update($, turnStart, () => (percent === undefined ? null : { key: costKey(pick), percent }))
697
698    return started
699  })
700
701  on('turn.complete', async ($, e, next) => {
702    const ended = await next(e)
703    if (e.agentId === undefined && e.reason === 'answer') {
704      const pick = await read($, active)
705      const tier = pick?.model ?? tierOf(await $.session.model())
706      const now = await $.clock.now()
707      if (tier) await update($, cache, clock => ({ ...clock, [tier]: now }))
708    }
709    if (e.agentId === undefined) {
710      const mark = await read($, turnStart)
711      await update($, turnStart, () => null)
712      const percent = (await $.session.usage()).rateLimits.find(one => one.kind === 'five_hour')?.percentUsed
713      if (mark && percent !== undefined && e.reason === 'answer') await $.store.set('turnCost', recordTurnCost(await loadCosts($), mark.key, percent - mark.percent))
714    }
715
716    return ended
717  })
718
719  on('session.measure', async ($, e, next) => {
720    if (e.changed.includes('rateLimits')) await noteUsage($, e.rateLimits)
721
722    return next(e)
723  })
724
725  on('session.end', async ($, e, next) => {
726    if (e.reason === 'clear') {
727      await update($, active, () => null)
728      await update($, cache, () => ({}))
729      $.ui.status(undefined)
730    }
731
732    return next(e)
733  })
734
735  on('ui.close', { id: 'prompt-upgrader' }, async ($, e, next) => {
736    const closed = await next(e)
737    if (e.origin.kind === 'person') await cancelUpgrade($)
738
739    return closed
740  })
741
742  // ── The band above the prompt ────────────────────────────────────────────
743
744  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
745    if (e.props.hasSurvey) return next(e)
746    const [chooser, upgrader] = [await read($, chooserOn), await read($, upgraderOn)]
747    if (!chooser && !upgrader) return next(e)
748    const [pick, doing, windows, up] = [await read($, active), await read($, busy), await read($, usage), await read($, upgrade)]
749    const { Box, Text } = $.ui.resolve(e)
750    const shown = windows.filter(one => one.percent >= 1)
751    const usageText = shown.map(one => `${one.kind === 'five_hour' ? '5h' : 'week'} ${Math.round(one.percent)}%`).join(' · ')
752    const usageColor = shown.some(one => one.percent >= 95) ? 'error' : shown.some(one => one.percent >= config.warnAt) ? 'warning' : undefined
753
754    return (
755      <Box key="pilot-band" flexDirection="row" justifyContent="space-between" width={e.props.bodyColumns}>
756        <Box flexShrink={1}>
757          {doing ? (
758            <Text color="claude" wrap="truncate-end">
759              ✨ {doing}
760            </Text>
761          ) : up ? (
762            <Text color="claude" wrap="truncate-end">
763              ✨ Upgrading your prompt: answer in the panel
764            </Text>
765          ) : chooser && pick ? (
766            <Text wrap="truncate-end">
767              <Text color={MODEL_COLOR[pick.model]} bold>
768                ◆ {label(pick)}
769              </Text>
770              <Text color={MODEL_COLOR[pick.model]}> {meter(pick.effort)}</Text>
771              <Text dimColor> {pick.reason}</Text>
772              {pick.by === 'trusted' ? <Text dimColor> · auto</Text> : null}
773            </Text>
774          ) : (
775            <Text dimColor wrap="truncate-end">
776              {[chooser ? '◆ Auto model' : '', upgrader ? '✨ Prompt upgrader' : ''].filter(Boolean).join(' · ')}
777            </Text>
778          )}
779        </Box>
780        {usageText ? (
781          <Text color={usageColor} dimColor={!usageColor}>
782            {' '}
783            {usageText}
784          </Text>
785        ) : null}
786      </Box>
787    )
788  })
789
790  // ── The upgrader's panel ─────────────────────────────────────────────────
791
792  on('ui.render', { component: 'Pane', requestId: 'prompt-upgrader' }, async ($, e) => {
793    const up = await read($, upgrade)
794    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
795    const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
796    const width = Math.max(30, e.props.bodyColumns)
797
798    if (!up)
799      return (
800        <Box flexDirection="column" paddingX={1}>
801          <Text dimColor wrap="wrap">
802            No prompt is being upgraded. Long new prompts open this panel; start one with ?? to ask for it, or with !! to skip it.
803          </Text>
804        </Box>
805      )
806
807    const step = up.stage === 'questions' ? 'Step 1 of 2 · A few questions' : up.stage === 'writing' ? 'Step 2 of 2 · Writing' : up.stage === 'ready' ? 'Step 2 of 2 · Ready' : 'Step 2 of 2'
808    const header = (
809      <Box key="head" flexDirection="row" justifyContent="space-between">
810        <Text bold color="claude">
811          ✨ PROMPT UPGRADER
812        </Text>
813        <Text dimColor>{step}</Text>
814      </Box>
815    )
816    const original = (
817      <Box key="original" flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginTop={1}>
818        <Text dimColor bold>
819          Your prompt{up.intent ? ` · ${up.intent}` : ''}
820        </Text>
821        <Text dimColor wrap="wrap">
822          {clip(up.original.replace(/\s+/g, ' '), up.stage === 'questions' ? 360 : 160)}
823        </Text>
824      </Box>
825    )
826
827    const chip = (key: string, isOn: boolean, text: string, onPress: () => void) => (
828      <Button key={key} plain onPress={onPress}>
829        <Text color={isOn ? 'claude' : 'subtle'}>{isOn ? '●' : '○'}</Text> <Text bold={isOn}>{text}</Text>
830      </Button>
831    )
832
833    const modelRows = up.chooser ? (
834      <Box key="model" flexDirection="column" marginTop={1}>
835        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
836          <Text bold>Model </Text>
837          {MODELS.map(model => chip(`model:${model}`, up.pick.model === model, MODEL_LABEL[model], () => void repick($, { model })))}
838        </Box>
839        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
840          <Text bold>Effort</Text>
841          {EFFORTS.map(effort => chip(`effort:${effort}`, up.pick.effort === effort, EFFORT_LABEL[effort], () => void repick($, { effort })))}
842        </Box>
843        <Text dimColor wrap="wrap">
844          {meter(up.pick.effort)} {up.pick.reason}
845          {up.pick.by === 'you' ? ' · your choice' : ''}
846        </Text>
847        {up.switch.kind !== 'none' ? (
848          <Text color={up.switch.kind === 'costly' ? 'warning' : undefined} dimColor={up.switch.kind !== 'costly'} wrap="wrap">
849            {up.switch.kind === 'costly' ? '⚠ ' : 'ⓘ '}
850            {up.switch.note}
851          </Text>
852        ) : null}
853      </Box>
854    ) : null
855
856    const pickLine = up.chooser ? (
857      <Box key="pick-line" flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
858        <Text dimColor>Runs on</Text>
859        <Text color={MODEL_COLOR[up.pick.model]} bold>
860          ◆ {label(up.pick)}
861        </Text>
862        <Text dimColor>{up.pick.reason}</Text>
863        <Button key="chips" plain onPress={() => void toggleChips($)}>
864          <Text color="suggestion">{up.chipsOpen ? '▾ hide' : '▸ change'}</Text>
865        </Button>
866      </Box>
867    ) : null
868
869    if (up.stage === 'questions')
870      return (
871        <Box flexDirection="column" paddingX={1} width={width}>
872          {header}
873          {original}
874          {up.questions.map((question: UpgradeQuestion, index: number) => {
875            const answer = up.answers[question.id] ?? {}
876            const ownWords = answer.text?.trim() ?? ''
877
878            return (
879              <Box key={`q:${question.id}`} flexDirection="column" marginTop={1}>
880                <Text>
881                  <Text color="claude" bold>
882                    {index + 1}
883                  </Text>
884                  <Text bold> {question.topic.toUpperCase()}</Text>
885                </Text>
886                <Text wrap="wrap">{question.question}</Text>
887                <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
888                  {question.options.map((option, choice) =>
889                    chip(`opt:${question.id}:${choice}`, !ownWords && answer.choice === choice, option, () => void choose($, question.id, choice)),
890                  )}
891                </Box>
892                {Input ? (
893                  <Input
894                    key={`own:${question.id}`}
895                    placeholder="Or type your own answer…"
896                    value={answer.text ?? ''}
897                    onInput={value => void typeOwn($, question.id, value)}
898                    onSubmit={value => void typeOwn($, question.id, value)}
899                  />
900                ) : null}
901              </Box>
902            )
903          })}
904          {Input ? (
905            <Box key="extra" flexDirection="column" marginTop={1}>
906              <Text bold>Anything to add?</Text>
907              <Input key="extra-input" placeholder="Optional: a detail, a constraint…" value={up.extra} onInput={value => void typeExtra($, value)} onSubmit={value => void typeExtra($, value)} />
908            </Box>
909          ) : null}
910          {pickLine}
911          {up.chipsOpen ? modelRows : null}
912          <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
913            <Button key="improve" label="✨ Improve prompt" variant="primary" hotkey="i" autoFocus onPress={() => void writeUpgrade($)} />
914            <Button key="as-is" label="Send as is" hotkey="s" onPress={() => void launch($, 'original')} />
915            <Button key="cancel" label="Cancel" hotkey="x" onPress={() => void cancelFromPane($)} />
916          </Box>
917          <Text dimColor>Cancel or Esc puts your prompt back in the box.</Text>
918        </Box>
919      )
920
921    if (up.stage === 'writing')
922      return (
923        <Box flexDirection="column" paddingX={1} width={width}>
924          {header}
925          {original}
926          <Text key="writing" color="claude" bold>
927            ◌ Writing your improved prompt…
928          </Text>
929          <Box key="answers" flexDirection="column" marginTop={1}>
930            {up.questions.map(question => {
931              const answer = up.answers[question.id]
932              const words = answer?.text?.trim() || (answer?.choice !== undefined ? question.options[answer.choice] : '')
933
934              return (
935                <Text key={`a:${question.id}`} dimColor wrap="wrap">
936                  {question.topic}: {words || '—'}
937                </Text>
938              )
939            })}
940            {up.extra.trim() ? (
941              <Text key="a:extra" dimColor wrap="wrap">
942                Also: {up.extra.trim()}
943              </Text>
944            ) : null}
945          </Box>
946          <Box key="actions" flexDirection="row" columnGap={2} marginTop={1}>
947            <Button key="as-is" label="Send as is" hotkey="s" onPress={() => void launch($, 'original')} />
948          </Box>
949        </Box>
950      )
951
952    if (up.stage === 'failed')
953      return (
954        <Box flexDirection="column" paddingX={1} width={width}>
955          {header}
956          {original}
957          <Text key="error" color="warning" wrap="wrap">
958            ⚠ {up.error}
959          </Text>
960          <Box key="actions" flexDirection="row" columnGap={2} marginTop={1}>
961            <Button key="retry" label="Try again" variant="primary" hotkey="r" autoFocus onPress={() => void writeUpgrade($)} />
962            <Button key="as-is" label="Send as is" hotkey="s" onPress={() => void launch($, 'original')} />
963            <Button key="cancel" label="Cancel" hotkey="x" onPress={() => void cancelFromPane($)} />
964          </Box>
965        </Box>
966      )
967
968    return (
969      <Box flexDirection="column" paddingX={1} width={width}>
970        {header}
971        <Box key="improved-head" flexDirection="row" justifyContent="space-between" marginTop={1}>
972          <Text bold color="success">
973            ✓ Improved prompt
974          </Text>
975          <Text dimColor>
976            {wordCount(up.original)} → {wordCount(up.improved)} words
977          </Text>
978        </Box>
979        <Box key="improved" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
980          <Markdown key="improved-text" text={up.improved} />
981        </Box>
982        {up.previous.length > 0 ? (
983          <Button key="undo" plain onPress={() => void undoTweak($)}>
984            <Text color="suggestion">↶ Undo the last change</Text>
985          </Button>
986        ) : null}
987        {modelRows}
988        {Input ? (
989          <Box key="tweak" flexDirection="column" marginTop={1}>
990            <Input key="tweak-input" placeholder="Ask for a change: shorter, add tests…, then Enter" onSubmit={value => void (value.trim() ? writeUpgrade($, value) : undefined)} />
991          </Box>
992        ) : null}
993        {up.notice ? (
994          <Text key="notice" color={up.notice.startsWith('✓') || up.notice.startsWith('Copied') ? 'success' : 'warning'} wrap="wrap">
995            {up.notice}
996          </Text>
997        ) : null}
998        <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
999          <Button
1000            key="launch"
1001            label={up.chooser ? `▶ Launch on ${label(up.pick)}` : '▶ Launch'}
1002            variant="primary"
1003            hotkey="l"
1004            autoFocus
1005            onPress={() => void launch($, 'improved')}
1006          />
1007          <Button key="copy" label="Copy" hotkey="c" onPress={press => void copyImproved($, press.surface)} />
1008          <Button key="edit" label="Edit in box" hotkey="e" onPress={press => void editImproved($, press.surface)} />
1009          <Button key="original" label="Send original" hotkey="o" onPress={() => void launch($, 'original')} />
1010          {up.switch.kind === 'costly' ? <Button key="new" label="New conversation" hotkey="n" onPress={press => void newConversation($, press.surface)} /> : null}
1011        </Box>
1012      </Box>
1013    )
1014  })
1015}
1016
hooks/decide.ts 533 lines
1import type { Effort, ModelName, Pick, SwitchCost, TrustEntry, TurnCost, UpgradeQuestion } from '../types'
2
3// ── The ladder ────────────────────────────────────────────────────────────
4
5export const MODEL_LABEL: Record<ModelName, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus' }
6export const EFFORT_LABEL: Record<Effort, string> = { medium: 'Medium', high: 'High', xhigh: 'Extra', max: 'Max' }
7export const MODELS: ModelName[] = ['haiku', 'sonnet', 'opus']
8export const EFFORTS: Effort[] = ['medium', 'high', 'xhigh', 'max']
9export const MODEL_RANK: Record<ModelName, number> = { haiku: 0, sonnet: 1, opus: 2 }
10
11/** Weakest to strongest; the confirm dialog offers a pick's neighbours. */
12export const LADDER: [ModelName, Effort][] = [
13  ['haiku', 'medium'],
14  ['haiku', 'high'],
15  ['sonnet', 'medium'],
16  ['sonnet', 'high'],
17  ['sonnet', 'xhigh'],
18  ['opus', 'high'],
19  ['opus', 'xhigh'],
20  ['opus', 'max'],
21]
22
23export const label = (pick: { model: ModelName; effort: Effort }) => `${MODEL_LABEL[pick.model]} · ${EFFORT_LABEL[pick.effort]}`
24
25/** A four-step meter for the effort: ▰▰▱▱ is High. */
26export const meter = (effort: Effort) => {
27  const filled = EFFORTS.indexOf(effort) + 1
28
29  return '▰'.repeat(filled) + '▱'.repeat(EFFORTS.length - filled)
30}
31
32const rankOf = (pick: { model: ModelName; effort: Effort }) => {
33  const exact = LADDER.findIndex(([model, effort]) => model === pick.model && effort === pick.effort)
34  if (exact >= 0) return exact
35  // Off the ladder (Opus · Medium, Sonnet · Max): the nearest rung of the same model.
36  const same = LADDER.map((rung, index) => ({ rung, index })).filter(one => one.rung[0] === pick.model)
37  const wanted = EFFORTS.indexOf(pick.effort)
38
39  return [...same].sort((a, b) => Math.abs(EFFORTS.indexOf(a.rung[1]) - wanted) - Math.abs(EFFORTS.indexOf(b.rung[1]) - wanted))[0]?.index ?? 5
40}
41
42/** The rungs just above and below a pick, for the confirm dialog. */
43export const neighbours = (pick: { model: ModelName; effort: Effort }) => {
44  const rank = rankOf(pick)
45  const at = (index: number) => {
46    const rung = LADDER[index]
47
48    return rung && !(rung[0] === pick.model && rung[1] === pick.effort) ? { model: rung[0], effort: rung[1] } : undefined
49  }
50
51  return { up: at(rank + 1), down: at(rank - 1) }
52}
53
54export const samePick = (a: { model: ModelName; effort: Effort } | null | undefined, b: { model: ModelName; effort: Effort } | null | undefined) =>
55  !!a && !!b && a.model === b.model && a.effort === b.effort
56
57/** The tier a model id belongs to: "claude-opus-5-5[1m]" is opus. */
58export const tierOf = (model: string): ModelName | undefined =>
59  /opus/i.test(model) ? 'opus' : /sonnet/i.test(model) ? 'sonnet' : /haiku/i.test(model) ? 'haiku' : undefined
60
61// ── Words typed by the person: tags, and a pick in their own words ─────────
62
63export type Tags = {
64  text: string
65  model?: ModelName
66  effort?: Effort
67  /** `!!`: no upgrader for this prompt. */
68  skipUpgrade: boolean
69  /** `??`: the upgrader even for a short prompt. */
70  forceUpgrade: boolean
71}
72
73const MODEL_WORDS: Record<string, ModelName> = { opus: 'opus', sonnet: 'sonnet', haiku: 'haiku' }
74const EFFORT_WORDS: Record<string, Effort> = { medium: 'medium', med: 'medium', high: 'high', extra: 'xhigh', xhigh: 'xhigh', max: 'max' }
75const TAG = /^(!!|\?\?|!(opus|sonnet|haiku|medium|med|high|extra|xhigh|max))$/i
76
77/** Reads `!!`, `??` and `!opus`/`!max`… at the start or the end of a prompt, and strips them. */
78export const parseTags = (raw: string): Tags => {
79  const words = raw.trim().split(/\s+/)
80  const tags: string[] = []
81  while (words.length > 1 && TAG.test(words[0] ?? '')) tags.push(words.shift() as string)
82  while (words.length > 1 && TAG.test(words[words.length - 1] ?? '')) tags.push(words.pop() as string)
83  if (tags.length === 0) return { text: raw, skipUpgrade: false, forceUpgrade: false }
84  const found: Tags = { text: words.join(' '), skipUpgrade: false, forceUpgrade: false }
85  for (const tag of tags.map(one => one.toLowerCase())) {
86    if (tag === '!!') found.skipUpgrade = true
87    else if (tag === '??') found.forceUpgrade = true
88    else {
89      const word = tag.slice(1)
90      if (MODEL_WORDS[word]) found.model = MODEL_WORDS[word]
91      if (EFFORT_WORDS[word]) found.effort = EFFORT_WORDS[word]
92    }
93  }
94  // Keep the person's own line breaks when only the edges carried tags.
95  const first = raw.indexOf(words[0] ?? '')
96  const last = raw.lastIndexOf(words[words.length - 1] ?? '')
97  if (first >= 0 && last >= first) found.text = raw.slice(first, last + (words[words.length - 1] ?? '').length)
98
99  return found
100}
101
102/** "sonnet max", "Opus · Extra", "max" typed under Other: a pick, or undefined. */
103export const parsePickWords = (words: string, base: { model: ModelName; effort: Effort }) => {
104  const lower = words.toLowerCase()
105  const model = MODELS.find(one => lower.includes(one))
106  const effortWord = Object.keys(EFFORT_WORDS).find(one => new RegExp(`\\b${one}\\b`).test(lower))
107  const effort = effortWord ? EFFORT_WORDS[effortWord] : undefined
108  if (!model && !effort) return undefined
109
110  return { model: model ?? base.model, effort: effort ?? (model && model !== base.model ? 'high' : base.effort) }
111}
112
113// ── What kind of prompt it is ──────────────────────────────────────────────
114
115const ACK =
116  /^(y|yes|yeah|yep|yup|ok|okay|k|sure|go|go ahead|go for it|do it|continue|carry on|proceed|next|done|thanks|thank you|thx|merci|oui|non|no|nope|vas-y|vas y|go on|c'est bon|parfait|perfect|great|nice|cool|lgtm|ship it|approved?|agreed|d'accord|ok merci|top|super|[1-9]|[a-d]|option [a-d1-9])[\s.!?]*$/i
117
118export const wordCount = (text: string) => text.trim().split(/\s+/).filter(Boolean).length
119
120export type PromptKind = 'ack' | 'short' | 'substantial'
121
122/**
123 * `ack`: "yes", "continue" — keeps what runs. `short`: a brief instruction or an
124 * answer to Claude's question — confirmed only when the pick changes.
125 * `substantial`: a new task in its own words — the upgrader's, and always confirmed.
126 */
127export const kindOf = (text: string, lastAssistant: string): PromptKind => {
128  const trimmed = text.trim()
129  if (ACK.test(trimmed)) return 'ack'
130  const words = wordCount(trimmed)
131  const tail = lastAssistant.trim().slice(-400)
132  const claudeAsked = /\?\s*$/.test(tail) || /\?\s*\n/.test(tail.slice(-200))
133  if (words < 12) return 'short'
134  if (claudeAsked && words < 30) return 'short'
135
136  return 'substantial'
137}
138
139// ── Go-aheads: replies that continue the work at hand (calibrated on 200 real prompts) ──
140
141const GO_AHEAD =
142  /^(y|yes|yeah|yep|ok(ay)?|sure|go|go ahead|go for (it|both|all)|do it|let'?s (go|do it)|work on|move forward|you can (move forward|do|go|proceed|continue|start)|proceed|continue|carry on|please (continue|proceed|resume)|agreed?|approved?|confirmed?|both|all of them|keep|fine|perfect|great|parfait|d'accord|oui|vas[- ]?y|go on|c'est bon|run it( yourself)?|deploy it)\b/i
143const SELECTION = /^(ok(ay)?( with| for)? |work on |do |go with )?((option )?[a-e1-9]\b[\s,&+]*(and |et |through |to |à )?)+(are |is )?(a go|yes|ok|oui|go|done)?\b/i
144const STATUS =
145  /^(i'?ve|i have|i'?m now|i am now|j'ai|c'est|it'?s|app has|the app has|everything|all (these|of them)|done|chrome is|digest|that'?s|it was|ajouté|fait)\b.*\b(done|filled|logged|added|created|set|put|published|reconnected|arrived|configured|available|approved|mis|ajouté|fait|relancé|installé|connecté|ended|views?)\b/i
146const RESUME = /\b(resume|reprends|reprend|continue|carry on)\b/i
147
148/**
149 * True for a reply that continues the work at hand: a go-ahead ("Work on 1, 3, 4"),
150 * a status from the person ("App has been published"), a resume, or a short answer
151 * to Claude's own question. The chooser keeps what runs for these, unasked.
152 */
153export const isContinuation = (text: string, lastAssistant: string) => {
154  const trimmed = text.trim()
155  const words = wordCount(trimmed)
156  if (words === 0) return false
157  if (ACK.test(trimmed)) return true
158  const tail = lastAssistant.trim().slice(-400)
159  if ((/\?\s*$/.test(tail) || /\?\s*\n/.test(tail.slice(-200))) && words <= 25) return true
160  if (words <= 12 && RESUME.test(trimmed)) return true
161  if (words > 25 || trimmed.includes('?') || MULTI_STEP_RULES[0]?.test(trimmed)) return false
162
163  return GO_AHEAD.test(trimmed) || SELECTION.test(trimmed) || (words <= 15 && STATUS.test(trimmed))
164}
165
166// ── The rules: instant picks for the obvious prompts ──────────────────────
167
168const any = (rules: RegExp[], text: string) => rules.filter(rule => rule.test(text)).length
169
170const MAX_RULES = [
171  /\barchitect/i,
172  /\bfrom scratch\b/i,
173  /\bmigrate\b/i,
174  /\bmigration (plan|strategy|to|from|of)\b/i,
175  /\bmigrer\b/i,
176  /\brace condition/i,
177  /\bdeadlock/i,
178  /\bmemory leak/i,
179  /\broot cause\b/i,
180  /\bstill (fails?|failing|broken|crash|crashing|doesn'?t work|not working)/i,
181  /\btried everything\b/i,
182  /\bultrathink\b/i,
183  /\bthink (very |really )?hard\b/i,
184  /\bsecurity (audit|review)\b/i,
185  /\bredesign\b/i,
186  /\boverhaul\b/i,
187  /\brewrite (the|all|whole|entire)\b/i,
188  /\bde z[ée]ro\b/i,
189  /\brefonte\b/i,
190  /\bfuite m[ée]moire\b/i,
191  /\bcause racine\b/i,
192  /\btoujours (cass[ée]|pas|en [ée]chec|le m[êe]me bug|un bug)/i,
193  /\br[ée]fl[ée]chis (bien|à fond|longuement)\b/i,
194  /\b(strategy|strat[ée]gie)\b/i,
195]
196
197const EXTRA_RULES = [
198  /\bdebug/i,
199  /\bbugs?\b/i,
200  /\bcrash/i,
201  /\bfail(s|ing|ed|ure)?\b/i,
202  /\berrors?\b/i,
203  /\brefactor/i,
204  /\bimplement/i,
205  /\bfeatures?\b/i,
206  /\breview\b/i,
207  /\binvestigat/i,
208  /\banaly[sz]/i,
209  /\boptimi[sz]/i,
210  /\bperformance\b/i,
211  /\bwhy (does|is|do|did|doesn'?t|isn'?t)\b/i,
212  /\bplan\b/i,
213  /\bintegrat/i,
214  /\baudit\b/i,
215  /\bplante\b/i,
216  /\berreurs?\b/i,
217  /\bcorrige/i,
218  /\bd[ée]bug/i,
219  /\bimpl[ée]mente/i,
220  /\bfonctionnalit[ée]/i,
221  /\brevue\b/i,
222  /\banalyse/i,
223  /\bpourquoi\b/i,
224  /\boptimise/i,
225  /\bcon[çc]ois\b/i,
226  /\bimprove/i,
227  /\bam[ée]liore/i,
228  /\bwhat'?s next\b/i,
229  /\bparit[yé]/i,
230]
231
232/** Several actions chained, or shipping: release work is never a one-liner. */
233const MULTI_STEP_RULES = [/\b(release|testflight|production|publish|ship it|mise en prod)\b/i, /\b(and then|then|ensuite|puis|once it'?s)\b/i]
234
235/** A yes/no question about the state of things: a quick check. */
236const CHECK_QUESTION = /^(did you|have you|has it|is (it|everything|that|this)|are (they|you|we done)|was it|isn'?t it|do you have|est-ce que (c'est|tout|tu as))\b[^?]{0,200}\?\s*$/i
237const STATUS_PING = /\b(still running|toujours en cours|is it done|c'est fini)\b/i
238const RUN_IT = /\b(apply|run|deploy|applique|lance)\b.{0,30}\b(migrations?|yourself|toi-m[êe]me)\b/i
239
240const ROUTINE_RULES = [
241  /\brename\b/i,
242  /\bformat(ting)?\b/i,
243  /\btranslate\b/i,
244  /\btypos?\b/i,
245  /\bcommit\b/i,
246  /\bpush\b/i,
247  /\bgit (status|log|diff|pull)\b/i,
248  /\brun (the )?(tests?|build|lint|linter)\b/i,
249  /\bbump\b/i,
250  /\bsummari[sz]e\b/i,
251  /\bexplain\b/i,
252  /\bshow me\b/i,
253  /\blist (the|all)\b/i,
254  /\bwhat('?s| is| are| does)\b/i,
255  /\bhow (do|can|to)\b/i,
256  /\btradui[st]\b/i,
257  /\brenomme\b/i,
258  /\bexplique\b/i,
259  /\br[ée]sume\b/i,
260  /\bc'est quoi\b/i,
261  /\bcomment (je|on|faire)\b/i,
262  /\blance (les )?tests\b/i,
263]
264
265const TRIVIAL = /^(hi|hello|hey|salut|bonjour|coucou|thanks|thank you|merci|ok|cool|test|ping)\b[\s.!?]*$/i
266
267export type RulePick = { pick: Pick; isConfident: boolean }
268
269/** Instant local pick; `isConfident` false means a quick Haiku read should settle it. */
270export const rulePick = (text: string, hasImages: boolean): RulePick => {
271  const words = wordCount(text)
272  const rules = (model: ModelName, effort: Effort, reason: string, isConfident: boolean): RulePick => ({
273    pick: { model, effort, reason, by: 'rules', kind: reason },
274    isConfident,
275  })
276  if (TRIVIAL.test(text.trim())) return rules('haiku', 'medium', 'trivial message', true)
277  if (STATUS_PING.test(text) && words <= 8) return rules('haiku', 'medium', 'status check', true)
278  const max = any(MAX_RULES, text)
279  const extra = any(EXTRA_RULES, text)
280  const routine = any(ROUTINE_RULES, text)
281  if (RUN_IT.test(text) && max === 0 && words <= 20) return rules('sonnet', 'high', 'run a step', true)
282  if (CHECK_QUESTION.test(text.trim()) && max === 0 && words <= 25) return rules('sonnet', 'medium', 'quick check', extra === 0 && words <= 12)
283  const isShipping = MULTI_STEP_RULES[0]?.test(text) === true
284  const isChained = MULTI_STEP_RULES[1]?.test(text) === true && routine >= 1
285  if (max >= 1 && (words >= 20 || extra >= 1 || max >= 2)) return rules('opus', 'max', 'hard, deep problem', true)
286  if (max >= 1) return rules('opus', 'xhigh', 'demanding task', true)
287  if (extra >= 2) return rules('opus', 'xhigh', words > 40 ? 'multi-part engineering task' : 'debugging or design work', true)
288  if (extra === 1 && words >= 50) return rules('opus', 'xhigh', 'detailed engineering task', true)
289  if (extra === 1) return rules('opus', 'high', 'engineering task', words >= 6)
290  if ((isShipping || isChained) && max === 0) return rules('opus', 'high', 'multi-step chore', isShipping)
291  if (hasImages) return rules('opus', 'high', 'task with images', false)
292  if (routine >= 1 && words <= 30) return rules('sonnet', words <= 10 ? 'medium' : 'high', 'routine request', words <= 20)
293  if (words > 150) return rules('opus', 'high', 'long, detailed task', false)
294
295  return rules('opus', 'high', 'general task', false)
296}
297
298/** Keeps a pick sensible: no Low, efforts the model takes, Opus never below Medium. */
299export const normalise = (model: string | undefined, effort: string | undefined, fallback: Pick): { model: ModelName; effort: Effort } => {
300  const tier = (MODELS as string[]).includes(model ?? '') ? (model as ModelName) : fallback.model
301  const level = effort === 'extra' ? 'xhigh' : effort === 'low' ? 'medium' : effort
302  const chosen = (EFFORTS as string[]).includes(level ?? '') ? (level as Effort) : fallback.effort
303
304  return { model: tier, effort: chosen }
305}
306
307// ── Switching models mid-conversation ──────────────────────────────────────
308
309/** Below this, re-reading a conversation on another model costs next to nothing. */
310export const SMALL_CONTEXT = 20_000
311
312export const kTokens = (tokens: number) => (tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`)
313
314export const ago = (ms: number) => {
315  const minutes = Math.round(ms / 60_000)
316  if (minutes < 1) return 'just now'
317  if (minutes < 60) return `${minutes} min ago`
318
319  return `${Math.round(minutes / 60)} h ago`
320}
321
322/** `none`: same model; `cheap`: a plain confirm says so; `costly`: the three-choice switch dialog. */
323export type Switch = SwitchCost
324
325/**
326 * What moving from the model that runs now to the pick's costs. The cache is
327 * per model: a model that answered within the cache's lifetime still holds the
328 * conversation, so going back to it is cheap; one that never did re-reads it all.
329 */
330export const switchCost = (
331  pick: { model: ModelName },
332  current: { model: ModelName } | null,
333  contextTokens: number,
334  lastAnswered: Partial<Record<ModelName, number>>,
335  now: number,
336  cacheMs: number,
337): Switch => {
338  if (!current || current.model === pick.model) return { kind: 'none', note: '', short: '', tokens: '' }
339  const from = MODEL_LABEL[current.model]
340  const to = MODEL_LABEL[pick.model]
341  if (contextTokens < SMALL_CONTEXT)
342    return {
343      kind: 'cheap',
344      note: `The conversation is still short (${kTokens(contextTokens)} tokens): switching from ${from} costs little.`,
345      short: 'Short conversation: switching costs little',
346      tokens: kTokens(contextTokens),
347    }
348  const targetAt = lastAnswered[pick.model]
349  if (targetAt !== undefined && now - targetAt < cacheMs)
350    return {
351      kind: 'cheap',
352      note: `${to} still holds this conversation in its cache (answered ${ago(now - targetAt)}): switching back costs little.`,
353      short: `${to}'s cache is still warm: switching costs little`,
354      tokens: kTokens(contextTokens),
355    }
356  const currentAt = lastAnswered[current.model]
357  if (currentAt !== undefined && now - currentAt >= cacheMs)
358    return {
359      kind: 'cheap',
360      note: `${from}'s cache has gone cold (last answer ${ago(now - currentAt)}): switching costs nothing extra.`,
361      short: `${from}'s cache is cold: switching costs nothing extra`,
362      tokens: kTokens(contextTokens),
363    }
364
365  return {
366    kind: 'costly',
367    note: `Switching from ${from} re-reads ${kTokens(contextTokens)} tokens on ${to} without the cache; staying on ${from} reads them from its cache.`,
368    short: `Re-reads ${kTokens(contextTokens)} tokens on ${to}, without the cache`,
369    tokens: kTokens(contextTokens),
370  }
371}
372
373/**
374 * Mid-conversation, a step DOWN in model is rarely worth a full re-read: when
375 * the switch would be costly, the stronger model that runs now stays, at Medium.
376 */
377export const keepStronger = (pick: Pick, current: { model: ModelName; effort: Effort } | null, cost: Switch): Pick => {
378  if (!current || cost.kind !== 'costly' || MODEL_RANK[pick.model] >= MODEL_RANK[current.model]) return pick
379
380  return { model: current.model, effort: 'medium', reason: `${pick.reason}, kept on ${MODEL_LABEL[current.model]} (cache warm)`, by: 'kept' }
381}
382
383/** Past this, a conversation may not fit another model's window: it stays where it is. */
384export const MOVABLE_CONTEXT = 150_000
385
386/** A conversation too long to move keeps its model (the effort still follows the pick). */
387export const keepIfTooLong = (pick: Pick, current: { model: ModelName } | null, contextTokens: number): Pick =>
388  current && pick.model !== current.model && contextTokens > MOVABLE_CONTEXT
389    ? { ...pick, model: current.model, reason: `${pick.reason}, kept on ${MODEL_LABEL[current.model]} (too long to move)`, by: 'kept' }
390    : pick
391
392// ── Learning from the person: ask less over time ──────────────────────────
393
394/** Kept the recommendation this many times in a row for a kind of task: stop asking for it. */
395export const TRUST_AFTER = 5
396
397/** The kind a pick's trust is kept under: the rules' kind, else its level ("opus:high"). */
398export const kindKey = (pick: Pick) => pick.kind ?? `${pick.model}:${pick.effort}`
399
400export const isTrusted = (trust: Record<string, TrustEntry>, kind: string) => (trust[kind]?.streak ?? 0) >= TRUST_AFTER
401
402/** One answer to a plain confirm: kept the recommendation (streak + 1) or chose otherwise (back to 0). */
403export const recordAnswer = (trust: Record<string, TrustEntry>, kind: string, kept: boolean, now: number): Record<string, TrustEntry> => ({
404  ...trust,
405  [kind]: { streak: kept ? (trust[kind]?.streak ?? 0) + 1 : 0, at: now },
406})
407
408// ── What a turn costs ──────────────────────────────────────────────────────
409
410export const costKey = (pick: { model: ModelName; effort: Effort }) => `${pick.model}:${pick.effort}`
411
412/** Relative weight of a turn per model (input price ratio) and per effort (thinking spent). */
413const MODEL_WEIGHT: Record<ModelName, number> = { haiku: 1, sonnet: 3, opus: 5 }
414const EFFORT_WEIGHT: Record<Effort, number> = { medium: 0.7, high: 1, xhigh: 1.4, max: 2 }
415const weight = (pick: { model: ModelName; effort: Effort }) => MODEL_WEIGHT[pick.model] * EFFORT_WEIGHT[pick.effort]
416
417/** Readings past this are a window reset or another session's burst, not one turn. */
418const MAX_TURN_POINTS = 40
419
420/** Adds one turn's cost (points of the 5-hour window) to the moving average of its level. */
421export const recordTurnCost = (costs: Record<string, TurnCost>, key: string, points: number): Record<string, TurnCost> => {
422  if (!Number.isFinite(points) || points < 0 || points > MAX_TURN_POINTS) return costs
423  const was = costs[key]
424  const avg = was ? was.avg * 0.8 + points * 0.2 : points
425
426  return { ...costs, [key]: { avg: Math.round(avg * 100) / 100, n: (was?.n ?? 0) + 1 } }
427}
428
429/**
430 * A turn's expected cost on a level: its own average once two turns ran on it,
431 * else the other levels' averages scaled by weight; undefined with no history.
432 */
433export const estimateTurn = (costs: Record<string, TurnCost>, pick: { model: ModelName; effort: Effort }): number | undefined => {
434  const own = costs[costKey(pick)]
435  if (own && own.n >= 2) return own.avg
436  const known = Object.entries(costs).filter(([key, one]) => one.n >= 2 && key.includes(':'))
437  if (known.length === 0) return undefined
438  const perWeight =
439    known.reduce((sum, [key, one]) => {
440      const [model, effort] = key.split(':') as [ModelName, Effort]
441      return MODEL_WEIGHT[model] && EFFORT_WEIGHT[effort] ? sum + one.avg / weight({ model, effort }) : sum
442    }, 0) / known.length
443
444  return perWeight * weight(pick)
445}
446
447/** "≈3%", "<1%", or '' with no history. */
448export const costText = (points: number | undefined) =>
449  points === undefined ? '' : points < 1 ? '<1%' : `≈${Math.round(points)}%`
450
451// ── Reading the models' answers ────────────────────────────────────────────
452
453const firstJson = (text: string): unknown => {
454  const start = text.indexOf('{')
455  const end = text.lastIndexOf('}')
456  if (start < 0 || end <= start) return undefined
457  try {
458    return JSON.parse(text.slice(start, end + 1))
459  } catch {
460    return undefined
461  }
462}
463
464const str = (value: unknown, max: number) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
465
466export type Read = {
467  isClear: boolean
468  intent: string
469  pick: Pick
470  questions: UpgradeQuestion[]
471}
472
473/** The Haiku read: whether the prompt is clear, the questions worth asking, and the pick. */
474export const parseRead = (text: string, fallback: Pick, maxQuestions: number): Read | undefined => {
475  const json = firstJson(text) as Record<string, unknown> | undefined
476  if (!json || typeof json !== 'object') return undefined
477  const { model, effort } = normalise(str(json.model, 10), str(json.effort, 10), fallback)
478  const raw = Array.isArray(json.questions) ? json.questions : []
479  const questions: UpgradeQuestion[] = []
480  for (const [index, one] of raw.entries()) {
481    if (questions.length >= maxQuestions || !one || typeof one !== 'object') continue
482    const item = one as Record<string, unknown>
483    const options = (Array.isArray(item.options) ? item.options : [])
484      .map(option => str(option, 60))
485      .filter(Boolean)
486      .slice(0, 4)
487    const question = str(item.question, 200)
488    if (!question || options.length < 2) continue
489    const recommended = typeof item.recommended === 'number' && item.recommended >= 0 && item.recommended < options.length ? item.recommended : 0
490    questions.push({ id: `q${index + 1}`, topic: str(item.topic, 14) || `Question ${index + 1}`, question, options, recommended })
491  }
492
493  return {
494    isClear: json.clear === true || questions.length === 0,
495    intent: str(json.intent, 60),
496    pick: { model, effort, reason: str(json.reason, 40) || fallback.reason, by: 'haiku' },
497    questions,
498  }
499}
500
501/** The classify-only read. */
502export const parseClassify = (text: string, fallback: Pick): Pick | undefined => {
503  const json = firstJson(text) as Record<string, unknown> | undefined
504  if (!json || typeof json !== 'object') return undefined
505  const { model, effort } = normalise(str(json.model, 10), str(json.effort, 10), fallback)
506
507  return { model, effort, reason: str(json.reason, 40) || fallback.reason, by: 'haiku' }
508}
509
510/** The rewrite: the prompt between <prompt> tags, then `PICK: opus high | reason`. */
511export const parseRewrite = (text: string, fallback: Pick): { prompt: string; pick: Pick } | undefined => {
512  const match = /<prompt>\s*([\s\S]*?)\s*<\/prompt>/i.exec(text)
513  const prompt = (match?.[1] ?? '').trim()
514  if (!prompt) return undefined
515  const line = /PICK:\s*([a-z]+)\s+([a-z]+)\s*(?:\|\s*(.+))?/i.exec(text)
516  const { model, effort } = normalise(line?.[1]?.toLowerCase(), line?.[2]?.toLowerCase(), fallback)
517
518  return { prompt, pick: { model, effort, reason: (line?.[3] ?? '').trim().slice(0, 40) || fallback.reason, by: 'upgrader' } }
519}
520
521/** The answers as the rewrite reads them. */
522export const answersText = (questions: UpgradeQuestion[], answers: Record<string, { choice?: number; text?: string }>, extra: string) => {
523  const lines = questions.map(question => {
524    const answer = answers[question.id]
525    const words = answer?.text?.trim() || (answer?.choice !== undefined ? question.options[answer.choice] : undefined)
526
527    return `- ${question.question} → ${words ?? '(no preference)'}`
528  })
529  if (extra.trim()) lines.push(`- Also: ${extra.trim()}`)
530
531  return lines.join('\n')
532}
533
hooks/prompts.ts 45 lines
1import type { ModelName } from '../types'
2
3/** How the chooser ranks work; shared by every read so the picks agree. */
4export const POSTURE = `Pick the model and effort that will run the prompt. Aim for the strongest model the task deserves, without waste.
5- opus: the default for real work — features, bug fixes, refactors, reviews, plans, anything multi-step or touching several files. effort "high"; "xhigh" for hard debugging, multi-file features, code reviews; "max" only for architecture, migrations, stubborn or subtle bugs (race conditions, leaks), research synthesis, or when the person asks for deep thought.
6- sonnet: clearly routine work — a small single-file edit, a quick factual or how-to question, formatting, renaming, translating, git chores, running a command. effort "medium" or "high".
7- haiku: trivial only — a greeting, thanks, a one-line lookup. effort "medium".
8Never "low". reason: 2 to 5 plain words naming the task's kind ("multi-file feature", "quick question").`
9
10export const CLASSIFY_SYSTEM = `You route a developer's prompt to Claude Code (an agentic coding assistant). ${POSTURE}
11Answer with JSON only: {"model":"haiku|sonnet|opus","effort":"medium|high|xhigh|max","reason":"..."}`
12
13export const READ_SYSTEM = (maxQuestions: number) => `You triage a developer's prompt to Claude Code (an agentic coding assistant that reads and edits the code itself) before it runs. Answer with JSON only.
14
151. clear: true only when a competent developer could start right away with no decision left open: a precise bug report, a fully specified change, a plain question. A new feature, a redesign, or any request with several reasonable outcomes is NOT clear: ask about the decisions that would change the result. Never ask what Claude can find by reading the code or the project rules.
162. When not clear, up to ${maxQuestions} questions whose answers would most change the result (scope, expected outcome, constraints, approach, priorities), most important first. Each: "topic" (1-2 words, at most 12 characters), "question" (one short sentence ending in "?"), "options" (2 to 4 distinct answers of at most 48 characters, the likeliest first), "recommended" (index of the likeliest). Write them in the prompt's language.
173. ${POSTURE}
184. intent: what the prompt asks, in at most 6 words, in the prompt's language.
19
20{"clear":false,"intent":"...","model":"opus","effort":"high","reason":"...","questions":[{"topic":"Scope","question":"...?","options":["...","..."],"recommended":0}]}`
21
22const MODEL_HINT: Record<ModelName, string> = {
23  opus: 'Opus plans well on its own: give it the goal, the decisions already made, the constraints and how to verify; do not dictate steps it can work out.',
24  sonnet: 'Sonnet does best with a bounded task: name the area or files to touch and the exact expected result.',
25  haiku: 'Haiku needs one bounded task, explicit steps and the exact expected output.',
26}
27
28export const REWRITE_SYSTEM = (target: ModelName) => `You rewrite a developer's prompt to Claude Code (an agentic coding assistant that reads and edits the code itself) so it gets an excellent result on the first try. Fold in the answers the developer just gave.
29
30Rules:
31- Keep the developer's intent, facts, names, paths, code and language: write in the language of their prompt. Add no requirement they did not state or choose.
32- Claude already reads the project's rules (CLAUDE.md) and the code: never restate them. Mention a rule only when an answer decides something about it.
33- Every sentence must change the outcome: no role-play ("You are an expert"), no politeness, no filler, no restating the obvious. Shorter is better when nothing is lost.
34- Open with the goal as one imperative sentence. Then, only where they add something: the context Claude cannot find in the code; scope and constraints (what to touch, what to leave alone); the decisions made in the answers; how to tell it is done (behaviour to see, tests to pass). Use short bullets for several points; no headings for a small task.
35- ${MODEL_HINT[target]}
36- Then pick the model and effort for the rewritten prompt. ${'Opus is the default for real work; Sonnet for clearly routine work; Haiku for trivial work only; effort medium, high, xhigh or max, never low.'}
37
38Answer exactly in this shape, nothing before or after:
39<prompt>
40the rewritten prompt
41</prompt>
42PICK: <haiku|sonnet|opus> <medium|high|xhigh|max> | <2 to 5 words naming the task's kind>`
43
44export const RECAP_PROMPT = `Write a handoff note so a fresh Claude Code session can take over this work. Plain text, at most 12 lines: the goal, what is done (with file paths), decisions made and why, what is left, and anything that must not be redone or broken. No preamble.`
45
types/index.d.ts 112 lines
1export type ModelName = 'haiku' | 'sonnet' | 'opus'
2
3/** The four levels the chooser uses; `xhigh` is shown as "Extra". Low is never used. */
4export type Effort = 'medium' | 'high' | 'xhigh' | 'max'
5
6/** Who settled a pick: the local rules, a Haiku read, the upgrader's rewrite, the person, or the chooser keeping what runs. */
7export type PickSource = 'rules' | 'haiku' | 'upgrader' | 'you' | 'kept' | 'trusted'
8
9export type Pick = {
10  model: ModelName
11  effort: Effort
12  /** Two to five plain words: "multi-file feature". */
13  reason: string
14  by: PickSource
15  /** The kind of task the rules saw ("engineering", "routine"…); trust is kept per kind. */
16  kind?: string
17}
18
19/** How many times in a row the person kept the recommended pick for one kind of task. */
20export type TrustEntry = { streak: number; at: number }
21
22/** What one turn on a model and effort costs, in points of the 5-hour window (a moving average). */
23export type TurnCost = { avg: number; n: number }
24
25/** The 5-hour window's reading when a main turn started, and what it ran on. */
26export type TurnStartMark = { key: string; percent: number }
27
28export type UpgradeQuestion = {
29  id: string
30  /** A short chip: "Scope", "Done when". */
31  topic: string
32  question: string
33  options: string[]
34  /** Index of the option preselected. */
35  recommended: number
36}
37
38/** A question's answer: an option, or the person's own words (which win). */
39export type UpgradeAnswer = { choice?: number; text?: string }
40
41export type UpgradeStage = 'questions' | 'writing' | 'ready' | 'failed'
42
43/** What moving to another model mid-conversation costs: nothing to say, little, or a full re-read. */
44export type SwitchCost = {
45  kind: 'none' | 'cheap' | 'costly'
46  /** A sentence for the panel. */
47  note: string
48  /** A clause for the dialog. */
49  short: string
50  /** The conversation's size, "90k". */
51  tokens: string
52}
53
54/** One run of the prompt upgrader, drawn by its panel. */
55export type Upgrade = {
56  id: string
57  stage: UpgradeStage
58  original: string
59  /** What the prompt asks, in six words or fewer. */
60  intent: string
61  questions: UpgradeQuestion[]
62  answers: Record<string, UpgradeAnswer>
63  /** "Anything to add?" */
64  extra: string
65  improved: string
66  /** Earlier rewrites, newest last, so a tweak can be taken back. */
67  previous: string[]
68  pick: Pick
69  /** True once the person changed the model or effort in the panel: the rewrite then keeps it. */
70  pickTouched: boolean
71  /** The model/effort chips are open in the questions step. */
72  chipsOpen: boolean
73  /** Whether the model chooser is on: the panel then shows and applies the pick. */
74  chooser: boolean
75  /** What runs now in this conversation (null in a fresh one), and how much it holds. */
76  from: { model: ModelName; effort: Effort } | null
77  contextTokens: number
78  switch: SwitchCost
79  /** What went wrong while writing. */
80  error: string
81  /** A one-line confirmation after Copy. */
82  notice: string
83}
84
85/** A usage window as the band shows it. */
86export type UsageWindow = { kind: string; percent: number; resetsAt?: string }
87
88/** The prompt cache's state per model: when each last answered in this conversation. */
89export type CacheClock = Partial<Record<ModelName, number>>
90
91/** An improved prompt handed to the box to edit: the next prompt runs on its pick, with no questions. */
92export type Handoff = { pick: Pick; until: number }
93
94declare module 'claude-code' {
95  interface PluginState {
96    'prompt-pilot': {
97      /** What runs now: every main request of the session goes to it. */
98      active: Pick | null
99      /** The upgrader's panel, while one runs. */
100      upgrade: Upgrade | null
101      /** What the band says while a prompt is being read. */
102      busy: string
103      usage: UsageWindow[]
104      cache: CacheClock
105      handoff: Handoff | null
106      turnStart: TurnStartMark | null
107      chooserOn: boolean
108      upgraderOn: boolean
109    }
110  }
111}
112