Routes each prompt and subagent run to the cheapest model and effort that can handle it, and manages the cache, compaction and model switches around it.

auto route and effort
Your typo fix doesn't need Opus. arne is a Claude Code plugin that sends each prompt to the cheapest model and effort that can handle it (Haiku 5.5, Sonnet 5.5 or Opus 5.5), and does the cache math before you switch or hand off a big chat.
In its bench, arne cost 53% less per task than running everything on Opus, and 35% less than effortless.
https://github.com/user-attachments/assets/20300835-8253-4130-91c0-946e7c5370c5
~/.claude/arne/decisions.jsonl, which never leaves your machine./arne off turns routing off.The band above the prompt shows the model that's running and why it was picked:

Open the band to see the cost of staying, switching or handing off, and to pin a model or effort:

Settings, where you choose the judge and the cheapest and strongest model and effort arne may use:

Stats, which estimates what arne saved (shown here with example data):

/plugin marketplace add tu2525/arne
/plugin install arne@arne
Restart Claude Code afterwards. If you use effortless, turn it off first, because arne replaces it.
The band above the prompt is the control:
| Control | What it does |
|---|---|
| ● Auto | Turns routing on or off, or resumes it after you pick a model yourself |
| model / effort | Pins a choice for this chat (Auto unpins) |
| why | Shows the last decision, with 👍/👎 to rate it |
| cache | Shows what a cold cache will cost |
| ⚙ | Settings (also in /config) |
/arne does the same from the prompt: on, off, pin haiku|sonnet|opus [low|medium|high], unpin, handoff, subagents auto|haiku|sonnet|opus|off, why, log, stats, backend haiku|heuristic|laya|jev, settings.
Open it from the band's panel, from the cold-cache and heavy-chat alerts, or with /arne handoff [haiku|sonnet|opus]. Haiku summarizes the chat into arne-handoff.md and copies it to the clipboard. The Handoff setting decides what happens next:
/clear, switches tier and sends the summary as the first message. The old chat is saved to arne-archive/ first.Once a chat passes 30k tokens, the panel shows one line per cheaper tier: what a handoff costs now, and how many turns until it pays off.
Open it from the band's panel (Settings → Stats) or with /arne stats. It shows an estimate of what arne saved today, over the last 7 days and in total. To get it, each turn's real tokens are priced again as if the turn had run on Opus at medium effort. The figure can't be exact, because a different model would have written a different answer. Model switches and summaries are counted against arne.
This is off by default. When it's on, a subagent gets its model when it starts. The model is at medium effort or more, and never dearer than the chat that sent it or the Subagents ceiling in Settings. If the call asks for a model under the ceiling, that model runs. A dearer one is capped, and the Agent call says so (for example, "arne: Sonnet (capped from Opus)"). Forks, teammates and workflow agents are always left alone.
| Backend | Where it runs |
|---|---|
haiku (default) | Your Claude session |
heuristic | Locally, with no model call |
laya | A local laya-serve |
jev | TypeSafe's hosted judge. Your prompts leave your machine. You need a key from console.typesafe.ai/keys, set in /config (kept in secure storage) or in TYPESAFE_API_KEY |
Plugins can't move the desktop app's model menu. arne switches with the real /model, but the menu can keep showing your last pick. The band always shows the model that's actually running.
Latest results: bench/agentic/FINDINGS.md. It covers 5 reps per task, a 5-prompt chat test, pinned and effortless setups, the method, every test prompt and how it's checked, and how to send us your own numbers. Short version: arne cost $0.047 per task against $0.228 on Opus, and $0.021 per chat against $0.599, at about the same pass rate. The first run, below, is kept for history.
These are effortless's own bench/agentic tasks (MIT): four real coding tasks on effortless's source. They find a threshold, add JSDoc without touching code, write and run a test-counting script, and explain an update flow. Every run started on Opus · medium. There were three setups, with 2 runs per task each, through claude -p --setting-sources project, which keeps user plugins out.
| Setup | Runs | Correct | Correct, harness denials excluded | API-equivalent cost | Per task | Avg requests | Avg time |
|---|---|---|---|---|---|---|---|
| Opus medium, no mods | 8 | 8 | 8 | $1.71 | $0.214 | 7.4 | 30 s |
| effortless | 8 | 6 | 8 | $1.23 | $0.153 | 7.1 | 28 s |
| arne | 8 | 6 | 7 | $0.80 | $0.100 | 7.9 | 29 s |
total_cost_usd from each run, which is the list-price equivalent. It isn't what a Claude plan bills.node tools/count-tests.mjs even though it was allowed. The agent wrote the script but couldn't run it.bash bench/agentic/run.sh 2, then node bench/agentic/score.cjs.arne replaces effortless by HeyCubit (MIT), and reuses its cost formulas and bench tasks.
MIT, see LICENSE.
hooks/register.tsx 1829 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { JEV_MODEL, JEV_URL, LAYA_URL, parseVerdict, SYSTEM, SYSTEM_ONE_TIMEOUT_MS, systemOneBody, systemOneVerdict } from './backends'
5import { bandText, CACHE_TTL, clampEffort, clampTier, coldAdvice, contextOf, costCompact, costContinue, dollars, EFFORT_LABEL, EFFORT_LABEL_SHORT, EFFORT_RANK, fillOf, fitRest, fitTier, midTask, mmss, nextTtl, overTurns, paysOffAfter, plainReason, planSwitch, shortModel, summaryPrompt, tierOf, PRICE, OUT, READ } from './econ'
6import type { BandState, Row as CostRow } from './econ'
7import { MODEL, failedAttempt, route } from './route'
8import { VERSION } from './version'
9import type { Backend, Decision, Tier } from './route'
10import { parseUsage, periodStart, PERIODS, savedText, summarize, usageRow } from './stats'
11import type { Period, UsageRow } from './stats'
12import type { Cache, Row } from '../types'
13
14const LOG_FILE = '.claude/arne/decisions.jsonl' // under the home directory; local only, never shared
15
16// A path under the home directory, read at runtime; an absolute path (the bench sets one) stays as it is.
17async function homePath($: any, p: string): Promise<string> {
18 if (/^([A-Za-z]:)?\//.test(p)) return p
19 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
20 return home ? `${home.replace(/\\/g, '/').replace(/\/$/, '')}/${p}` : p
21}
22
23const PANE = 'arne-log'
24const SETTINGS = 'arne-settings'
25const STATS = 'arne-stats'
26
27const TIER_OF: Record<string, Tier> = { haiku: 'S', sonnet: 'M', opus: 'L' }
28
29const last = atom({ plugin: 'arne', key: 'last' } as const, null)
30const log = atom({ plugin: 'arne', key: 'log' } as const, [])
31const cache = atom({ plugin: 'arne', key: 'cache' } as const, null)
32const clockNow = atom({ plugin: 'arne', key: 'now' } as const, 0)
33const coldDismissed = atom({ plugin: 'arne', key: 'coldDismissed' } as const, 0)
34const routing = atom({ plugin: 'arne', key: 'routing' } as const, false)
35const isOff = atom({ plugin: 'arne', key: 'isOff' } as const, false)
36// True after the person picked a model by hand (/model or the picker). One of the two inputs to mode().
37const isPaused = atom({ plugin: 'arne', key: 'isPaused' } as const, false)
38// The effort the app sends when arne leaves it alone: what the band shows while paused or off.
39const appEffort = atom({ plugin: 'arne', key: 'appEffort' } as const, null)
40// The session's real model, as state so the band redraws when it changes ($.session.model() alone would not).
41const sessionModel = atom({ plugin: 'arne', key: 'sessionModel' } as const, null)
42const held = atom({ plugin: 'arne', key: 'held' } as const, null)
43// True while effortless is enabled: it writes effort, so arne routes models only.
44const effortless = atom({ plugin: 'arne', key: 'effortless' } as const, false)
45const EFFORTLESS = 'effortless@effortless'
46const SETTINGS_FILE = '.claude/settings.json' // under the home directory, same as LOG_FILE
47const shown = atom({ plugin: 'arne', key: 'shown' } as const, null)
48// True while the band's panel is open (model, effort, why, settings).
49const whyOpen = atom({ plugin: 'arne', key: 'whyOpen' } as const, false)
50// The decision key the person just rated; the panel then says it logged the rating.
51const thanked = atom({ plugin: 'arne', key: 'thanked' } as const, null as string | null)
52// True while Laya is the judge but laya-serve is not answering; the why line then says Haiku judged.
53const layaDown = atom({ plugin: 'arne', key: 'layaDown' } as const, false)
54// True while Jev is the judge but no TypeSafe key is found (17s); the why line then says Haiku judged.
55const jevNoKey = atom({ plugin: 'arne', key: 'jevNoKey' } as const, false)
56// The typesafe_key option (sensitive: secure storage, never settings.json). Kept here only, never in an atom,
57// a log row or on screen.
58let optionKey = ''
59// The TypeSafe key: TYPESAFE_API_KEY first, then the option.
60const typesafeKey = async ($: any): Promise<string | undefined> => (await Promise.resolve($.env.get('TYPESAFE_API_KEY')).catch(() => undefined)) || optionKey || undefined
61const ADD_KEY_HINT = 'Set it in /config → arne → TypeSafe API key, or the TYPESAFE_API_KEY environment variable.'
62// What the desktop app's model picker shows: the session's first model, then each pick made in it
63// (PostModelSwitch source "sdk"). Plugins cannot move that picker, so arne's /model can differ from it.
64const appPicker = atom({ plugin: 'arne', key: 'appPicker' } as const, null)
65// The Stats pane's period (17q).
66const statsPeriod = atom({ plugin: 'arne', key: 'statsPeriod' } as const, 'week')
67// 17u. Every own config write reloads the plugin and wipes module state, so what a press must show at
68// once, or what has to outlive that reload, lives here.
69// Bumped on every option change: the Settings pane reads it, since `live` alone draws nothing again.
70const liveRev = atom({ plugin: 'arne', key: 'liveRev' } as const, 0)
71// Bumped after a pin's store write: the band reads it, since a store read does not subscribe.
72const pinRev = atom({ plugin: 'arne', key: 'pinRev' } as const, 0)
73// The cold spell (its warmUntil) whose heavy alert the person dismissed, as coldDismissed.
74const heavyDismissed = atom({ plugin: 'arne', key: 'heavyDismissed' } as const, 0)
75// A compact arne started is running: its buttons give way to "summarizing…", and a second press does nothing.
76const compacting = atom({ plugin: 'arne', key: 'compacting' } as const, false)
77// `claude plugin disable effortless` is running (up to 60 s): its button is hidden meanwhile.
78const disabling = atom({ plugin: 'arne', key: 'disabling' } as const, false)
79// The main turn's decision, for its steps.
80const currentAtom = atom({ plugin: 'arne', key: 'current' } as const, null)
81// The session model arne expects: the one it last switched to, or last saw. A switch to another tier is the person's.
82const expectedAtom = atom({ plugin: 'arne', key: 'expected' } as const, null)
83// A Summarize, then send/continue is running: its compaction must not also send "Continue".
84const compactSendingAtom = atom({ plugin: 'arne', key: 'compactSending' } as const, false)
85// The tier the last handoff went to, for the panel's note; the "Handoff…" item's tier list is open (17w).
86const handedOff = atom({ plugin: 'arne', key: 'handedOff' } as const, null)
87const handoffMenu = atom({ plugin: 'arne', key: 'handoffMenu' } as const, false)
88// This chat's subagents (17x): how many arne moved to each tier, kept on their model, ran as asked, or capped (17y).
89const subCount = atom({ plugin: 'arne', key: 'subCount' } as const, null)
90type SubCount = Partial<Record<Tier | 'kept' | 'asked' | 'capped', number>>
91const countSpawn = ($: any, k: keyof SubCount) => update($, subCount, (c: SubCount | null) => ({ ...c, [k]: ((c ?? {})[k] ?? 0) + 1 }))
92// The Stats pane's three periods, parsed from the log once per open rather than on every press.
93const statsData = atom({ plugin: 'arne', key: 'stats' } as const, null)
94// What the band's panel shows: its main rows, Settings or Stats. In the app a Pane lost the first press
95// of every button, so Settings and Stats moved into the band, whose buttons answer at once.
96type View = 'main' | 'settings' | 'stats'
97const panelView = atom({ plugin: 'arne', key: 'panelView' } as const, 'main')
98const TIER_NAME_OF: Record<Tier, string> = { S: 'haiku', M: 'sonnet', L: 'opus' }
99const STATES: BandState[] = ['routing', 'off', 'paused', 'cold', 'heavy', 'normal']
100
101// /compact runs as the app's own command, so the idle wait and the app's own summary path apply.
102async function effortlessOn($: any): Promise<boolean> {
103 const text: string = await $.fs.read(await homePath($, SETTINGS_FILE)).catch(() => '')
104 try {
105 return JSON.parse(text).enabledPlugins?.[EFFORTLESS] === true
106 } catch {
107 return false
108 }
109}
110
111// The state flips only at the next session start: effortless's hooks stay loaded until then.
112async function disableEffortless($: any) {
113 if (await read($, disabling)) return
114 await update($, disabling, () => true)
115 const r = await $.process.run(['claude', 'plugin', 'disable', EFFORTLESS], { timeoutMs: 60_000 }).catch(() => undefined)
116 await update($, disabling, () => false)
117 $.ui.toast(r?.exitCode === 0 ? 'Effortless disabled. Restart to hand effort to arne.' : 'Could not disable effortless; disable it from the plugin list.')
118}
119
120// One compact at a time: the alerts show "summarizing…" while it runs, and a second press returns false.
121async function compactNow($: any, note: string): Promise<boolean> {
122 if (await read($, compacting)) return false
123 await update($, compacting, () => true)
124 try {
125 await $.command.run({ command: 'compact', args: note.trim() })
126 return true
127 } catch {
128 $.ui.toast('Compact failed; try again when the session is idle')
129 return false
130 } finally {
131 await update($, compacting, () => false)
132 }
133}
134
135// Card colours. On a dark theme the prompt box's outline colour, so arne's cards match it. No fill: it paints a square box behind the rounded border; any other theme keeps its keys.
136let CARD: { border: string } = { border: 'promptBorder' }
137let cardSynced = false
138async function syncCard($: any) {
139 if (cardSynced) return
140 const row = (await $.config.list().catch(() => [])).find((r: any) => r.key === 'theme')
141 if (!row) return
142 cardSynced = true
143 if (String(row.value).startsWith('dark')) CARD = { border: '#4d4d4c' }
144}
145
146// --- Handoff (17o, 17w) ---
147// Haiku summarizes the chat into arne-handoff.md and the clipboard. Setting Handoff = newchat (default): a
148// toast asks the person to open a new chat on `tier` and paste; no plugin call opens a chat or pastes into
149// one. Handoff = clear: /clear starts a fresh chat in place, pinned to `tier`, whose first message is the
150// summary. One at a time (the compacting atom).
151const HANDOFF_FILE = 'arne-handoff.md'
152const ARCHIVE_DIR = 'arne-archive'
153const SUMMARY_SYSTEM = 'Summarize this coding conversation for its next turn: goal, decisions and why, files changed (paths), exact errors, open questions, next step. Plain text, under 1500 words.'
154
155// Set before the first await, so two presses in the same tick still run one handoff; the atom draws "summarizing…".
156let handingOff = false
157async function handoff($: any, tier: Tier, surface?: string) {
158 if (handingOff) return
159 handingOff = true
160 if (await read($, compacting)) return void (handingOff = false)
161 await update($, compacting, () => true)
162 let step = 'the summary'
163 try {
164 const msgs: any = await $.session.messages()
165 const said = (Array.isArray(msgs) ? msgs : []).filter((m: any) => m.text?.trim())
166 if (said.length === 0) throw new Error('empty chat')
167 const r = await $.model.complete(
168 { model: live.compactModel, prompt: summaryPrompt(said), system: SUMMARY_SYSTEM, effort: 'low' },
169 { timeoutMs: 120_000 },
170 )
171 if (!r.isAnswered || !r.text) throw new Error('no summary')
172 const sum = usageRow('summary', await $.clock.now(), r.usage, MODEL[TIER_OF[live.compactModel] ?? 'S'])
173 if (sum) await appendLog($, sum)
174 const text = `# Handoff
175
176Summary of an earlier chat (written by ${shortModel(live.compactModel)} for arne):
177
178${r.text}
179`
180 await $.fs.write(HANDOFF_FILE, text).catch(() => undefined)
181 step = 'the clipboard'
182 const copied = await $.ui.copy({ text, surface }).catch(() => undefined)
183 if (copied?.isCopied === false) throw new Error('copy')
184 if (live.handoff !== 'clear') {
185 // No plugin call opens a new chat or pastes into one, so the old chat is left alone.
186 await update($, handedOff, () => tier)
187 $.ui.toast(`Handoff copied. Open a new chat on ${shortModel(MODEL[tier])} and paste. This chat is untouched.`)
188 return
189 }
190 // The whole old chat, kept as a file before /clear: the sidebar history alone is not a copy.
191 const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
192 const transcript = said.map((m: any) => `## ${m.role ?? 'message'}\n\n${m.text}`).join('\n\n')
193 await $.fs.write(`${ARCHIVE_DIR}/handoff-${stamp}.md`, `# Chat before handoff\n\n${transcript}\n`).catch(() => undefined)
194 step = '/clear'
195 await $.command.run({ command: 'clear', args: '' })
196 await update($, cache, () => null) // the new chat has no cache yet
197 await update($, subCount, () => null)
198 step = '/model'
199 // Pinned after the clear: a pin belongs to the chat it was set in.
200 await setPinPart($, { tier }, false)
201 if (tierOf(await $.session.model()) !== tier) await moveModel($, MODEL[tier])
202 if (tierOf(await $.session.model()) !== tier) throw new Error('model')
203 step = 'sending the summary'
204 await $.prompt.submit({ text, asUser: true })
205 await update($, handedOff, () => tier)
206 $.ui.toast(`Handed off to ${shortModel(MODEL[tier])}. Old chat saved in history; archive it from the sidebar when you are done.`)
207 } catch {
208 $.ui.toast(`Handoff stopped at ${step}.${step === 'the summary' ? '' : ` The summary is on your clipboard and in ${HANDOFF_FILE}.`}`)
209 } finally {
210 handingOff = false
211 await update($, compacting, () => false)
212 await update($, handoffMenu, () => false)
213 }
214}
215
216// --- The held prompt (step 17a) ---
217// A prompt typed on a cold, big chat waits here until the person picks Compact & send or Send anyway.
218const CONTINUE_TEXT = 'Continue where you left off.'
219// Messages a compact keeps word for word after the summary, and the judge's card reads (17v).
220const KEEP_AFTER_COMPACT = 10
221let lastReason: string | undefined // how the last main turn ended
222
223// Sends a prompt as the person's own words. A plugin's own $.prompt.submit skips that plugin's
224// prompt.submit hooks (seen in the test kit), so it is never held again and never routed there.
225async function sendAsUser($: any, text: string) {
226 try {
227 await $.prompt.submit({ text, asUser: true })
228 } catch {
229 $.ui.toast('Could not send your message; it is back in the prompt box')
230 await $.prompt.fill({ text, mode: 'replace' }).catch(() => undefined)
231 }
232}
233
234async function compactAndSend($: any) {
235 const text = await read($, held)
236 if (!text || (await read($, compactSendingAtom)) || (await read($, compacting))) return
237 await update($, held, () => null) // cleared first: a second click finds nothing to send
238 await update($, compactSendingAtom, () => true)
239 const ok = await compactNow($, '').finally(() => update($, compactSendingAtom, () => false))
240 if (ok) await routeAndSend($, text)
241 else await update($, held, () => text)
242}
243
244// Heavy banner: nothing is held (the prompt was never blocked), so after compacting arne sends "Continue".
245async function compactThenContinue($: any) {
246 if ((await read($, compactSendingAtom)) || (await read($, compacting))) return
247 await update($, compactSendingAtom, () => true)
248 const ok = await compactNow($, '').finally(() => update($, compactSendingAtom, () => false))
249 if (ok) await routeAndSend($, CONTINUE_TEXT)
250}
251
252async function sendAnyway($: any) {
253 const text = await read($, held)
254 if (!text) return
255 await update($, held, () => null)
256 await routeAndSend($, text)
257}
258
259
260// Module state. Reset on a reload, like every module variable (what must survive one is in atoms, 17u).
261let prev: Decision | undefined
262// After an escalation, the next HOLD_TURNS prompts stay at least on its tier, so a judge that read the retry
263// as small does not drop straight back to the model that failed (claude-router holds 2 turns too).
264const HOLD_TURNS = 2
265let hold: { tier: Tier; left: number } | undefined
266let failures = 0 // failed attempts reported at the current tier; the person's retry is not a new tier until live.attempts of them
267const RANK: Record<Tier, number> = { S: 0, M: 1, L: 2 }
268let switching = false // arne's own /model is queued or running: the session model may still be the old one
269let lastResponseAt: number | undefined
270let arneSwitched = false // arne's own /model ran: the next main turn's cache writes are the switch's (17q)
271// Decisions made at prompt.submit, by text, for the turn.start of the same prompt.
272const planned = new Map<string, Decision>()
273// Subagent decisions, by agentId, so each step of a run uses the effort picked at its spawn (17x).
274const agents = new Map<string, Decision | undefined>()
275// Picks whose spawn has not resolved yet: the subagent's first request can arrive before its agentId does.
276const pendingPicks: Decision[] = []
277// A step of an agent with no pick yet claims the oldest pending pick on its model (effortless's helperEffortOf).
278function agentPick(agentId: string, model: string): Decision | undefined {
279 if (agents.has(agentId)) return agents.get(agentId)
280 const i = pendingPicks.findIndex(d => tierOf(model) === d.tier)
281 if (i < 0) return undefined
282 const [d] = pendingPicks.splice(i, 1)
283 agents.set(agentId, d)
284 return d
285}
286
287// The band's pickers pin a tier, an effort, or both; the part left out is arne's to decide.
288type Pin = { tier?: Tier; effort?: Decision['effort'] }
289type Settings = { enabled: boolean; pin: Pin | null; backend: string }
290
291// The userConfig values, as the engine holds them. arne's own writes (the band, Settings, /arne) go
292// through setOption, which updates this copy itself: the app never shows a plugin its own config.set.
293// A /config write lands in the config.set hook below. Either write reloads arne, and register reads them again.
294type Live = {
295 auto: boolean
296 backend: string
297 modelFloor: string
298 modelCeiling: string
299 effortFloor: string
300 effortCeiling: string
301 compactModel: string
302 coldTokens: number
303 attempts: number // failed attempts at a tier before a retry goes one up
304 heavyTokens: number
305 continueAfterCompact: boolean
306 subagents: string // 17y: the dearest model a subagent may run on (haiku, sonnet, opus), or off
307 handoff: string // newchat: copy the summary and ask for a new chat; clear: /clear this chat and send it
308}
309const DEFAULTS: Live = {
310 auto: true,
311 backend: 'haiku',
312 modelFloor: 'haiku',
313 modelCeiling: 'sonnet', // the judge stops here; only a failed attempt goes on to Opus
314 effortFloor: 'low',
315 effortCeiling: 'high',
316 compactModel: 'haiku',
317 coldTokens: 30_000,
318 attempts: 1, // measured: 3 retries in a row talked a passing Sonnet answer into failing (0/3 vs 3/3)
319 heavyTokens: 150_000,
320 continueAfterCompact: true,
321 subagents: 'sonnet', // a subagent runs on Sonnet at most
322 handoff: 'newchat',
323}
324// 17y: Subagents was On/Off before it became a ceiling; On reads as Sonnet, Off as off. Auto is no ceiling (Opus).
325const subCeiling = (v: unknown) => (v === false || v === 'off' ? 'off' : v === 'auto' || TIER_OF[String(v)] ? String(v) : v === true ? 'sonnet' : 'auto')
326let live: Live = { ...DEFAULTS }
327const FIELDS = Object.keys(DEFAULTS) as (keyof Live)[]
328
329// The cold offer applies: the chat model's cache is cold, the chat is past coldTokens, and this cold
330// spell was not dismissed. A compact clears the cache atom, so there is no offer until the next response.
331async function coldOffer($: any, now: number) {
332 const c: Cache | null = await read($, cache)
333 if (!c) return undefined
334 const until = c.warmUntil[c.model] ?? 0
335 if (until > now || c.C < live.coldTokens) return undefined
336 if ((await read($, coldDismissed)) === until) return undefined
337 // Only when summarizing first is cheaper than the plain resend (17p): otherwise there is nothing to offer.
338 const advice = coldAdvice(c.C, c.model, c.warmUntil, now)
339 const resend = advice.rows.find(r => r.action === 'continue')!.first
340 const summary = advice.rows.find(r => r.action === 'compact')!.first
341 return summary < resend ? { c, ...advice } : undefined
342}
343
344// Where a handoff goes when no tier is named: the tier the judge last wanted, else the chat's own.
345async function handoffTier($: any): Promise<Tier> {
346 const l = await read($, last)
347 return tierOf(l?.waitFor ?? l?.model ?? '') ?? tierOf(await $.session.model()) ?? 'M'
348}
349
350// The one on/off answer every surface reads: the /arne reply, the band, the status line and the pane.
351type Mode = 'off' | 'paused' | 'on'
352// Reads atoms, not `live`, so every surface that asks redraws when the answer changes.
353async function modeOf($: any): Promise<Mode> {
354 return (await read($, isOff)) ? 'off' : (await read($, isPaused)) ? 'paused' : 'on'
355}
356
357// Clears a pause: the session's model is now the one arne expects.
358async function resume($: any) {
359 await update($, isPaused, () => false)
360 const model: string = await $.session.model()
361 await update($, expectedAtom, () => model)
362}
363
364// arne's own config writes while they run, by key. The app never shows them to arne's config.set hook
365// (index.d.ts, $.config.set); the test kit does, so the hook skips these keys to match the app.
366const ownWrites = new Set<string>()
367async function setOnce($: any, key: string, value: unknown): Promise<{ value: unknown } | { deny: string }> {
368 ownWrites.add(key)
369 try {
370 return await $.config.set({ key, value })
371 } catch (err) {
372 return { deny: String((err as Error)?.message ?? err) }
373 } finally {
374 ownWrites.delete(key)
375 }
376}
377// With two arne plugins enabled (a stale cached copy and a dev one), the engine keys rows
378// `arne@<marketplace>.<field>`, and `arne.<field>` names no row: find arne's own row and write that.
379async function ownSet($: any, key: string, value: unknown): Promise<{ value: unknown } | { deny: string }> {
380 const r = await setOnce($, key, value)
381 if (!('deny' in r) || !r.deny.includes('no /config row')) return r
382 const field = key.slice(key.indexOf('.') + 1)
383 const rows: { key: string; provider?: unknown }[] = await $.config.list().catch(() => [])
384 const named = rows.filter(row => row.key.endsWith(`.${field}`) || row.key === field)
385 // The provider's shape isn't documented: any arne in the key or the provider object marks arne's row.
386 const mine = named.filter(row => JSON.stringify([row.key, row.provider ?? null]).includes('arne'))
387 if (mine.length > 1) return { deny: 'two copies of arne are enabled; turn one off in /plugin' }
388 if (mine.length) return mine[0]!.key !== key ? setOnce($, mine[0]!.key, value) : r
389 // The engine lists no row for this option at all: nothing can persist it, but the surfaces already hold
390 // the value, so it applies for this session instead of rolling back with an error.
391 if (!named.length) return { value }
392 // Name the rows it saw, so a toast says what the engine calls this option.
393 return { deny: `${r.deny.replace(/\)$/, '')}; rows ending .${field}: ${named.map(row => row.key).join(', ') || 'none'})` }
394}
395
396// Puts one option into `live` and onto every surface: the band (isOff), the Settings pane (liveRev), the status line.
397async function applyLive($: any, field: keyof Live, value: unknown) {
398 ;(live as Record<string, unknown>)[field] = field === 'subagents' ? subCeiling(value) : value
399 await update($, liveRev, n => n + 1)
400 if (field === 'auto') {
401 await update($, isOff, () => !value)
402 // Turning it on, from /config, the band, Settings or /arne on, is the person's go-ahead: clear any pause.
403 if (value) await resume($)
404 await showStatus($)
405 }
406}
407
408// The one writer for arne's options (17u): the surfaces change first, then the config write. A refused
409// write puts the old value back and says why.
410async function setOption($: any, field: keyof Live, value: unknown) {
411 const was = live[field]
412 await applyLive($, field, value)
413 const r = await ownSet($, `arne.${field}`, value)
414 if ('deny' in r) {
415 await applyLive($, field, was)
416 // The engine's own "<plugin>: $.config.set: " prefix: the toast already shows arne's name.
417 $.ui.toast(`Could not change ${field}: ${r.deny.replace(/^[^:]*: \$\.config\.set: /, '')}`)
418 }
419}
420
421const setAuto = ($: any, on: boolean) => setOption($, 'auto', on)
422
423// The band's Auto button: on turns off, off turns on. A pause resumes without a config write.
424async function toggleAuto($: any) {
425 const mode = await modeOf($)
426 if (mode !== 'paused') return setAuto($, mode === 'off')
427 await resume($)
428 await showStatus($)
429}
430
431// Sets one part of the pin and keeps the other; a pin with neither part left is removed. A pin ends a pause.
432// The band redraws first; a model pin then moves the real /model on the next tick, unless the pick
433// already moved it (the app's own picker). The tick also lets /arne pin run it: a /model run from inside
434// a command's own hook is refused.
435async function setPinPart($: any, part: Pin, move = true) {
436 const next: Pin = { ...((await settings($)).pin ?? {}), ...part }
437 if (next.tier === undefined) delete next.tier
438 if (next.effort === undefined) delete next.effort
439 if (next.tier || next.effort) await $.store.set(await pinKey($), next)
440 else await $.store.delete(await pinKey($))
441 await update($, pinRev, n => n + 1)
442 await resume($)
443 await showStatus($)
444 if (move && part.tier && tierOf(await $.session.model()) !== part.tier) {
445 const target = MODEL[part.tier]
446 $.clock.after(0, () => moveModel($, target))
447 }
448}
449
450// The real /model moves now, marked as arne's own so PostModelSwitch does not pause arne.
451async function moveModel($: any, target: string) {
452 const was = await read($, expectedAtom)
453 await update($, expectedAtom, () => target)
454 switching = true
455 try {
456 await $.command.run({ command: 'model', args: target })
457 arneSwitched = true
458 } catch {
459 await update($, expectedAtom, () => was)
460 $.ui.toast(`Could not switch /model to ${shortModel(target)}`)
461 } finally {
462 switching = false
463 }
464}
465
466// Good call / Wrong call on the last decision: a correction label for the classifier, written beside the log rows.
467async function feedback($: any, verdict: 'up' | 'down') {
468 const l = await read($, last)
469 if (!l) return
470 // Thanked first, so the buttons give way on the press; a failed write brings them back.
471 const was = await read($, thanked)
472 await update($, thanked, () => l.key)
473 try {
474 const old: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
475 await $.fs.write(await homePath($, LOG_FILE), old + JSON.stringify({ type: 'feedback', key: l.key, verdict, model: l.model, effort: l.effort, reason: l.reason, source: l.source }) + '\n')
476 } catch {
477 await update($, thanked, () => was)
478 $.ui.toast('Could not log that feedback')
479 }
480}
481
482async function showStatus($: any) {
483 $.ui.status(statusOf(await modeOf($), await $.session.model()))
484}
485
486// The status line: the footer already names the plugin, so no "arne" here.
487const statusOf = (mode: Mode, model: string) =>
488 mode === 'on' ? undefined : bandText({ off: mode === 'off', paused: mode === 'paused', routing: false, model, warmLeftMs: 0, C: 0 }).short
489
490// $.store is one JSON file for every session, so a pin is keyed by its chat: a new chat starts on Auto (17p).
491// ponytail: one key per pinned chat is never cleaned up; prune old keys if the store ever nears its 4 MiB cap.
492async function pinKey($: any): Promise<string> {
493 return `pin:${await $.session.id().catch(() => '')}`
494}
495
496async function settings($: any): Promise<Settings> {
497 return {
498 enabled: live.auto,
499 pin: ((await $.store.get(await pinKey($))) ?? null) as Pin | null,
500 backend: live.backend,
501 }
502}
503
504// laya-serve is started once per session when it is not already answering.
505// Sessions share the one server on 127.0.0.1:8000; a second start just fails to bind.
506let layaUp = false
507let layaStarting = false
508
509async function layaHealthy($: any): Promise<boolean> {
510 try {
511 const r = await $.http.fetch(`${LAYA_URL}/health`, { method: 'GET' })
512 return r.ok
513 } catch {
514 return false
515 }
516}
517
518// Not awaited: a spawn streams for as long as the server runs. Without laya-serve
519// on PATH the spawn errors and the chain falls back to haiku.
520async function startLaya($: any) {
521 if (layaStarting) return
522 layaStarting = true
523 try {
524 for await (const _ of $.process.spawn({ argv: ['laya-serve'] })) {
525 // output is not read; the server logs go to its own console
526 }
527 } catch {
528 // laya not installed, or the server exited: the why line says Haiku judged until it answers
529 $.ui.toast('laya-serve did not start (not on PATH?). Haiku judges until it does.')
530 } finally {
531 layaStarting = false
532 }
533}
534
535// laya -> haiku -> prev -> default. The chain falls through silently.
536// What the project is, so Haiku judges "how hard is this prompt" for this codebase, not in a vacuum.
537// Read once per load from CLAUDE.md, else README.md, in the project folder. ponytail: stale until reload.
538let projectCtx: Promise<string> | undefined
539function projectContext($: any): Promise<string> {
540 projectCtx ??= (async () => {
541 for (const f of ['CLAUDE.md', 'README.md']) {
542 const t: string = await $.fs.read(f).catch(() => '')
543 if (t.trim()) return `\n\nProject context (${f}, for judging difficulty; do not answer it):\n${t.slice(0, 2500)}`
544 }
545 return ''
546 })()
547 return projectCtx
548}
549
550// The judge's view of the task so far (17p), so "continue", "do it" or "B" are read against the work in hand.
551// Built from the transcript each time: the first prompt, the last 10 messages from both sides (17v), the
552// last verdict, files touched, the context size. At most CARD_MAX characters (~2k tokens). '' when the
553// transcript cannot be read.
554const CARD_MAX = 8000
555async function taskCard($: any, c: Cache | null): Promise<string> {
556 const msgs: any = await $.session.messages().catch(() => [])
557 if (!Array.isArray(msgs) || msgs.length === 0) return ''
558 // Rows with words only: a user row that carries tool results is the engine's, not the person's.
559 const said = msgs.filter((m: any) => m.text?.trim() && !m.toolResults?.length)
560 const prompts = said.filter((m: any) => m.role === 'user').map((m: any) => m.text.trim())
561 const recent = said.slice(-KEEP_AFTER_COMPACT)
562 const files = new Set<string>()
563 for (const m of msgs) for (const u of m.toolUses ?? []) {
564 const f = u.input?.file_path ?? u.input?.notebook_path ?? u.input?.path
565 if (typeof f === 'string') files.add(f)
566 }
567 const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
568 const lines = [
569 prompts[0] && `First prompt: ${clip(prompts[0], 1200)}`,
570 recent.length > 1 && `Last messages:\n${recent.map((m: any) => `- ${m.role}: ${clip(m.text.trim(), 500)}`).join('\n')}`,
571 // No previous verdict here: the judge anchored on it and kept repeating Opus ("aight go on" → Opus).
572 files.size && `Files touched: ${[...files].slice(-15).join(', ')}`,
573 c && `Context: ${Math.round(c.C / 1000)}k tokens`,
574 ].filter(Boolean)
575 return lines.length ? clip(`Conversation so far:\n${lines.join('\n')}`, CARD_MAX) : ''
576}
577
578function backendsOf($: any, backend: string, card = ''): Backend[] {
579 const haiku: Backend = {
580 name: 'haiku',
581 ask: async text => {
582 const prompt = card ? `${card}\n\nNew prompt to judge:\n${text.slice(0, 4000)}` : text.slice(0, 4000)
583 const r = await $.model.complete(
584 { model: 'haiku', prompt, system: SYSTEM + (await projectContext($)), effort: 'low' },
585 { timeoutMs: 3000 },
586 )
587 return r.isAnswered ? parseVerdict(r.text) : undefined
588 },
589 }
590 // System One (17s): laya-serve and Jev take the same request with the task card in its state.
591 // No answer within 1.5 s falls through to Haiku.
592 const systemOne = async (url: string, headers: Record<string, string>, text: string, model?: string) => {
593 const r = await Promise.race([
594 $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: systemOneBody(text, card, model) }),
595 $.clock.sleep(SYSTEM_ONE_TIMEOUT_MS).then(() => undefined),
596 ])
597 return r?.ok ? JSON.parse(r.text) : undefined
598 }
599 const laya: Backend = {
600 name: 'laya',
601 ask: async text => systemOneVerdict((await systemOne(`${LAYA_URL}/v1/systemone`, {}, text))?.answers, 'laya'),
602 }
603 const jev: Backend = {
604 name: 'jev',
605 ask: async text => {
606 // Never stored, logged or shown. No key, no request.
607 const key = await typesafeKey($)
608 if (!key) return undefined
609 const j = await systemOne(JEV_URL, { authorization: `Bearer ${key}` }, text, JEV_MODEL)
610 // Jev's prices are not published, so Stats counts its tokens on a row of their own.
611 const u = j?.usage
612 if (u) await appendLog($, { type: 'usage', at: await $.clock.now(), kind: 'jev', model: JEV_MODEL, input: u.input_tokens ?? 0, read: 0, write: 0, out: u.output_tokens ?? 0 })
613 return systemOneVerdict(j?.answers, 'jev')
614 },
615 }
616 return backend === 'laya' ? [laya, haiku] : backend === 'jev' ? [jev, haiku] : backend === 'haiku' ? [haiku] : []
617}
618
619// The main loop's decision for one prompt, inside the floor and ceiling (pins included).
620async function decideMain($: any, text: string): Promise<Decision | undefined> {
621 const s = await settings($)
622 if (!s.enabled) return undefined
623 if (s.backend === 'laya' && !layaUp) {
624 layaUp = await layaHealthy($)
625 if (!layaUp) void startLaya($)
626 }
627 // The band says so when Laya was picked but is not answering: the chain judges with Haiku instead.
628 await update($, layaDown, () => s.backend === 'laya' && !layaUp)
629 const jevKeyless = s.backend === 'jev' && !(await typesafeKey($))
630 await update($, jevNoKey, () => jevKeyless)
631 // Any pin pins both parts and skips routing (17p): the part it leaves out stays what the session has now.
632 // A pin is the person's own pick, so it skips the floor and ceiling (how xhigh, max or a capped tier are reached).
633 const pin = s.pin ?? undefined
634 const c: Cache | null = await read($, cache)
635 let d: Decision
636 if (pin?.tier || pin?.effort) {
637 const effort = pin.effort ?? (((await read($, appEffort)) ?? 'medium') as Decision['effort'])
638 d = { tier: pin.tier ?? tierOf(await $.session.model()) ?? 'M', effort, reason: 'pinned', source: 'pin', keepEffort: !pin.effort }
639 } else {
640 const card = await taskCard($, c)
641 const api: any = await $.session.messages({ as: 'api' }).catch(() => [])
642 const failed = failedAttempt(Array.isArray(api) ? api : [], text)
643 failures = failed ? failures + 1 : 0
644 // A failure under the limit retries on the same model; the one that reaches it goes up.
645 const retry = failed && prev && failures < live.attempts
646 let r = retry
647 ? ({ ...prev, reason: `attempt ${failures + 1} of ${live.attempts} at this tier`, source: 'prev' } as Decision)
648 : await route(text, { prev, failed, paused: await read($, isPaused), backends: backendsOf($, s.backend, card) })
649 if (!r) return undefined
650 if (r.source === 'escalate') {
651 hold = { tier: r.tier, left: HOLD_TURNS }
652 failures = 0
653 }
654 else if (hold && hold.left-- > 0 && RANK[r.tier] < RANK[hold.tier]) r = { ...r, tier: hold.tier, reason: `${r.reason}; held after a failure` }
655 d = { ...r, tier: clampTier(r.tier, live.modelFloor, r.source === 'escalate' ? 'L' : live.modelCeiling), effort: clampEffort(r.effort, live.effortFloor, live.effortCeiling) }
656 }
657 // Never route to a model whose window is smaller than the chat: a bigger tier takes the prompt instead.
658 // This is the one thing that moves a pin, and the band then says why ("the chat needs a bigger window").
659 const tier = c ? fitTier(d.tier, c.C) : d.tier
660 const sized = tier === d.tier ? d : { ...d, tier, reason: 'window' }
661 prev = sized
662 return sized
663}
664
665// The model the real /model should move to before this prompt, or undefined to stay.
666// A pin always moves; otherwise only where planSwitch says moving pays (a cache boundary, or worth it).
667async function switchTarget($: any, d: Decision): Promise<string | undefined> {
668 const chat = tierOf(await $.session.model())
669 if (!chat || d.tier === chat) return undefined
670 // An escalation moves even on a warm cache: a cold write costs less than failing again.
671 if (d.source === 'pin' || d.source === 'escalate') return MODEL[d.tier]
672 const c: Cache | null = await read($, cache)
673 const plan = planSwitch({ decided: d.tier, chat, C: c?.C ?? 0, warmUntil: c?.warmUntil ?? {}, now: await $.clock.now() })
674 return plan.why === 'sticky' ? undefined : MODEL[d.tier]
675}
676
677// The effort the native /effort should move to before this prompt, or undefined to stay. Haiku takes
678// none, and while effortless is on the effort is its to set.
679// Effort is never typed as /effort any more: turn.step rewrites it silently on every request, so the
680// prompt is not dropped and re-sent for it. ponytail: the app's effort control keeps showing its old value.
681async function effortTarget(_$: any, _d: Decision, _model: string): Promise<Decision['effort'] | undefined> {
682 return undefined
683}
684
685// Runs the real /model and /effort, then sends the dropped prompt once, whether or not they worked.
686async function switchThenSend($: any, target: string | undefined, effort: Decision['effort'] | undefined, text: string) {
687 // The person may have picked a model while this waited: theirs stands.
688 if ((await modeOf($)) === 'on' && effort) {
689 try {
690 await $.command.run({ command: 'effort', args: effort })
691 await update($, appEffort, () => effort)
692 } catch {
693 $.ui.toast(`Could not set /effort to ${effort}`)
694 }
695 }
696 if ((await modeOf($)) === 'on' && target) {
697 const was = await read($, expectedAtom)
698 await update($, expectedAtom, () => target)
699 switching = true
700 try {
701 await $.command.run({ command: 'model', args: target })
702 arneSwitched = true
703 } catch {
704 await update($, expectedAtom, () => was)
705 planned.delete(text) // decided for a model it is not on: turn.start decides again
706 $.ui.toast(`Could not switch /model to ${shortModel(target)}; sending on the current model`)
707 } finally {
708 switching = false
709 }
710 }
711 await sendAsUser($, text)
712}
713
714// A prompt arne sends for the person (a released hold, a continue) gets the typed prompt's treatment
715// here, since prompt.submit will not see it: decided, and the real /model moved first when it should.
716async function routeAndSend($: any, text: string) {
717 const d = await decideMain($, text)
718 if (d) planned.set(text, d)
719 const target = d && (await switchTarget($, d))
720 const effort = d && (await effortTarget($, d, target ?? (await $.session.model())))
721 if (target || effort) await switchThenSend($, target, effort, text)
722 else await sendAsUser($, text)
723}
724
725// $.fs has no append: read, then write the whole file. ponytail: O(n) per turn, fine below ~10k rows.
726async function appendLog($: any, row: (Row & { text: string; agent?: string; decided?: string; from?: string; to?: string; at?: number }) | UsageRow) {
727 try {
728 const old: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
729 await $.fs.write(await homePath($, LOG_FILE), old + JSON.stringify(row) + '\n')
730 } catch {
731 // no log this time
732 }
733}
734
735// Stats for one period, from the log's usage rows (17q). ponytail: reads the whole log per call; fine
736// below ~10k rows, keep running totals in the store if it ever gets slow.
737async function statsFor($: any, period: Period) {
738 const text: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
739 return summarize(parseUsage(text), periodStart(period, await $.clock.now()))
740}
741
742// Opens the band's panel on a view. Stats reads all three periods from one parse here, so a period
743// press only redraws (17u).
744async function openView($: any, view: View) {
745 if (view === 'stats') {
746 const text: string = await $.fs.read(await homePath($, LOG_FILE)).catch(() => '')
747 const rows = parseUsage(text)
748 const now: number = await $.clock.now()
749 await update($, statsData, () => Object.fromEntries(PERIODS.map(([p]) => [p, summarize(rows, periodStart(p, now))])))
750 }
751 await update($, panelView, () => view)
752 await update($, whyOpen, () => true)
753}
754
755// A view's header: "‹ Back" to the main panel, then the view's title.
756function viewHead($: any, { Box, Button, Text }: any, title: string) {
757 return (
758 <Box key="view-head" flexDirection="row" alignItems="center" gap={1}>
759 <Button key="back" plain label="‹ Back" onPress={() => update($, panelView, () => 'main')} />
760 <Text weight="semibold">{title}</Text>
761 </Box>
762 )
763}
764
765// A picked segment: the raised fill alone is faint (1.35:1 on the dark card), so a ✓ marks it too.
766const picked = (label: string, on: boolean) => (on ? `✓ ${label}` : label)
767const SETTINGS_LABEL_W = 16 // "Strongest model" is the longest label
768
769// The panel's Settings view: a label and a segmented control per row. A press shows the new value at
770// once, then writes it through $.config.set (setOption), so the panel and /config never disagree.
771function settingsView($: any, { Box, Button, Text }: any, gui: boolean, cols: number, needsKey = false) {
772 // Every row is the same: the label in a fixed column, the options beside it. A row with many options
773 // wraps under its own first option, never to the far edge.
774 const choice = (label: unknown, key: keyof Live, options: { value: unknown; label: string }[]) => {
775 const set = (value: unknown) => setOption($, key, value)
776 return (
777 <Box key={key} flexDirection="row" alignItems="flex-start" columnGap={2}>
778 <Box width={SETTINGS_LABEL_W} flexShrink={0}>
779 <Text>{label}</Text>
780 </Box>
781 <Box flexDirection="row" flexWrap="wrap" flexGrow={1} flexShrink={1} columnGap={1} rowGap={1}>
782 {options.map(o =>
783 o.value === live[key] ? (
784 <Button key={`${key}-${String(o.value)}`} variant="secondary" label={picked(o.label, true)} onPress={() => set(o.value)} />
785 ) : (
786 <Button key={`${key}-${String(o.value)}`} plain label={o.label} onPress={() => set(o.value)} />
787 ),
788 )}
789 </Box>
790 </Box>
791 )
792 }
793 // Each group: a caption over a bordered card of rows, as in the design's inset lists.
794 const card = (title: string, rows: unknown[], note?: unknown) => (
795 <Box key={`card-${title}`} flexDirection="column" flexGrow={1}>
796 <Text size="footnote" dimColor>{title}</Text>
797 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={CARD.border} paddingX={1} rowGap={gui ? 1 : 0}>
798 {rows as any}
799 </Box>
800 {note as any}
801 </Box>
802 )
803 const MODELS = [{ value: 'haiku', label: 'Haiku' }, { value: 'sonnet', label: 'Sonnet' }, { value: 'opus', label: 'Opus' }]
804 // The same names as the band: full from 72 columns, short below.
805 const effortNames = cols < 72 ? EFFORT_LABEL_SHORT : EFFORT_LABEL
806 const EFFORTS = (['low', 'medium', 'high', 'xhigh', 'max'] as const).map(v => ({ value: v, label: effortNames[v] ?? v }))
807 return (
808 <Box key="settings-view" flexDirection="column" gap={1}>
809 {viewHead($, { Box, Button, Text }, 'Settings')}
810 {/* The band is at most half the window and scrolls past that: from 90 columns the cards sit side by side. */}
811 <Box key="settings-cards" flexDirection={cols >= 90 ? 'row' : 'column'} gap={cols >= 90 ? 2 : 1}>
812 {card('Routing', [
813 choice('Auto routing', 'auto', [{ value: true, label: 'On' }, { value: false, label: 'Off' }]),
814 choice('Judge', 'backend', [
815 { value: 'heuristic', label: 'Rules' },
816 { value: 'haiku', label: 'Haiku' },
817 { value: 'laya', label: 'Laya' },
818 { value: 'jev', label: 'Jev' },
819 ]),
820 choice('Subagents', 'subagents', [
821 { value: 'auto', label: 'Auto' },
822 { value: 'haiku', label: 'Haiku' },
823 { value: 'sonnet', label: 'Sonnet' },
824 { value: 'opus', label: 'Opus' },
825 { value: 'off', label: 'Off' },
826 ]),
827 ], (
828 // The judge's note sits under the card it describes. Jev's while it is the judge.
829 <Box key="routing-notes" flexDirection="column">
830 <Text size="footnote" dimColor>
831 {live.backend === 'jev' ? 'Jev is hosted: your prompts leave this machine.' : 'Laya runs on this machine and learns from your ratings.'}
832 </Text>
833 {needsKey && (
834 // No masked Input exists, so the key is never typed here: the button points to /config's secure field.
835 <Box key="jev-key" flexDirection="row" flexWrap="wrap" columnGap={2}>
836 <Text size="footnote" dimColor>Jev needs a TypeSafe key.</Text>
837 <Button key="add-key" plain label="Add key…" onPress={() => $.ui.toast(ADD_KEY_HINT)} />
838 </Box>
839 )}
840 </Box>
841 ))}
842 {card('Limits', [
843 choice('Cheapest model', 'modelFloor', MODELS),
844 choice('Strongest model', 'modelCeiling', MODELS),
845 choice('Lowest effort', 'effortFloor', EFFORTS),
846 choice('Highest effort', 'effortCeiling', EFFORTS),
847 choice('Handoff', 'handoff', [{ value: 'newchat', label: 'New chat' }, { value: 'clear', label: 'Clear' }]),
848 ])}
849 </Box>
850 <Box key="settings-notes" flexDirection="column">
851 <Text size="footnote" dimColor>{`arne ${VERSION}. Subagents are experimental. Also in /config.`}</Text>
852 </Box>
853 </Box>
854 )
855}
856
857const STATS_EMPTY: Record<Period, string> = { today: 'No turns today yet.', week: 'No turns in the last 7 days yet.', all: 'No turns yet.' }
858
859// The panel's Stats view (17q): what arne saved against running every turn on Opus at medium.
860// An estimate, said so. Back returns to the main panel.
861async function statsView($: any, { Box, Button, Text }: any, cols: number, gui: boolean) {
862 const period = (await read($, statsPeriod)) as Period
863 // Parsed once at open (openView); a panel drawn without that open (a reload) parses here.
864 const st = (await read($, statsData))?.[period] ?? (await statsFor($, period))
865 const usd = (v: number) => `$${v.toFixed(2)}`
866 // Bars as text, so they follow the theme: one accent for arne, dim for the baselines.
867 const room = Math.max(10, Math.min(40, cols - 24))
868 const max = Math.max(st.spent, st.opus, 1e-9)
869 // ▆ not █: a full block touches the label's descenders above it.
870 const bar = (v: number) => '▆'.repeat(Math.max(v > 0 ? 1 : 0, Math.round((v / max) * room)))
871 const tile = (name: string, base: number) => {
872 const t = savedText(st.spent, base)
873 return (
874 <Box key={`tile-${name}`} flexDirection="column" borderStyle="round" borderColor={CARD.border} paddingX={1} flexGrow={1}>
875 <Text size="footnote" dimColor>{`Against ${name}`}</Text>
876 <Text size="title" weight="semibold" tabular>{t.headline}</Text>
877 <Text size="footnote" dimColor tabular>{t.sub}</Text>
878 </Box>
879 )
880 }
881 return (
882 <Box key="stats-view" flexDirection="column" gap={1}>
883 <Box flexDirection="row" flexWrap="wrap" alignItems="center" justifyContent="space-between" columnGap={2} rowGap={1}>
884 {viewHead($, { Box, Button, Text }, 'Stats')}
885 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
886 {PERIODS.map(([p, label]) =>
887 p === period ? (
888 <Button key={`period-${p}`} variant="secondary" label={picked(label, true)} onPress={() => update($, statsPeriod, () => p)} />
889 ) : (
890 <Button key={`period-${p}`} plain label={label} onPress={() => update($, statsPeriod, () => p)} />
891 ),
892 )}
893 </Box>
894 </Box>
895 {st.turns === 0 && st.lines.length === 0 ? (
896 <Box flexDirection="column">
897 <Text>{STATS_EMPTY[period]}</Text>
898 <Text size="footnote" dimColor>Once there are, this shows what arne spent next to the same turns on Opus at medium effort. It is an estimate.</Text>
899 </Box>
900 ) : (
901 <Box flexDirection="column" gap={1}>
902 <Box flexDirection={cols < 60 ? 'column' : 'row'} gap={1}>
903 {tile('Opus · medium', st.opus)}
904 </Box>
905 <Box flexDirection="column" borderStyle="round" borderColor={CARD.border} paddingX={1} rowGap={gui ? 1 : 0}>
906 {([['With arne', st.spent], ['Opus · medium', st.opus]] as const).map(([label, v]) => (
907 <Box key={`bar-${label}`} flexDirection="column">
908 <Box flexDirection="row" justifyContent="space-between">
909 <Text dimColor={label !== 'With arne'}>{label}</Text>
910 <Text tabular>{usd(v)}</Text>
911 </Box>
912 {label === 'With arne' ? <Text color="claude">{bar(v)}</Text> : <Text dimColor>{bar(v)}</Text>}
913 </Box>
914 ))}
915 </Box>
916 <Box flexDirection="column">
917 <Text size="footnote" dimColor>Where it went</Text>
918 <Box flexDirection="column" borderStyle="round" borderColor={CARD.border} paddingX={1}>
919 {st.lines.map(l => (
920 <Box key={`line-${l.label}`} flexDirection="row" columnGap={2}>
921 <Box flexGrow={1}>
922 <Text>{l.label}</Text>
923 </Box>
924 <Text dimColor tabular>{l.label === 'Switches and summaries' ? `${l.count} ${l.count === 1 ? 'time' : 'times'}` : `${l.count} ${l.tokens === undefined ? 'turn' : 'call'}${l.count === 1 ? '' : 's'}`}</Text>
925 <Text tabular>{l.tokens === undefined ? usd(l.cost) : `${l.tokens.toLocaleString('en-US')} tokens`}</Text>
926 </Box>
927 ))}
928 </Box>
929 </Box>
930 <Text size="footnote" dimColor>An estimate: each turn's real tokens, priced again as if every turn ran on Opus at medium effort. Model switches and summaries count against arne.</Text>
931 </Box>
932 )}
933 </Box>
934 )
935}
936
937// Drives the band's countdown while the cache is warm. A reload cancels every timer and session.start does
938// not run again, so a response landing and a render of a warm band arm it too; `tick` is module state, so a
939// reload arms it once more. A cold cache stays cold, so the tick stops there: no write every second while
940// the chat sits idle. ponytail: a 1s state write while warm.
941let tick: { cancel: () => void } | undefined
942function armTick($: any) {
943 if (tick) return
944 try {
945 tick = $.clock.every(1000, async () => {
946 const c = await read($, cache)
947 const now: number = await $.clock.now()
948 await update($, clockNow, () => now) // the last one redraws the band as the cache turns cold
949 if (c === null || (c.warmUntil[c.model] ?? 0) <= now) {
950 tick?.cancel()
951 tick = undefined
952 }
953 })
954 } catch {
955 tick = undefined
956 }
957}
958
959// A main-loop response landed: the answering model's cache is warm for one more TTL.
960async function cacheTouched($: any, model: string, usage: unknown) {
961 const tier = tierOf(model)
962 // No usage means no response landed (an interrupted or failed step): the cache and its size are unchanged.
963 if (!tier || !usage) return
964 const now: number = await $.clock.now()
965 const since = lastResponseAt === undefined ? undefined : now - lastResponseAt
966 lastResponseAt = now
967 await update($, cache, (c: Cache | null): Cache => {
968 const ttl = nextTtl(c?.ttl ?? '5m', usage, since)
969 return { ttl, C: contextOf(usage), model: tier, warmUntil: { ...c?.warmUntil, [tier]: now + CACHE_TTL[ttl] } }
970 })
971 await update($, clockNow, () => now)
972 armTick($)
973}
974
975export const register: Register = (on, options) => {
976 live = { ...DEFAULTS, ...(options as Partial<Live>) }
977 live.subagents = subCeiling(live.subagents)
978 optionKey = String((options as Record<string, unknown>).typesafe_key ?? '')
979
980 // A write to an arne.* row from /config or another plugin lands here. arne's own writes never do: the
981 // app skips a plugin's own hooks, and ownWrites makes the test kit skip them the same way (17u).
982 on('config.set', async ($, e, next) => {
983 if (ownWrites.has(e.key)) return next(e)
984 const r = await next(e)
985 // `arne.<field>`, or `arne@<marketplace>.<field>` while another arne is enabled too.
986 const field = /^arne(@[^.]+)?\./.test(e.key) ? (e.key.slice(e.key.indexOf('.') + 1) as keyof Live) : undefined
987 if (field && FIELDS.includes(field) && !('deny' in r)) await applyLive($, field, r.value)
988 if ((field as string) === 'typesafe_key' && !('deny' in r)) {
989 optionKey = String(r.value ?? '')
990 await update($, liveRev, n => n + 1)
991 }
992 return r
993 }).catch(($, e, next) => next(e))
994
995 on('session.start', async ($, e, next) => {
996 // Busy flags are atoms, so they outlive a reload or a quit that cut their `finally` short: a new session clears them.
997 await update($, compacting, () => false)
998 await update($, compactSendingAtom, () => false)
999 await update($, disabling, () => false)
1000 await update($, subCount, () => null)
1001 await $.command.register({
1002 name: 'arne',
1003 description: 'Auto model and effort routing: on, off, pin, unpin, handoff, subagents, why, log, stats, backend, debug',
1004 })
1005 const eff = await effortlessOn($)
1006 await update($, effortless, () => eff)
1007 await update($, isOff, () => !live.auto)
1008 const first = await $.session.model()
1009 // The app's picker shows the session's model at start. Re-read each start, so a stale pick does not outlive it.
1010 await update($, appPicker, () => first)
1011 armTick($)
1012 return next(e)
1013 })
1014
1015 // arne's own switch lands here as source "command" too, so it is told apart by its tier: arne sets
1016 // `expected` before it runs /model, and a repeat event for the same switch still matches.
1017 on('classic.PostModelSwitch', async ($, e, next) => {
1018 const r = await next(e)
1019 // ponytail: a manual pick of the tier arne already expects reads as arne's own and does not pause;
1020 // tell them apart by origin if command.run ever carries one for /model.
1021 const expected = await read($, expectedAtom)
1022 const isOwn = expected !== null && tierOf(e.to_model) === tierOf(expected)
1023 // A pick in the desktop app's picker is an input: it pins that tier, not a pause. Auto in the band's panel unpins.
1024 if (e.source === 'sdk' && !switching) {
1025 await update($, appPicker, () => e.to_model)
1026 const tier = tierOf(e.to_model)
1027 if (tier) await setPinPart($, { tier }, false)
1028 } else {
1029 const byHand = e.source === 'command' || e.source === 'picker'
1030 if (!isOwn && byHand && !(await read($, isOff))) await update($, isPaused, () => true)
1031 }
1032 await update($, expectedAtom, () => e.to_model)
1033 await update($, sessionModel, () => e.to_model)
1034 await showStatus($)
1035 return r
1036 }).catch(($, e, next) => next(e))
1037
1038 on('turn.complete', async ($, e, next) => {
1039 const r = await next(e)
1040 const isMain = e.agentId === undefined
1041 if (isMain) lastReason = e.reason
1042 // Each turn's real usage goes to the log, for Stats across reloads (17q). A turn with none is not logged.
1043 const row = usageRow(isMain ? 'turn' : 'agent', await $.clock.now(), r.usage ?? e.usage, undefined, isMain && arneSwitched)
1044 if (isMain) arneSwitched = false
1045 if (row) await appendLog($, row)
1046 return r
1047 })
1048
1049 // Haiku writes the summary for every compaction. If Haiku fails, the engine's own compaction stands.
1050 on('session.compact', async ($, e, next) => {
1051 if (e.trigger === 'precompute' || e.messages.length < 4) return next(e)
1052 try {
1053 const r = await $.model.complete(
1054 {
1055 model: live.compactModel,
1056 prompt: summaryPrompt(e.messages),
1057 system: SUMMARY_SYSTEM + (e.instructions ? ` Keep: ${e.instructions}` : ''),
1058 effort: 'low',
1059 },
1060 { timeoutMs: 120_000 },
1061 )
1062 if (!r.isAnswered || !r.text) return next(e)
1063 const sum = usageRow('summary', await $.clock.now(), r.usage, MODEL[TIER_OF[live.compactModel] ?? 'S'])
1064 if (sum) await appendLog($, sum)
1065 const kept = e.messages.slice(-KEEP_AFTER_COMPACT) // word for word after the summary (17v)
1066 return {
1067 messages: [
1068 { role: 'user', text: `Summary of the conversation so far (written by Haiku for arne):\n\n${r.text}`, toolUses: [] },
1069 ...kept,
1070 ],
1071 }
1072 } catch {
1073 return next(e)
1074 }
1075 }).catch(($, e, next) => next(e))
1076
1077 // A plain /compact (or Compact with nothing held) picks the work back up, once, if it stopped mid-task.
1078 // Compact & send skips this: its own held prompt is the one message that goes.
1079 on('classic.PostCompact', async ($, e, next) => {
1080 // The cache numbers are the old chat's, and the atom outlives a reload: a stale big, expired cache
1081 // made the first prompt after a compact read as a cold 100k chat and get held (17v). No cache until
1082 // the next response lands (cacheTouched); planSwitch then reads the chat as a boundary.
1083 await update($, cache, () => null)
1084 const r = await next(e)
1085 if (e.trigger === 'manual' && !(await read($, compactSendingAtom)) && live.continueAfterCompact && !(await read($, held)) && midTask(lastReason, e.compact_summary)) {
1086 $.clock.after(0, () => routeAndSend($, CONTINUE_TEXT))
1087 }
1088 return r
1089 }).catch(($, e, next) => next(e))
1090
1091 // A typed prompt is decided here, before its turn exists. On a cold, big chat it is held (17a). When
1092 // the decision moves the model, the prompt is dropped, the real /model runs, and the prompt is sent
1093 // again (17c): Claude's own picker then always shows the model the request runs on.
1094 on('prompt.submit', async ($, e, next) => {
1095 const typed = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
1096 // Left alone: commands, prompts typed into a running turn, and attachments (a resubmit would lose them).
1097 if (!typed || e.command || e.isMeta || e.turnId !== undefined || e.attachments?.length) return next(e)
1098 if ((await modeOf($)) !== 'on') return next(e)
1099 if (!(await read($, held)) && (await coldOffer($, await $.clock.now()))) {
1100 await update($, held, () => e.text)
1101 return { drop: 'arne is holding your message: the cache is cold. Pick Summarize, then send or Send as is above the prompt.' }
1102 }
1103 await update($, routing, () => true)
1104 const d = await decideMain($, e.text).finally(() => update($, routing, () => false))
1105 if (d) planned.set(e.text, d)
1106 const target = d && (await switchTarget($, d))
1107 const effort = d && (await effortTarget($, d, target ?? (await $.session.model())))
1108 if (!target && !effort) return next(e)
1109 $.clock.after(0, () => switchThenSend($, target, effort, e.text))
1110 const to = [target && shortModel(target), effort].filter(Boolean).join(' · ')
1111 return { drop: `arne: switching to ${to} first; your message follows.` }
1112 }).catch(($, e, next) => next(e))
1113
1114 on('command.run', { command: 'arne' }, async ($, e) => {
1115 const [sub, ...rest] = e.args.trim().split(/\s+/)
1116 const s = await settings($)
1117
1118 switch (sub) {
1119 case 'on':
1120 await setAuto($, true)
1121 return { text: live.auto ? 'arne on.' : 'arne stays off: the setting was refused.' }
1122 case 'off':
1123 await setAuto($, false)
1124 return { text: live.auto ? 'arne stays on: the setting was refused.' : 'arne off. Requests use the session model.' }
1125 case 'settings':
1126 await openView($, 'settings')
1127 return { text: 'arne settings are open in the band above the prompt. Changes there match /config.' }
1128 case 'pin': {
1129 const tier = TIER_OF[rest[0]]
1130 if (!tier) return { text: 'Usage: /arne pin haiku|sonnet|opus [low|medium|high|xhigh|max]' }
1131 if (rest[1] && !(rest[1] in EFFORT_RANK)) return { text: 'Efforts: low, medium, high, xhigh, max.' }
1132 const effort = (rest[1] ?? 'medium') as Decision['effort']
1133 // A pin is the person's pick made through arne: it ends a pause and moves /model (on the next tick).
1134 await setPinPart($, { tier, effort })
1135 return { text: `arne pinned to ${rest[0]} · ${effort}.` }
1136 }
1137 case 'unpin':
1138 await setPinPart($, { tier: undefined, effort: undefined })
1139 return { text: 'arne unpinned.' }
1140 case 'backend': {
1141 const b = rest[0]
1142 if (b !== 'haiku' && b !== 'heuristic' && b !== 'laya' && b !== 'jev') return { text: 'Backends: haiku, heuristic, laya, jev.' }
1143 await setOption($, 'backend', b)
1144 if (live.backend !== b) return { text: `arne backend stays ${live.backend}: the setting was refused.` }
1145 if (b === 'laya') {
1146 return {
1147 text: 'arne backend: laya. It needs laya-serve on PATH (Python 3.10+, the laya package, torch; git clone https://github.com/NandhaKishorM/laya and pip install -e .). Until it answers, turns fall back to haiku.',
1148 }
1149 }
1150 if (b === 'jev') {
1151 return {
1152 text: 'arne backend: jev. Jev is TypeSafe’s hosted judge: your prompts and the task card leave this machine. Set the key in /config (arne → TypeSafe API key) or TYPESAFE_API_KEY; keys are at console.typesafe.ai/keys. Until then, turns fall back to haiku.',
1153 }
1154 }
1155 return { text: `arne backend: ${b}.` }
1156 }
1157 case 'stats': {
1158 const lines = await Promise.all(
1159 PERIODS.map(async ([p, label]) => {
1160 const st = await statsFor($, p)
1161 const opus = savedText(st.spent, st.opus)
1162 return `${label}: $${st.spent.toFixed(2)} spent over ${st.turns} turns. Against Opus · medium: ${opus.headline.toLowerCase()} (${opus.sub}).`
1163 }),
1164 )
1165 await openView($, 'stats')
1166 return { text: `arne stats (an estimate: each turn's real tokens, priced again on Opus):\n${lines.join('\n')}` }
1167 }
1168 case 'subagents': {
1169 // `on` was the old switch: it reads as the default ceiling.
1170 const v = rest[0] === 'on' ? 'auto' : rest[0]
1171 if (v !== 'off' && v !== 'auto' && !TIER_OF[v ?? '']) return { text: 'Usage: /arne subagents auto|haiku|sonnet|opus|off' }
1172 await setOption($, 'subagents', v)
1173 return {
1174 text: live.subagents === 'off' ? 'arne leaves subagents on the model they ask for.' : live.subagents === 'auto' ? "arne picks each subagent's model, no ceiling." : `arne routes subagents, ${shortModel(MODEL[TIER_OF[live.subagents] ?? 'M'])} at most.`,
1175 }
1176 }
1177 case 'handoff': {
1178 const named: Tier | undefined = TIER_OF[rest[0] ?? '']
1179 if (rest[0] && !named) return { text: 'Usage: /arne handoff [haiku|sonnet|opus]' }
1180 const tier = named ?? (await handoffTier($))
1181 // Next tick: /clear and /model are refused from inside a command's own hook.
1182 $.clock.after(0, () => handoff($, tier))
1183 return { text: `arne: handing off to ${shortModel(MODEL[tier])}…` }
1184 }
1185 case 'why': {
1186 const l = await read($, last)
1187 return { text: l ? `${l.model} · ${l.effort}: ${l.reason} (${l.source})` : 'No decision yet.' }
1188 }
1189 case 'log':
1190 await $.ui.open({ id: PANE, title: 'arne log' })
1191 return { text: 'arne log opened.' }
1192 case 'show': {
1193 // Draws the band in one state for a few seconds, to check each look without a real trigger.
1194 const state = rest[0] as BandState
1195 if (rest[0] === 'live') {
1196 await update($, shown, () => null)
1197 return { text: 'Band shows the live state.' }
1198 }
1199 if (!STATES.includes(state)) return { text: `Usage: /arne show live|${STATES.join('|')}` }
1200 await update($, shown, () => ({ state, until: 0 }))hooks/backends.ts 85 lines1import type { Tier, Effort, Verdict } from './route'
2
3const TIERS: Tier[] = ['S', 'M', 'L']
4// ponytail: the judge never picks above high. xhigh and max are reached only by a pin or a floor, so a
5// judge's answer cannot raise the cost of a turn past what the person allowed.
6const EFFORTS: Effort[] = ['low', 'medium', 'high']
7
8export const SYSTEM = `You route a coding-assistant prompt to a model tier. Reply with JSON only:
9{"tier":"S|M|L","effort":"low|medium|high","reason":"<6 words>"}
10S = trivial lookup or edit, or a search, read or list (a subagent's brief to find or map code is S).
11M = normal coding, including multi-step or multi-file chores: edits, re-renders, commits, running tools.
12L = only when the hard part is the thinking: architecture, a race or subtle bug, a security review.
13Many steps is not L: the model hands searches and grunt work to cheap subagents. When unsure, pick M.
14Effort is high when the prompt asks to think hard or be careful, or touches security.`
15
16// Pure: a model reply to a verdict, or undefined when it is not one.
17export function parseVerdict(text: string): Verdict | undefined {
18 const m = text.match(/\{[\s\S]*\}/)
19 if (!m) return undefined
20 try {
21 const v = JSON.parse(m[0])
22 if (!TIERS.includes(v.tier) || !EFFORTS.includes(v.effort)) return undefined
23 return { tier: v.tier, effort: v.effort, reason: String(v.reason ?? '').slice(0, 80) }
24 } catch {
25 return undefined
26 }
27}
28
29// System One (17s): laya-serve here and Jev at TypeSafe speak the same POST /v1/systemone.
30export const LAYA_URL = 'http://127.0.0.1:8000'
31export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
32export const JEV_MODEL = 'jev-latest'
33export const SYSTEM_ONE_TIMEOUT_MS = 1500
34// Below this an answer falls through to the next judge. ponytail: untuned; step 8 measures it.
35export const MIN_CONFIDENCE = 0.5
36
37// Request body: two typed questions in one call, read against the task card and the prompt.
38export function systemOneBody(text: string, task = '', model?: string): string {
39 return JSON.stringify({
40 state: { ...(task ? { task } : {}), prompt: text.slice(0, 4000) },
41 ...(model ? { model } : {}),
42 min_confidence: MIN_CONFIDENCE,
43 questions: {
44 tier: {
45 type: 'choice',
46 instructions: 'How hard is this coding-assistant request?',
47 criteria: {
48 S: 'trivial lookup, typo, rename, format, one-line answer, a search or read of the code',
49 M: 'normal coding, including multi-step or multi-file chores: edits, re-renders, commits, running tools',
50 L: 'only when the hard part is the thinking: architecture, a race or subtle bug, a security review',
51 },
52 },
53 effort: {
54 type: 'score',
55 instructions: 'How much thinking does it need?',
56 criteria: ['low', 'medium', 'high'],
57 },
58 },
59 })
60}
61
62// The most likely key of an answer's probabilities: an option name for a choice, a level index for a score.
63function argmax(p: unknown): string | undefined {
64 if (!p || typeof p !== 'object') return undefined
65 let best: [string, number] | undefined
66 for (const [k, v] of Object.entries(p)) if (typeof v === 'number' && (!best || v > best[1])) best = [k, v]
67 return best?.[0]
68}
69
70// Pure: System One's answers to a verdict, by argmax over each answer's probabilities. A score is an
71// expectation (0.67 between two levels), never an index. An abstained, unsure or missing answer is
72// undefined, so the chain falls through to the next judge.
73export function systemOneVerdict(answers: any, reason = 'system one'): Verdict | undefined {
74 const t = answers?.tier
75 const e = answers?.effort
76 if (!t || !e || t.abstention || e.abstention) return undefined
77 if ((typeof t.confidence === 'number' && t.confidence < MIN_CONFIDENCE) || (typeof e.confidence === 'number' && e.confidence < MIN_CONFIDENCE)) return undefined
78 const tier = argmax(t.probabilities) ?? t.choice
79 const level = argmax(e.probabilities)
80 // No probabilities (an older laya-serve): a whole-number score is still a level.
81 const effort = level !== undefined ? EFFORTS[Number(level)] : Number.isInteger(e.score) ? EFFORTS[e.score] : undefined
82 if (!TIERS.includes(tier) || !effort) return undefined
83 return { tier, effort, reason }
84}
85hooks/econ.ts 253 lines1// Pure cost model, in units of one Sonnet uncached input token.
2// Ratios from effortless 1.71.0 (MIT, HeyCubit): MODEL_PRICE, WEIGHT and the measured 1h cache write.
3import type { Tier } from './route'
4
5export const PRICE: Record<Tier, number> = { S: 1 / 3, M: 1, L: 2 }
6export const READ = 0.1 // cache read, × input price
7export const WRITE = 2 // Claude Code writes the 1h cache: 2× input price (measured)
8export const OUT = 5 // output, × input price
9
10/** First request of the next prompt when staying on `m`: a read when warm, a full re-write when cold. */
11export const costContinue = (C: number, m: Tier, warm: boolean) => C * PRICE[m] * (warm ? READ : WRITE)
12
13/** First request after moving to `to`. */
14export const costSwitch = (C: number, to: Tier, warmTo: boolean) => costContinue(C, to, warmTo)
15
16/** Haiku reads the chat once (uncached), writes summary S, then `m` writes S to its cache. */
17export const costCompact = (C: number, S: number, m: Tier) => C * PRICE.S + OUT * S * PRICE.S + WRITE * S * PRICE[m]
18
19/** Whether moving one prompt from `inUse` to `to` is cheaper than staying (effortless's routeWorth). */
20export function routeWorth(to: Tier, inUse: Tier, C: number, warm: { to: boolean; inUse: boolean }): boolean {
21 if (!(C > 0)) return true
22 return costSwitch(C, to, warm.to) < costContinue(C, inUse, warm.inUse)
23}
24
25export type Row = { action: 'continue' | 'switch' | 'compact' | 'handoff'; to?: Tier; first: number; after: number }
26
27/**
28 * The options for the next prompt on a chat of C tokens on model m, cheapest first.
29 * `first` is the first request's cost, `after` each later request's. S is the expected summary size;
30 * a handoff also carries HANDOFF.md (`doc` tokens).
31 */
32export function coldAdvice(
33 C: number,
34 m: Tier,
35 warmUntil: Partial<Record<Tier, number>>,
36 now: number,
37 S = 4000,
38 doc = 2000,
39): { best: Row; rows: Row[] } {
40 const warm = (t: Tier) => (warmUntil[t] ?? 0) > now
41 const rows: Row[] = [
42 { action: 'continue', first: costContinue(C, m, warm(m)), after: C * PRICE[m] * READ },
43 ...(['S', 'M', 'L'] as Tier[])
44 .filter(t => t !== m && PRICE[t] < PRICE[m])
45 .map(t => ({ action: 'switch' as const, to: t, first: costSwitch(C, t, warm(t)), after: C * PRICE[t] * READ })),
46 { action: 'compact', first: costCompact(C, S, m), after: S * PRICE[m] * READ },
47 // One per tier: Haiku reads the chat, and the new chat on `t` starts from the summary and the doc, cold.
48 ...(['S', 'M', 'L'] as Tier[]).map(t => ({ action: 'handoff' as const, to: t, first: costCompact(C, S + doc, t), after: (S + doc) * PRICE[t] * READ })),
49 ]
50 rows.sort((a, b) => a.first - b.first)
51 return { best: rows[0], rows }
52}
53
54/** The fewest later turns after which `row` has cost less than staying, or undefined past `max`. */
55export function paysOffAfter(row: Row, stay: Row, max = 20): number | undefined {
56 for (let n = 0; n <= max; n++) if (row.first + n * row.after < stay.first + n * stay.after) return n
57 return undefined
58}
59
60/** What a row costs over this turn and the next `turns - 1`: the "best" tag goes to the cheapest. */
61export const overTurns = (row: Row, turns = 5) => row.first + (turns - 1) * row.after
62
63/** Units to dollars: Sonnet 5.5 input is $3 per million tokens. ponytail: fixed rate, read real prices if they change. */
64export const dollars = (units: number) => (units * 3) / 1e6
65
66// --- Cache clock helpers (ported from effortless cacheTtlOf / mostlyCached) ---
67
68export const CACHE_TTL = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
69export type Ttl = keyof typeof CACHE_TTL
70type Usage = {
71 input_tokens?: number
72 cache_read_input_tokens?: number
73 cache_creation_input_tokens?: number
74 cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }
75}
76
77/** The cache lifetime a response's writes got, when it says. */
78export function cacheTtlOf(usage: unknown): Ttl | undefined {
79 const c = (usage as Usage | null)?.cache_creation
80 if ((c?.ephemeral_1h_input_tokens ?? 0) > 0) return '1h'
81 if ((c?.ephemeral_5m_input_tokens ?? 0) > 0) return '5m'
82 return undefined
83}
84
85/** Most of the prompt came from the cache: it was still warm. */
86export function mostlyCached(usage: unknown): boolean {
87 const u = (usage ?? {}) as Usage
88 const r = u.cache_read_input_tokens ?? 0
89 const all = r + (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
90 return all > 0 && r / all > 0.5
91}
92
93/** Context size C: everything the request sent. */
94export const contextOf = (usage: unknown) => {
95 const u = (usage ?? {}) as Usage
96 return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
97}
98
99/**
100 * The TTL after a response. A read back after more than 5 minutes proves 1h; once 1h is seen,
101 * a short 5m tail write never pulls it back down.
102 */
103export function nextTtl(prev: Ttl, usage: unknown, sinceLastMs: number | undefined): Ttl {
104 const seen = cacheTtlOf(usage) ?? (sinceLastMs !== undefined && sinceLastMs > CACHE_TTL['5m'] && mostlyCached(usage) ? '1h' : undefined)
105 if (!seen || (seen === '5m' && prev === '1h' && mostlyCached(usage))) return prev
106 return seen
107}
108
109export const tierOf = (model: string): Tier | undefined =>
110 /haiku/.test(model) ? 'S' : /sonnet/.test(model) ? 'M' : /opus/.test(model) ? 'L' : undefined
111
112export const mmss = (ms: number) => {
113 const s = Math.max(0, Math.ceil(ms / 1000))
114 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
115}
116
117export type SwitchPlan = { tier: Tier; real: boolean; why: 'same' | 'boundary' | 'worth' | 'sticky' }
118
119/**
120 * Where this prompt's requests go, and whether the real /model moves.
121 * The real model changes only at a cache boundary (chat model cold, or right after a compact).
122 * While warm, requests stay on the chat's model unless routeWorth says the move pays.
123 */
124export function planSwitch(p: {
125 decided: Tier
126 chat: Tier
127 C: number
128 warmUntil: Partial<Record<Tier, number>>
129 now: number
130}): SwitchPlan {
131 const { decided, chat, C, warmUntil, now } = p
132 if (decided === chat) return { tier: chat, real: false, why: 'same' }
133 const warm = (t: Tier) => (warmUntil[t] ?? 0) > now
134 // After a compact the caller passes no warmUntil (the cache atom is cleared), so that is a boundary too.
135 if (!warm(chat)) return { tier: decided, real: true, why: 'boundary' }
136 if (routeWorth(decided, chat, C, { to: warm(decided), inUse: true })) return { tier: decided, real: false, why: 'worth' }
137 return { tier: chat, real: false, why: 'sticky' }
138}
139
140/** The prompt for Haiku's summary: the transcript as text, the last part if it is long. */
141export function summaryPrompt(messages: readonly { role: string; text: string }[], maxChars = 300_000): string {
142 const all = messages.map(m => `${m.role}: ${m.text}`).join('\n\n')
143 return all.length > maxChars ? all.slice(-maxChars) : all
144}
145
146/** The last turn stopped mid-task: it did not end on an answer, or the compact summary names a next step. */
147export const midTask = (reason: string | undefined, summary: string) =>
148 (reason !== undefined && reason !== 'answer') || /next step[^\n:]*:\s*(?!none|n\/a|nothing)\S/i.test(summary)
149
150// --- Band indicator (step 14) ---
151
152// Context window per tier, in tokens. ponytail: assumed sizes (Haiku 200k, Sonnet and Opus 1M); read the model's limit if they differ.
153export const WINDOWS: Record<Tier, number> = { S: 200_000, M: 1_000_000, L: 1_000_000 }
154/** Context fill as a whole percent of the tier's window, never above 100. */
155export const fillOf = (C: number, t: Tier) => Math.min(100, Math.round((C / WINDOWS[t]) * 100))
156/** The cheapest tier, not below `tier`, whose window holds C tokens. Tiers are ordered S < M < L. */
157export function fitTier(tier: Tier, C: number): Tier {
158 const order: Tier[] = ['S', 'M', 'L']
159 return order.find(t => order.indexOf(t) >= order.indexOf(tier) && WINDOWS[t] >= C) ?? 'L'
160}
161export const HEAVY_TOKENS = 150_000 // over this, the band warns that the chat is large
162export type BandState = 'routing' | 'off' | 'paused' | 'cold' | 'heavy' | 'normal'
163export type BandInput = {
164 off: boolean
165 paused: boolean
166 routing: boolean
167 model: string // full model id of the last decision
168 effort?: string
169 warmLeftMs: number // ms until the chat's model cache goes cold; 0 or less = cold
170 C: number
171 flash?: boolean
172 heavyTokens?: number
173}
174
175const SHORT: Record<string, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus' }
176const EFFORT_SHORT: Record<string, string> = { low: 'low', medium: 'med', high: 'high', xhigh: 'xhi', max: 'max' }
177export const shortModel = (model: string) => SHORT[(/haiku|sonnet|opus/.exec(model)?.[0] ?? '')] ?? model
178export const shortEffort = (effort?: string) => (effort ? EFFORT_SHORT[effort] ?? effort : '-')
179
180/** The band's state and its two texts: `text` for the band, `short` for the status line. */
181export function bandText(i: BandInput): { state: BandState; text: string; short: string } {
182 const label = `${shortModel(i.model)} · ${shortEffort(i.effort)}`
183 const k = `${Math.round(i.C / 1000)}k`
184 const pct = `${fillOf(i.C, tierOf(i.model) ?? 'M')}%`
185 const flash = i.flash ? ' ▲' : ''
186 // `short` goes to the status line, which the footer already labels with the plugin's name.
187 if (i.off) return { state: 'off', text: `off · ${label}`, short: 'off' }
188 if (i.routing) return { state: 'routing', text: 'routing…', short: '…' }
189 if (i.paused) return { state: 'paused', text: `paused · ${label} (you picked it; /arne on resumes)`, short: `paused · you picked ${shortModel(i.model)}` }
190 if (i.C > 0 && i.warmLeftMs <= 0) return { state: 'cold', text: `cold · ${k} · see the offer below`, short: `cold ${k}` }
191 if (i.C >= (i.heavyTokens ?? HEAVY_TOKENS)) return { state: 'heavy', text: `${label} · heavy ${k} · ◔ ${pct}`, short: `${shortModel(i.model)}·${shortEffort(i.effort)} heavy` }
192 return {
193 state: 'normal',
194 text: `${label} · cache warm ${mmss(i.warmLeftMs)} · ${k}${flash}`,
195 short: `${shortModel(i.model)}·${shortEffort(i.effort)}${flash}`,
196 }
197}
198
199// --- Settings clamps (step 15) ---
200
201const TIER_NAME: Record<string, Tier> = { haiku: 'S', sonnet: 'M', opus: 'L' }
202const TIER_RANK: Record<Tier, number> = { S: 0, M: 1, L: 2 }
203export const EFFORT_RANK: Record<string, number> ={ low: 0, medium: 1, high: 2, xhigh: 3, max: 4 }
204
205/** A tier kept between the floor and ceiling model names (haiku|sonnet|opus). */
206export function clampTier(tier: Tier, floor: string, ceiling: string): Tier {
207 const lo = TIER_RANK[TIER_NAME[floor] ?? 'S']
208 const hi = TIER_RANK[TIER_NAME[ceiling] ?? 'L']
209 const r = Math.min(Math.max(TIER_RANK[tier], lo), Math.max(lo, hi))
210 return (['S', 'M', 'L'] as Tier[])[r]
211}
212
213/** An effort kept between the floor and ceiling names. */
214export function clampEffort<E extends string>(effort: E, floor: string, ceiling: string): E {
215 const lo = EFFORT_RANK[floor] ?? 0
216 const hi = EFFORT_RANK[ceiling] ?? 4
217 const r = Math.min(Math.max(EFFORT_RANK[effort] ?? 1, lo), Math.max(lo, hi))
218 return (Object.keys(EFFORT_RANK).find(k => EFFORT_RANK[k] === r) ?? effort) as E
219}
220
221// --- Words for people (step 17i) ---
222
223/** A decision's internal reason code as a plain phrase. Judge reasons are already words and pass through. */
224export function plainReason(code: string): string {
225 if (code === 'continuation') return 'it continues the last turn'
226 if (code === 'default') return 'there is no clear signal'
227 if (code === 'window') return 'the chat needs a bigger window'
228 if (code.startsWith('prior: ')) return `${code.slice(7)} is a fixed rule`
229 return code
230}
231
232/**
233 * The resting line at a given width. `head` (the mark, model and effort) never shrinks; the reason takes
234 * the leftover and ends in an ellipsis; the cache text falls back through `caches`, longest first.
235 */
236export function fitRest(width: number, head: string, reason: string, caches: string[]): { head: string; reason: string; cache: string } {
237 const GAP = 2
238 const last = caches[caches.length - 1]
239 for (const cache of caches) {
240 const room = width - head.length - cache.length - GAP * 2
241 if (room >= Math.min(reason.length, 12) || cache === last) {
242 if (room <= 0) return { head, reason: '', cache }
243 const r = reason.length <= room ? reason : `${reason.slice(0, Math.max(0, room - 1))}…`
244 return { head, reason: r, cache }
245 }
246 }
247 return { head, reason: '', cache: last }
248}
249
250/** Effort names for the band's panel: the short forms when the panel is narrow. */
251export const EFFORT_LABEL: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' }
252export const EFFORT_LABEL_SHORT: Record<string, string> = { low: 'Low', medium: 'Med', high: 'High', xhigh: 'XHigh', max: 'Max' }
253hooks/route.ts 99 lines1export type Tier = 'S' | 'M' | 'L'
2export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3export type Verdict = { tier: Tier; effort: Effort; reason: string }
4// keepEffort: a model-only pin; the request keeps the effort the app sends, whatever `effort` says.
5export type Decision = Verdict & { source: string; keepEffort?: boolean }
6export type Backend = { name: string; ask: (text: string) => Promise<Verdict | undefined> }
7export type Ctx = {
8 prev?: Decision
9 pin?: { tier: Tier; effort: Effort }
10 paused?: boolean
11 agentType?: string // set for subagent runs, absent on the main loop
12 backends?: Backend[] // fallback chain, first verdict wins
13 failed?: boolean // the last attempt failed (failedAttempt): the prompt goes one tier up from prev
14}
15
16export const MODEL: Record<Tier, string> = {
17 S: 'claude-haiku-5-5',
18 M: 'claude-sonnet-5-5',
19 L: 'claude-opus-5-5',
20}
21
22const PRIORS: Record<string, Verdict> = {
23 Explore: { tier: 'S', effort: 'low', reason: 'prior: Explore' },
24 'cavecrew-investigator': { tier: 'S', effort: 'low', reason: 'prior: investigator' },
25 'statusline-setup': { tier: 'S', effort: 'low', reason: 'prior: statusline-setup' },
26 Plan: { tier: 'L', effort: 'high', reason: 'prior: Plan' },
27}
28
29const CONTINUE = /^\W*(y|yes|ok|okay|continue|go on|go ahead|proceed|sure|ok do it|do it)\W*$/i
30// A short follow-up with none of these words carries on the work in hand ("looks good, next one"):
31// it keeps the previous tier and effort and the judge is not asked (17p).
32// No keyword or length sends a prompt to a tier: the judge reads every new task, since a word is
33// a poor guess ("add a security note" is no Opus job) and a judge call costs far less than a wrong tier.
34const FOLLOW_MAX = 80
35const TASK = /\b(add|build|write|fix|implement|create|refactor|make|change|remove|delete|test|debug|explain|why|how|what|update|rename|move|review|design|plan|migrate|install|deploy|port|rewrite|find|search|list|show|check|run|look|read|summari[sz]e|compare|convert|generate|setup|set up|typo|format|security|architect\w*|race)\b/i
36
37const keep = (text: string) => text.trim() === '' || CONTINUE.test(text) || (text.trim().length < FOLLOW_MAX && !TASK.test(text))
38
39const DEFAULT: Verdict = { tier: 'M', effort: 'medium', reason: 'default' }
40
41const UP_TIER: Record<Tier, Tier> = { S: 'M', M: 'L', L: 'L' }
42// The judge's own ceiling: escalation does not reach xhigh or max either.
43const UP_EFFORT: Record<Effort, Effort> = { low: 'medium', medium: 'high', high: 'high', xhigh: 'xhigh', max: 'max' }
44
45// The person says the last attempt did not work.
46export const RETRY = /\b(did ?n[o']?t work|does ?n[o']?t work|not working|still (?:fail\w*|broken|wrong|not \w+|does ?n[o']?t \w+)|try again|that'?s (?:wrong|not right)|wrong (?:answer|number|result|output)|same error)\b/i
47
48type ApiMessage = { role: string; content: unknown }
49const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
50const blocksOf = (c: unknown): any[] => (typeof c === 'string' ? [{ type: 'text', text: c }] : Array.isArray(c) ? c : [])
51const textOf = (c: unknown): string => blocksOf(c).map(b => (b.type === 'text' ? b.text : '')).join('\n')
52// Line numbers, timings and counts change between runs of the same error.
53const signature = (b: any) => textOf(b.content).replace(/\d+/g, '#').trim().slice(0, 300)
54
55// Pure: whether the attempt since the last prompt the person typed failed. After claude-router (MIT): the same
56// tool error twice with an edit between them. Or the new prompt says so (RETRY).
57export function failedAttempt(messages: readonly ApiMessage[], text: string): boolean {
58 if (RETRY.test(text)) return true
59 const isTyped = (m: ApiMessage) => m.role === 'user' && !blocksOf(m.content).some(b => b.type === 'tool_result')
60 let typed = messages.findLastIndex(isTyped)
61 // The new prompt may already be in the transcript: then the attempt is the one before it.
62 if (typed >= 0 && textOf(messages[typed].content).trim() === text.trim()) typed = messages.slice(0, typed).findLastIndex(isTyped)
63 const errors: { at: number; sig: string }[] = []
64 const edits: number[] = []
65 messages.forEach((m, at) => {
66 if (at <= typed) return
67 for (const b of blocksOf(m.content)) {
68 if (m.role === 'user' && b.type === 'tool_result' && b.is_error) errors.push({ at, sig: signature(b) })
69 if (m.role === 'assistant' && b.type === 'tool_use' && EDIT_TOOLS.has(b.name)) edits.push(at)
70 }
71 })
72 return errors.some((a, i) => errors.slice(i + 1).some(b => b.sig === a.sig && edits.some(e => e > a.at && e < b.at)))
73}
74
75// Precedence: pin > paused > failed attempt goes up from prev > follow-up keeps prev > backends > prev > default.
76// undefined = leave the request alone (paused).
77export async function route(text: string, ctx: Ctx = {}): Promise<Decision | undefined> {
78 if (ctx.pin) return { ...ctx.pin, reason: 'pinned', source: 'pin' }
79 if (ctx.paused && !ctx.agentType) return undefined
80
81 const prior = ctx.agentType ? PRIORS[ctx.agentType] : undefined
82 if (prior?.tier === 'L') return { ...prior, source: 'prior' }
83
84 // A failed attempt is not asked again at the tier that failed it: one tier and one effort up.
85 if (!ctx.agentType && ctx.prev && ctx.failed)
86 return { tier: UP_TIER[ctx.prev.tier], effort: UP_EFFORT[ctx.prev.effort], reason: 'last attempt failed', source: 'escalate' }
87
88 // A subagent's brief is a new task every time, so it never just continues the last turn (17x).
89 if (!ctx.agentType && ctx.prev && keep(text)) return { ...ctx.prev, reason: 'continuation', source: 'prev' }
90
91 for (const b of ctx.backends ?? []) {
92 const v = await b.ask(text).catch(() => undefined)
93 if (v) return prior && v.tier !== 'L' ? { ...prior, source: 'prior' } : { ...v, source: b.name }
94 }
95 if (prior) return { ...prior, source: 'prior' }
96 if (ctx.prev) return { ...ctx.prev, source: 'prev' }
97 return { ...DEFAULT, source: 'default' }
98}
99hooks/version.ts 3 lines1// Keep equal to .claude-plugin/plugin.json (bump both): a hooks module cannot import JSON.
2export const VERSION = '0.4.1'
3hooks/stats.ts 124 lines1// Stats (17q): what arne saved, from the usage rows in decisions.jsonl. An estimate: each turn's real
2// tokens priced again as if it ran on a baseline model. Effort has no price of its own here: a different
3// effort or model would have written different output, and that cannot be known, so the tokens stay as they were.
4import { dollars, OUT, PRICE, READ, tierOf, WRITE } from './econ'
5import type { Tier } from './route'
6
7export type UsageRow = {
8 type: 'usage'
9 at: number
10 kind: 'turn' | 'agent' | 'summary' | 'jev' // jev: one Jev judge call, its tokens only (17s)
11 model: string
12 input: number
13 read: number
14 write: number
15 out: number
16 switched?: boolean // the turn right after arne's own /model: its cache writes are the switch's cost
17}
18
19export type Period = 'today' | 'week' | 'all'
20export const PERIODS: [Period, string][] = [['today', 'Today'], ['week', '7 days'], ['all', 'All time']]
21
22/** The first timestamp a period takes in: local midnight today, seven days back, or the beginning. */
23export function periodStart(period: Period, now: number): number {
24 if (period === 'all') return 0
25 if (period === 'week') return now - 7 * 24 * 60 * 60_000
26 const d = new Date(now)
27 d.setHours(0, 0, 0, 0)
28 return d.getTime()
29}
30
31/** A usage row from turn.complete's usage, or undefined when the turn reported none. */
32export function usageRow(
33 kind: UsageRow['kind'],
34 at: number,
35 usage: { model?: string; input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | undefined,
36 model?: string,
37 switched?: boolean,
38): UsageRow | undefined {
39 const m = usage?.model ?? model
40 if (!usage || !m || !tierOf(m)) return undefined
41 return {
42 type: 'usage',
43 at,
44 kind,
45 model: m,
46 input: usage.input_tokens ?? 0,
47 read: usage.cache_read_input_tokens ?? 0,
48 // The usage carries no 5m/1h split; Claude Code writes the 1h cache (econ.ts WRITE).
49 write: usage.cache_creation_input_tokens ?? 0,
50 out: usage.output_tokens ?? 0,
51 ...(switched ? { switched: true } : {}),
52 }
53}
54
55/** Dollars for a row's tokens on a tier. `asRead` prices the cache writes as reads (no switch happened). */
56export const priceOn = (r: UsageRow, t: Tier, asRead = false) =>
57 dollars(PRICE[t] * (r.input + r.read * READ + r.write * (asRead ? READ : WRITE) + r.out * OUT))
58
59export type Line = { label: string; count: number; cost: number; tokens?: number } // tokens: a line with no known price (Jev)
60export type Summary = { spent: number; opus: number; lines: Line[]; turns: number }
61
62/** Totals for the rows at or after `since`. Rows without usage never get here (usageRow drops them). */
63export function summarize(rows: UsageRow[], since: number): Summary {
64 const picked = rows.filter(r => r.at >= since && tierOf(r.model))
65 // Jev's prices are not published (17s): its calls count in tokens, on their own line, outside the dollars.
66 const jevRows = rows.filter(r => r.at >= since && r.kind === 'jev')
67 const jev: Line = { label: 'Jev judge', count: jevRows.length, cost: 0, tokens: jevRows.reduce((n, r) => n + r.input + r.out, 0) }
68 const tiers: [Tier, string][] = [['S', 'Haiku'], ['M', 'Sonnet'], ['L', 'Opus']]
69 const byTier = new Map<Tier, Line>(tiers.map(([t, label]) => [t, { label, count: 0, cost: 0 }]))
70 const agents: Line = { label: 'Subagents', count: 0, cost: 0 }
71 const extra: Line = { label: 'Switches and summaries', count: 0, cost: 0 }
72 let spent = 0
73 let opus = 0
74 for (const r of picked) {
75 const t = tierOf(r.model)!
76 const cost = priceOn(r, t)
77 spent += cost
78 if (r.kind === 'summary') {
79 // A summary is arne's own spend: the default would not have made one.
80 extra.count++
81 extra.cost += cost
82 continue
83 }
84 // The default stays on one model, so it never pays a switch's cache rewrite: its writes read as reads.
85 opus += priceOn(r, 'L', r.switched)
86 const switchCost = r.switched ? cost - priceOn(r, t, true) : 0
87 if (r.switched) {
88 extra.count++
89 extra.cost += switchCost
90 }
91 const line = r.kind === 'agent' ? agents : byTier.get(t)!
92 line.count++
93 line.cost += cost - switchCost
94 }
95 const lines = [...byTier.values(), agents, extra, jev].filter(l => l.count > 0)
96 return { spent, opus, lines, turns: picked.filter(r => r.kind !== 'summary').length }
97}
98
99/** "Saved $X" against a baseline, or "$X more" when arne cost more; with the percent of the baseline. */
100export function savedText(spent: number, base: number): { headline: string; sub: string } {
101 const diff = base - spent
102 const pct = base > 0 ? Math.round((Math.abs(diff) / base) * 100) : 0
103 const usd = (v: number) => `$${v.toFixed(2)}`
104 return {
105 headline: diff >= 0 ? `Saved ${usd(diff)}` : `${usd(-diff)} more`,
106 sub: `${pct}% ${diff >= 0 ? 'less' : 'more'} than ${usd(base)}`,
107 }
108}
109
110/** The usage rows of a decisions.jsonl text; other rows and broken lines are skipped. */
111export function parseUsage(text: string): UsageRow[] {
112 const rows: UsageRow[] = []
113 for (const line of text.split('\n')) {
114 if (!line.includes('"type":"usage"')) continue
115 try {
116 const r = JSON.parse(line)
117 if (r.type === 'usage' && typeof r.at === 'number' && typeof r.model === 'string') rows.push(r)
118 } catch {
119 // a torn line: skipped
120 }
121 }
122 return rows
123}
124types/index.d.ts 24 lines1export type Row = {
2 key: string // turnId, plus agentId for subagent runs
3 model: string
4 effort: string
5 reason: string
6 source: string
7 out: number // output tokens, summed over the turn's steps
8 cacheRead: number // cache-read input tokens, summed
9 waitFor?: string // full model id the decision wanted, when the chat stays on another model until the cache cools
10}
11
12export type Cache = {
13 ttl: '5m' | '1h'
14 C: number // context tokens of the last main response
15 model: 'S' | 'M' | 'L' // the tier that answered last
16 warmUntil: Partial<Record<'S' | 'M' | 'L', number>> // epoch ms, per tier
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 arne: { last: Row | null; log: Row[]; cache: Cache | null; now: number; coldDismissed: number; routing: boolean; isOff: boolean; isPaused: boolean; appEffort: string | null; sessionModel: string | null; held: string | null; shown: { state: string; until: number } | null; effortless: boolean; whyOpen: boolean; thanked: string | null; layaDown: boolean; jevNoKey: boolean; appPicker: string | null; statsPeriod: string; liveRev: number; pinRev: number; heavyDismissed: number; compacting: boolean; disabling: boolean; current: import('../hooks/route').Decision | null; expected: string | null; compactSending: boolean; handedOff: import('../hooks/route').Tier | null; handoffMenu: boolean; panelView: 'main' | 'settings' | 'stats'; subCount: Partial<Record<import('../hooks/route').Tier | 'kept' | 'asked', number>> | null; stats: Record<string, import('../hooks/stats').Summary> | null }
22 }
23}
24