SLOPSHOPPER

pet

A little companion on the Windows desktop, always on top: a cardboard box or one of twelve figures (cat, dog, fox, bunny, hamster, owl, penguin, frog, axolotl…

newguardcommandtoastmodelprocess
A shopper browsing a rack in a slop shop
README

Desktop Pet

A little companion on the Windows desktop, always on top: a cardboard box or one of twelve figures (cat, dog, fox, bunny, hamster, owl, penguin, frog, axolotl, dragon, robot, ghost; cat, dog and bunny in nine coats). It says in a speech bubble what Claude is doing, shows permission requests, and lets you click Claude's questions and Next Steps' suggestions right in the bubble. Drag it anywhere; right-click for quiet time or another figure; optionally one pet per session, each with its own figure. Built once with the C# compiler that ships with Windows and signed with the Sign Guard script when one is set. /pet shows it, /pet cat calico picks a figure, /pet stop sends it off.

Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install pet --marketplace LupusMalusDeviant/claude-mods

Commands

CommandWhat it does
/petshow the desktop pet
/pet <figure> [coat]pick a figure, e.g. /pet cat calico
/pet stopsend it off

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
bubble9How long news stays in the bubble. Questions and errors stay until the next news.
summaryoffoff: the first sentences of the answer (no tokens). haiku: Haiku sums up a longer answer in up to three short sentences (what was done, what is open). A click on the pet shows the last answer in short either way.
kindboxWho stands on the desktop. random: a different one each session. /pet cat calico switches it too.
furautoThe coat colour of cat, dog and bunny.
perSessionoffon: every session whose prompt cache is warm has its own pet, side by side, each with its own figure (/pet or the right-click menu). When a session's cache goes cold its pet goes to sleep; the next message brings it back. off: one pet for all.
cacheWarn5That many minutes before the prompt cache expires the pet says so, with a button that keeps it warm for an hour (Cache Meter on). Not while keep-warm runs.
askonClaude's questions (AskUserQuestion) appear as buttons in the bubble too; the first answer counts, the pet's or the dialog's.
remind60A question still open whose bubble you hid (double-click, menu) comes back after that many seconds. Not while the pet is snoozed.
suggestonWith Next Steps on, its suggestions appear as buttons in the bubble. When an answer ends with a question to you, Haiku offers two or three short replies as buttons (a few tokens). A click sends one as your next prompt.
permissionsshowshow: the pet only says that Claude Code is waiting for your approval. answer: the bubble also offers "allow once" and "deny" for 90 seconds (never "always"; no rule is added). Mind that the pet stays on top: a click there approves.
typingoffon: a hotkey, the pet's menu or a button in the bubble opens a small input box; the text goes to Claude as the next prompt, as the answer to its open question, or in place of a suggestion. Typing takes the keyboard from the game for a moment (borderless windowed mode is fine). Any program of your user can write the pet's answer file, hence off by default.
hotkeyctrl+alt+spaceOpens the input box from anywhere, also from a game. With several pets the first one gets it.
languageenLanguage of the texts this mod shows and writes.
signScript``PowerShell script called as <script> -Exe <file>. Empty: ~/.claude/skills/sign-binary/sign.ps1
cacheTtl6060 on a subscription (Pro/Max), 5 for the API's default cache.

License

MIT, see LICENSE.

Source 4 files
hooks/entry.ts 9 lines
1// Entry of the standalone Desktop Pet: the Mod Box code, registered on its own.
2import type { Register } from 'claude-code'
3
4import { register as inner } from '../mods/pet/hooks/register'
5
6export const register: Register = (on, options) => {
7  ;(inner as any)(on, options)
8}
9
mods/pet/hooks/register.tsx 732 lines
1// Desktop Pet: a small always-on-top companion on the Windows desktop (a cardboard box with a
2// face, or one of a dozen animals and friends) that says in a speech bubble what Claude is doing: which file it reads or changes, which
3// command runs, when a guard holds something, and a short summary when the turn is done. It works
4// from the events alone, no tokens; with option summary "haiku" the end of a turn is put in one
5// sentence by Haiku. The pet is its own little program (overlay/Pet.cs), compiled once with the
6// C# compiler that ships with Windows and signed with the Sign Guard script (or, as there, the
7// sign-binary skill's when none is set and it exists).
8// The mod writes a status file the pet reads. When Claude asks a question (AskUserQuestion) or
9// Next Steps has suggestions, the bubble shows them as buttons; a click lands in
10// <status file>.answer, which this mod picks up: the question is answered (whichever comes first,
11// the pet or the dialog in Claude Code) or the suggestion is sent. A permission request is shown;
12// with option permissions "answer" the bubble also offers "allow once" and "deny" for a while.
13// The pet's right-click menu picks its figure (answer id "look"), as /pet <figure> [coat] does:
14// with one pet per session for that session alone, otherwise as the setting for every session.
15// The pet watches the prompt cache too: a few minutes before it goes cold it warns (with a button
16// that keeps it warm, when the Cache Meter is on). With one pet per session a session's pet stays
17// only while its cache is warm: it goes to sleep when the cache is cold and is back with the next
18// message.
19// With option typing on, a hotkey, the pet's menu or a button in the bubble opens a small input box
20// over the desktop: the text goes to this session as the next prompt, or as the answer to Claude's
21// open question (free text, "Other"), or in place of a suggestion. Any program of the same user can
22// write the answer file, so this is off by default.
23import { read, update } from 'claude-code'
24import type { EngineInterface, Register } from 'claude-code'
25
26import { addUsage, USAGE_STORE } from '../../settings/hooks/usage'
27import type { UsageBook } from '../../settings/hooks/usage'
28
29export type PetKind = 'hello' | 'work' | 'done' | 'ask' | 'error' | 'bye' | 'cold'
30/** One message for the bubble; with `ask` and `choices`, buttons whose click answers `ask`. */
31export type PetNote = { kind: PetKind; title: string; text: string; ask?: string; choices?: string[]; free?: string }
32/** Which figure stands on the desktop and, for cat, dog and bunny, its coat. */
33export type PetLook = { pet: string; fur: string }
34
35export const PETS = ['box', 'cat', 'dog', 'fox', 'bunny', 'hamster', 'owl', 'penguin', 'frog', 'axolotl', 'dragon', 'robot', 'ghost']
36export const FURS = ['auto', 'ginger', 'black', 'white', 'grey', 'brown', 'cream', 'calico', 'siamese', 'tuxedo']
37
38let lang: 'de' | 'en' = 'en'
39const t = (de: string, en: string) => (lang === 'en' ? en : de)
40let bubbleSec = '9'
41let summary = 'off'
42let signScript = ''
43let isWindows = false
44let statusPath = ''
45let project = ''
46let started = 0
47let steps = 0
48let look: PetLook = { pet: 'box', fur: 'auto' }
49let petChoice = 'box'
50let perSession = false
51let askViaPet = true
52let suggestViaPet = true
53let permissions: 'show' | 'answer' = 'show'
54let canType = false
55let remindSec = 60
56let hotkey = ''
57// Picks the pet may answer, by id; the answer file's last time read.
58const pending = new Map<string, (choice: string) => void>()
59let answerAt = 0
60// Next Steps: after a turn the suggestions arrive a few seconds later; watched until then.
61let watchStepsUntil = 0
62let stepsShownFor = ''
63let lastDone: PetNote | null = null
64// The last answer, compact, for a click on the pet: with its buttons while they still apply.
65let recap: PetNote | null = null
66const NEXT_STEPS = { plugin: 'mod-box', key: 'ns_steps' } as const
67const POLL_MS = 600
68// The prompt cache: how long it stays warm (the Cache Meter's setting), when the pet warns.
69let cacheTtlMs = 60 * 60_000
70let warnMs = 5 * 60_000
71let lastRequestAt = 0
72let isTurnRunning = false
73let warnedFor = 0
74let coldFor = 0
75let isAsleep = false
76let cacheCheckedAt = 0
77const CACHE_CHECK_MS = 5_000
78const KEEP_WARM_ARGS = '1h'
79// Tools whose permission request is the person's own decision (a plan to approve): never answered by the pet.
80const PERSONAL_TOOLS = ['ExitPlanMode', 'EnterPlanMode']
81// How long a permission request waits for a click in the pet (option permissions "answer").
82const PERMIT_WAIT_S = 90
83const changed = new Set<string>()
84
85const MODEL = 'haiku'
86const USAGE = { plugin: 'pet', key: 'usage' } as const
87// The pet is started again at most this often (it exits after two quiet hours or a "bye").
88const RESTART_MS = 60_000
89
90const base = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() || p
91const cut = (s: string, n: number) => {
92  const one = s.replace(/\s+/g, ' ').trim()
93  return one.length > n ? `${one.slice(0, n - 1)}…` : one
94}
95
96/** One line on a tool call, in the person's words. Pure, for the tests. */
97export function describe(tool: string, input: Record<string, unknown>, de: boolean): string {
98  const tr = (a: string, b: string) => (de ? a : b)
99  const s = (k: string) => String(input[k] ?? '')
100  switch (tool) {
101    case 'Read':
102      return tr(`Lese ${base(s('file_path'))}`, `Reading ${base(s('file_path'))}`)
103    case 'Edit':
104    case 'MultiEdit':
105    case 'NotebookEdit':
106      return tr(`Ändere ${base(s('file_path') || s('notebook_path'))}`, `Changing ${base(s('file_path') || s('notebook_path'))}`)
107    case 'Write':
108      return tr(`Schreibe ${base(s('file_path'))}`, `Writing ${base(s('file_path'))}`)
109    case 'Bash':
110    case 'PowerShell':
111      return s('description') ? cut(s('description'), 90) : tr(`Führe aus: ${cut(s('command'), 70)}`, `Running: ${cut(s('command'), 70)}`)
112    case 'Grep':
113      return tr(`Suche nach „${cut(s('pattern'), 40)}“`, `Searching for "${cut(s('pattern'), 40)}"`)
114    case 'Glob':
115      return tr(`Suche Dateien: ${cut(s('pattern'), 50)}`, `Looking for files: ${cut(s('pattern'), 50)}`)
116    case 'WebFetch': {
117      const host = /^https?:\/\/([^/]+)/.exec(s('url'))?.[1] ?? s('url')
118      return tr(`Lese Webseite ${cut(host, 50)}`, `Reading ${cut(host, 50)}`)
119    }
120    case 'WebSearch':
121      return tr(`Suche im Web: ${cut(s('query'), 60)}`, `Searching the web: ${cut(s('query'), 60)}`)
122    case 'Agent':
123    case 'Task':
124      return tr(`Schicke einen Helfer los: ${cut(s('description'), 60)}`, `Sending a helper: ${cut(s('description'), 60)}`)
125    case 'TodoWrite':
126      return tr('Plane die nächsten Schritte', 'Planning the next steps')
127    default: {
128      const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
129      if (mcp) return tr(`Nutze ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}`, `Using ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}`)
130      return tr(`Benutze ${tool}`, `Using ${tool}`)
131    }
132  }
133}
134
135/** The first sentence or two of an answer, without markdown. Pure, for the tests. */
136export function gist(answer: string, max = 170): string {
137  const plain = answer
138    .replace(/```[\s\S]*?```/g, ' ')
139    .replace(/`([^`]*)`/g, '$1')
140    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
141    .replace(/^#+\s*/gm, '')
142    .replace(/^\s*[-*]\s+/gm, '')
143    .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
144    .replace(/\|/g, ' ')
145    .replace(/\s+/g, ' ')
146    .trim()
147  const sentences = plain.match(/[^.!?]+[.!?]+/g) ?? [plain]
148  let out = ''
149  for (const sentence of sentences) {
150    if (out && (out + sentence).length > max) break
151    out += sentence
152  }
153  return cut(out || plain, max)
154}
155
156/** An answer in short for a click on the pet: its gist, and the question it ends with. Pure. */
157export function compact(answer: string, question: string | null, max = 320): string {
158  const head = gist(answer, question ? 200 : max)
159  return question && !head.includes(question) ? `${head}\n${question}` : head
160}
161
162/** The question an answer ends with (in its last paragraph), without markdown; null when none. Pure. */
163export function lastQuestion(answer: string, max = 200): string | null {
164  const paragraphs = answer.replace(/```[\s\S]*?```/g, ' ').split(/\n\s*\n/).map(p => p.trim()).filter(Boolean)
165  const last = paragraphs.at(-1) ?? ''
166  if (!last.includes('?')) return null
167  const sentences = gist(last, 100_000).match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? []
168  const question = [...sentences].reverse().find(s => s.trim().endsWith('?'))
169  return question ? cut(question.trim(), max) : null
170}
171
172/** The status file the pet reads: key=value lines, newlines in the text escaped. */
173export function statusText(note: PetNote, at: number, figure?: PetLook, extra: Record<string, string> = {}): string {
174  const esc = (s: string) => s.replace(/\r?\n/g, '\\n')
175  const lines = [`at=${at}`, `kind=${note.kind}`, `title=${esc(note.title)}`, `text=${esc(note.text)}`]
176  if (figure) lines.push(`pet=${figure.pet}`, `fur=${figure.fur}`)
177  if (note.ask && note.choices?.length) lines.push(`ask=${note.ask}`, ...note.choices.map(c => `choice=${esc(c)}`))
178  if (note.free) lines.push(`free=${note.free}`)
179  for (const [k, v] of Object.entries(extra)) if (v) lines.push(`${k}=${esc(v)}`)
180  return [...lines, ''].join('\n')
181}
182
183/** What the pet wrote after a click: the id it answers and the choice. Pure, for the tests. */
184export function parseAnswer(text: string): { id: string; choice: string } | null {
185  const get = (k: string) => new RegExp(`^${k}=(.*)$`, 'm').exec(text)?.[1]?.trim() ?? ''
186  const id = get('id')
187  const choice = get('choice').replace(/\\n/g, '\n')
188  return id && choice ? { id, choice } : null
189}
190
191/** The figure for a session: the one chosen, or any one for "random" (kept for the session). */
192export function pickLook(pet: string, fur: string, roll = Math.random): PetLook {
193  const figure = pet === 'random' ? PETS[1 + Math.floor(roll() * (PETS.length - 1))]! : PETS.includes(pet) ? pet : 'box'
194  const coat = fur === 'random' ? FURS[1 + Math.floor(roll() * (FURS.length - 1))]! : FURS.includes(fur) ? fur : 'auto'
195  return { pet: figure, fur: coat }
196}
197
198// One pet per session: each session's own figure, kept in $.store (the latest LOOKS_KEPT sessions).
199const LOOKS_STORE = 'pet:looks'
200const LOOKS_KEPT = 40
201let sessionId = ''
202// ...and when each one last sent a request, so a session that only starts again (the plugin
203// reloaded, the app reopened) knows whether its cache is still warm.
204const ACTIVE_STORE = 'pet:active'
205
206async function markRequest($: EngineInterface) {
207  lastRequestAt = (await $.clock.now()) || 1
208  if (!perSession || !sessionId) return
209  const all = ((await $.store.get(ACTIVE_STORE).catch(() => null)) ?? {}) as Record<string, number>
210  delete all[sessionId]
211  const kept = Object.entries(all).slice(-(LOOKS_KEPT - 1))
212  await $.store.set(ACTIVE_STORE, Object.fromEntries([...kept, [sessionId, lastRequestAt]])).catch(() => {})
213}
214
215// One pet per active session, at the start: a session whose cache is cold (or an old one taken up
216// again, its time unknown) has no pet until its next message; a pet still running for it leaves.
217async function startsAsleep($: EngineInterface, now: number): Promise<boolean> {
218  if (!perSession || !sessionId) return false
219  const known = (((await $.store.get(ACTIVE_STORE).catch(() => null)) ?? {}) as Record<string, number>)[sessionId]
220  if (typeof known === 'number') {
221    lastRequestAt = known
222    return cacheState(known, now, cacheTtlMs, 0) === 'cold'
223  }
224  const isOld = (await $.session.messages().catch(() => [])).length > 0
225  if (isOld) lastRequestAt = 0
226  return isOld
227}
228
229/** "cat calico", "random", "fox": the figure and coat a menu click or /pet names. Pure, for the tests. */
230export function parseLook(text: string, current: PetLook): PetLook | null {
231  const [first = '', second = ''] = text.trim().toLowerCase().split(/\s+/)
232  if (first !== 'random' && !PETS.includes(first)) return null
233  return pickLook(first, FURS.includes(second) || second === 'random' ? second : current.fur)
234}
235
236// The figure changes: for this session alone (one pet per session) or as the setting for all.
237async function applyLook($: EngineInterface, next: PetLook, named: string, hasCoat: boolean) {
238  look = next
239  if (perSession && sessionId) {
240    const all = ((await $.store.get(LOOKS_STORE).catch(() => null)) ?? {}) as Record<string, PetLook>
241    delete all[sessionId]
242    const kept = Object.entries(all).slice(-(LOOKS_KEPT - 1))
243    await $.store.set(LOOKS_STORE, Object.fromEntries([...kept, [sessionId, next]])).catch(() => {})
244  } else {
245    await $.config.set({ key: 'mod-box.petKind', value: named }).catch(() => null)
246    if (hasCoat) await $.config.set({ key: 'mod-box.petFur', value: next.fur }).catch(() => null)
247  }
248  await say($, { kind: 'hello', title: titled(t('Da bin ich', 'Here I am')), text: t('Wie gefalle ich dir?', 'How do I look?') })
249}
250
251// The menu's pick waits for a click at any time.
252// A click on the pet asks for the last answer again (id "recap").
253function listenForRecap($: EngineInterface) {
254  pending.set('recap', () => {
255    listenForRecap($)
256    void say($, recap ?? { kind: 'hello', title: titled(t('Zuletzt', 'Last')), text: t('In dieser Sitzung gab es noch keine Antwort.', 'No answer in this session yet.') }).catch(() => {})
257  })
258}
259
260function listenForLook($: EngineInterface) {
261  pending.set('look', choice => {
262    listenForLook($)
263    const next = parseLook(choice, look)
264    const [named = '', coat = ''] = choice.trim().toLowerCase().split(/\s+/)
265    if (next) void applyLook($, next, named, coat !== '').catch(() => {})
266  })
267}
268
269const promptId = () => `prompt-${sessionId.replace(/[^\w-]/g, '').slice(0, 8) || 'x'}`
270
271// Typed in the pet with nothing open: the session's next prompt (queued when a turn runs).
272function listenForPrompt($: EngineInterface) {
273  if (!canType) return
274  pending.set(promptId(), text => {
275    listenForPrompt($)
276    void sendSuggestion($, text).catch(() => {})
277  })
278}
279
280const waitPick = (id: string) => new Promise<string>(resolve => pending.set(id, resolve))
281
282// Reads the pet's answer file while something waits for a click, and offers Next Steps'
283// suggestions once they are there.
284/** Where the cache stands: minutes left while warm, or cold; null before the first request. Pure. */
285export function cacheState(lastAt: number, now: number, ttlMs: number, warnBeforeMs: number): 'warm' | 'cooling' | 'cold' | null {
286  if (!lastAt) return null
287  const left = lastAt + ttlMs - now
288  return left <= 0 ? 'cold' : warnBeforeMs > 0 && left <= warnBeforeMs ? 'cooling' : 'warm'
289}
290
291// Keep-warm (the Cache Meter's /keepwarm) holds the cache: no warning, no sleep meanwhile.
292async function isKeptWarm($: EngineInterface, now: number): Promise<boolean> {
293  if ((await $.store.get('always').catch(() => null)) === true) return true
294  const deadline = await $.store.get(`deadline:${sessionId}`).catch(() => null)
295  return typeof deadline === 'number' && deadline > now
296}
297
298async function checkCache($: EngineInterface) {
299  if (isTurnRunning || !lastRequestAt) return
300  const now = await $.clock.now()
301  if (now - cacheCheckedAt < CACHE_CHECK_MS) return
302  cacheCheckedAt = now
303  const state = cacheState(lastRequestAt, now, cacheTtlMs, warnMs)
304  if (state === 'warm' || state === null) return
305  if (await isKeptWarm($, now)) return
306  if (state === 'cooling' && warnedFor !== lastRequestAt) {
307    warnedFor = lastRequestAt
308    const minutes = Math.max(1, Math.ceil((lastRequestAt + cacheTtlMs - now) / 60_000))
309    const keep = t('Warm halten (1 Std.)', 'Keep warm (1 h)')
310    const canKeep = (await $.command.list().catch(() => [] as Array<{ name: string }>)).some(c => c.name === 'keepwarm')
311    const id = `warm-${lastRequestAt}`
312    if (canKeep) {
313      pending.set(id, choice => {
314        if (choice !== keep) return void say($, { kind: 'cold', title: titled(t('Cache', 'Cache')), text: t('Gut, ich lasse ihn abkühlen.', 'Fine, I let it cool down.') })
315        void $.command.run({ command: 'keepwarm', args: KEEP_WARM_ARGS }).catch(() => $.ui.toast(t(`Bitte /keepwarm ${KEEP_WARM_ARGS} eingeben.`, `Please type /keepwarm ${KEEP_WARM_ARGS}.`)))
316        void say($, { kind: 'work', title: titled(t('Cache', 'Cache')), text: t('Ich halte ihn eine Stunde warm.', 'Keeping it warm for an hour.') })
317      })
318    }
319    await say($, {
320      kind: 'ask',
321      title: titled(t('Cache kühlt ab', 'Cache is cooling')),
322      text: t(`Noch ${minutes} Min. warm. Danach liest die nächste Nachricht den ganzen Kontext neu.`, `Warm for ${minutes} more min. After that the next message re-reads the whole context.`),
323      ...(canKeep ? { ask: id, choices: [keep, t('Abkühlen lassen', 'Let it cool')] } : {}),
324    })
325    return
326  }
327  if (state === 'cold' && coldFor !== lastRequestAt) {
328    coldFor = lastRequestAt
329    pending.delete(`warm-${lastRequestAt}`)
330    if (perSession) {
331      // One pet per active session: this one goes to sleep until the next message.
332      isAsleep = true
333      await say($, { kind: 'bye', title: titled(t('Cache ist kalt', 'Cache is cold')), text: t('Ich lege mich schlafen. Mit der nächsten Nachricht bin ich wieder da.', "I'm going to sleep. I'll be back with your next message.") })
334    } else {
335      await say($, { kind: 'cold', title: titled(t('Cache ist kalt', 'Cache is cold')), text: t('Die nächste Nachricht liest den ganzen Kontext neu.', 'The next message re-reads the whole context.') })
336    }
337  }
338}
339
340async function poll($: EngineInterface) {
341  if (!statusPath) return
342  await checkCache($).catch(() => {})
343  if (pending.size > 0) {
344    const at = await $.fs.stat(`${statusPath}.answer`).then(x => x.mtimeMs).catch(() => 0)
345    if (at && at !== answerAt) {
346      answerAt = at
347      const a = parseAnswer(String(await $.fs.read(`${statusPath}.answer`).catch(() => '')))
348      const done = a ? pending.get(a.id) : undefined
349      if (a && done) {
350        pending.delete(a.id)
351        done(a.choice)
352      }
353    }
354  }
355  if (suggestViaPet && watchStepsUntil && lastDone) {
356    if ((await $.clock.now()) > watchStepsUntil) {
357      watchStepsUntil = 0
358      return
359    }
360    const s = (await read($, NEXT_STEPS as any).catch(() => null)) as { turnId: string; items: string[] } | null
361    if (!s || !s.items?.length || s.turnId === stepsShownFor) return
362    stepsShownFor = s.turnId
363    watchStepsUntil = 0
364    const id = `ns-${s.turnId}`
365    for (const key of [...pending.keys()]) if (key.startsWith('ns-')) pending.delete(key)
366    pending.set(id, text => void sendSuggestion($, text))
367    recap = { ...(recap ?? lastDone), ask: id, choices: s.items, free: id }
368    await say($, { ...lastDone, ask: id, choices: s.items, free: id })
369  }
370}
371
372async function sendSuggestion($: EngineInterface, text: string) {
373  await say($, { kind: 'work', title: titled(t('Los geht’s', 'On it')), text: cut(text, 120) })
374  await $.prompt.submit({ text }).catch(() => $.prompt.fill({ text }).catch(() => {}))
375}
376
377async function say($: EngineInterface, note: PetNote) {
378  if (!statusPath) return
379  const now = await $.clock.now()
380  // Typing: a note that answers something names its id; everything else goes to this session's prompt.
381  const typed = canType ? { ...note, free: note.free ?? promptId() } : { ...note, free: undefined }
382  // name: the project, shown under the pet all the time
383  const extra = { name: project, remind: note.kind === 'ask' && remindSec ? String(remindSec) : '', ...(canType ? { type: 'on', hotkey, typelabel: note.free ? t('Eigene Antwort …', 'Write your own answer …') : '' } : {}) }
384  await $.fs.write(statusPath, statusText(typed, now, look, extra)).catch(() => {})
385  // asleep (one pet per active session, cache cold): the note only reaches a pet that still runs
386  if (now - started > RESTART_MS && !isAsleep) void ensure($).catch(() => {})
387}
388
389type AskQuestion = { question: string; header?: string; multiSelect?: boolean; type?: string; options?: Array<{ label: string }> }
390
391const titled = (what: string) => (project ? `${project} · ${what}` : what)
392
393// Builds the pet when it is missing or older than its source, and starts it (a second start
394// exits at once: the pet holds a named mutex).
395const BUILD =
396  "Get-Process ModBoxPet -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; " +
397  'New-Item -ItemType Directory -Force $env:PET_DIR | Out-Null; ' +
398  '$csc = Join-Path $env:WINDIR "Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe"; ' +
399  'if (-not (Test-Path $csc)) { $csc = Join-Path $env:WINDIR "Microsoft.NET\\Framework\\v4.0.30319\\csc.exe" }; ' +
400  '& $csc -nologo -target:winexe -optimize+ "-out:$env:PET_EXE" -r:System.Windows.Forms.dll -r:System.Drawing.dll $env:PET_SRC; exit $LASTEXITCODE'
401// Waits for the pet's click on one permission request: prints the answer file once it names the
402// request, gives up when the status no longer offers it (answered elsewhere) or the time is over.
403const WAIT =
404  '$end = (Get-Date).AddSeconds([int]$env:PET_WAIT); ' +
405  'while ((Get-Date) -lt $end) { ' +
406  "if (Test-Path $env:PET_ANSWER) { $a = Get-Content -Raw $env:PET_ANSWER -ErrorAction SilentlyContinue; if ($a -match ('(?m)^id=' + [regex]::Escape($env:PET_ID) + '\\r?$')) { $a; exit 0 } }; " +
407  "$s = Get-Content -Raw $env:PET_STATUS -ErrorAction SilentlyContinue; if ($s -notmatch ('(?m)^ask=' + [regex]::Escape($env:PET_ID) + '\\r?$')) { exit 1 }; " +
408  'Start-Sleep -Milliseconds 250 }; exit 2'
409const START = "Start-Process -FilePath $env:PET_EXE -ArgumentList ('\"' + $env:PET_STATUS + '\"'), $env:PET_SECS"
410
411// The script that signs the pet: Sign Guard's, or as there the sign-binary skill's when none is
412// set; nothing when that is not there either.
413async function signer($: EngineInterface): Promise<string> {
414  if (signScript) return signScript
415  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
416  const skill = `${home}\\.claude\\skills\\sign-binary\\sign.ps1`
417  return home && (await $.fs.exists(skill).catch(() => false)) ? skill : ''
418}
419
420async function ensure($: EngineInterface): Promise<string | null> {
421  if (!isWindows || !statusPath) return null
422  started = await $.clock.now()
423  const home = (await $.env.get('LOCALAPPDATA')) || ''
424  if (!home) return t('LOCALAPPDATA fehlt', 'LOCALAPPDATA is missing')
425  const dir = `${home}\\mod-box\\pet`
426  const exe = `${dir}\\ModBoxPet.exe`
427  const src = `${$.plugin.root}/mods/pet/overlay/Pet.cs`.replace(/\//g, '\\')
428  const exeAt = await $.fs.stat(exe).then(s => s.mtimeMs).catch(() => 0)
429  const srcAt = await $.fs.stat(src).then(s => s.mtimeMs).catch(() => 0)
430  const env = { PET_DIR: dir, PET_EXE: exe, PET_SRC: src, PET_STATUS: statusPath, PET_SECS: bubbleSec }
431  if (!exeAt || exeAt < srcAt) {
432    const built = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', BUILD], { env, timeoutMs: 60_000 })
433    if (built.exitCode !== 0) return cut(built.stdout || built.stderr || 'csc failed', 200)
434    const script = await signer($)
435    if (script) {
436      await $.process
437        .run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Exe', exe], { timeoutMs: 120_000 })
438        .catch(() => null)
439    }
440  }
441  const run = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', START], { env, timeoutMs: 15_000 })
442  return run.exitCode === 0 ? null : cut(run.stderr || 'start failed', 200)
443}
444
445// One short Haiku call, its tokens booked for the pet.
446async function haiku($: EngineInterface, system: string, prompt: string, maxTokens: number): Promise<string | null> {
447  const r = await $.model.complete({ model: MODEL, maxTokens, timeoutMs: 15_000, system, prompt })
448  if (r.usage) {
449    await update($, USAGE, b => addUsage(b, 'pet', MODEL, r.usage!)).catch(() => {})
450    const total = await $.store.get(USAGE_STORE).catch(() => null)
451    await $.store.set(USAGE_STORE, addUsage(total as UsageBook | null, 'pet', MODEL, r.usage)).catch(() => {})
452  }
453  return r.isAnswered && r.text.trim() ? r.text : null
454}
455
456async function sum($: EngineInterface, answer: string): Promise<string | null> {
457  const text = await haiku(
458    $,
459    'You are a friendly desktop pet watching a coding assistant. In at most THREE short sentences (max 45 words) say what ' +
460      'the assistant just did or found and what is still open, for the person at the desk, so they can answer without ' +
461      `reading the whole reply. ${lang === 'de' ? 'Answer in German, informal (du).' : 'Answer in English.'} ` +
462      'No markdown, no quotes, no greeting, no question at the end.',
463    `<reply>\n${answer.slice(0, 4000)}\n</reply>`,
464    140,
465  )
466  return text ? cut(text.replace(/\s*\n+\s*/g, ' '), 320) : null
467}
468
469/** Haiku's reply options, one per line: without bullets, numbers or quotes, at most three. Pure. */
470export function parseReplies(text: string): string[] {
471  const lines = text
472    .split(/\r?\n/)
473    .map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').replace(/^["'„“]+|["'“”]+$/g, '').trim())
474    .filter(l => l.length > 0 && l.length <= 80)
475  return [...new Set(lines)].slice(0, 3)
476}
477
478// An answer that ends with a question: two or three short replies the person might give, as buttons.
479async function replies($: EngineInterface, answer: string, question: string): Promise<string[]> {
480  const text = await haiku(
481    $,
482    'A coding assistant ended its reply with a question to the person at the desk. Write the 2 or 3 most likely short ' +
483      'answers that person would send back, one per line, each at most 8 words, the most likely first, different from ' +
484      `each other. ${lang === 'de' ? 'In German, informal (du).' : 'In English.'} Only the answers: no numbering, no quotes.`,
485    `<reply>\n${answer.slice(-2500)}\n</reply>\n<question>${question}</question>`,
486    80,
487  )
488  return text ? parseReplies(text) : []
489}
490
491export const register: Register = (on, options) => {
492  lang = options.language === 'de' ? 'de' : 'en'
493  bubbleSec = String(options.bubble ?? '9')
494  summary = String(options.summary ?? 'off')
495  signScript = String(options.signScript ?? '').trim()
496  petChoice = String(options.kind ?? 'box')
497  look = pickLook(petChoice, String(options.fur ?? 'auto'))
498  perSession = options.perSession === 'on'
499  askViaPet = options.ask !== 'off'
500  suggestViaPet = options.suggest !== 'off'
501  permissions = options.permissions === 'answer' ? 'answer' : 'show'
502  cacheTtlMs = (Number(options.cacheTtl) || 60) * 60_000
503  canType = options.typing === 'on'
504  remindSec = options.remind === 'off' ? 0 : Number(options.remind) || 60
505  hotkey = String(options.hotkey ?? 'ctrl+alt+space') === 'off' ? '' : String(options.hotkey ?? 'ctrl+alt+space')
506  warnMs = options.cacheWarn === 'off' ? 0 : (Number(options.cacheWarn) || 5) * 60_000
507
508  on('session.start', {}, async ($, e, next) => {
509    const r = await next(e)
510    isWindows = (await $.env.get('OS')) === 'Windows_NT'
511    if (!isWindows) return r
512    const temp = (await $.env.get('TEMP')) || (await $.env.get('TMP')) || ''
513    // One pet for all sessions, or (option) one each: then every session has its own status file.
514    const sid = perSession ? `-${String(await $.session.id().catch(() => '')).replace(/[^\w-]/g, '').slice(0, 8)}` : ''
515    statusPath = temp ? `${temp}\\mod-box-pet${sid}.txt` : ''
516    sessionId = String(await $.session.id().catch(() => ''))
517    if (perSession && sessionId) {
518      const mine = (((await $.store.get(LOOKS_STORE).catch(() => null)) ?? {}) as Record<string, PetLook>)[sessionId]
519      if (mine && PETS.includes(mine.pet)) look = mine
520    }
521    listenForLook($)
522    listenForRecap($)
523    listenForPrompt($)
524    const now = (await $.clock.now()) || 1
525    lastRequestAt = now
526    isAsleep = await startsAsleep($, now)
527    coldFor = isAsleep ? lastRequestAt : 0
528    $.clock.every(POLL_MS, () => void poll($).catch(() => {}))
529    project = base(await $.session.cwd().catch(() => ''))
530    await $.command.register({ name: 'pet', description: t('Desktop-Pet: zeigen, verabschieden (stop) oder Figur wählen (z. B. /pet cat calico)', 'Desktop Pet: show it, send it off (stop) or pick a figure (e.g. /pet cat calico)'), argumentHint: '[stop | <figure> [coat]]' })
531    if (isAsleep) {
532      await say($, { kind: 'bye', title: titled(t('Cache ist kalt', 'Cache is cold')), text: t('Ich schlafe, bis du hier wieder schreibst.', "I'm asleep until you write here again.") })
533      return r
534    }
535    void (async () => {
536      await say($, { kind: 'hello', title: titled(t('Hallo!', 'Hello!')), text: t('Ich schaue Claude über die Schulter.', "I'm watching over Claude's shoulder.") })
537      await ensure($)
538    })().catch(() => {})
539    return r
540  })
541
542  on('turn.start', {}, async ($, e, next) => {
543    const r = await next(e)
544    if ((e as { agentId?: string }).agentId) return r
545    isTurnRunning = true
546    await markRequest($)
547    if (isAsleep && statusPath) {
548      isAsleep = false
549      void ensure($).catch(() => {})
550    }
551    steps = 0
552    changed.clear()
553    watchStepsUntil = 0
554    lastDone = null
555    // the last answer stays on a click, its buttons do not (the turn they answered is over)
556    if (recap) recap = { kind: recap.kind === 'ask' ? 'done' : recap.kind, title: recap.title, text: recap.text }
557    for (const key of [...pending.keys()]) if (key.startsWith('ns-')) pending.delete(key)
558    if (e.text.trim()) void say($, { kind: 'work', title: titled(t('Los geht’s', 'On it')), text: cut(e.text, 120) })
559    return r
560  })
561
562  on('tool.call', {}, async ($, e, next) => {
563    if (!statusPath || (e as any).agentId) return next(e)
564    const tool = String(e.tool)
565    // Claude's question: its own hook puts it in the bubble; a "Using AskUserQuestion" here would
566    // race it and could cover the choices.
567    if (tool === 'AskUserQuestion' && askViaPet) return next(e)
568    const input = e as unknown as Record<string, unknown>
569    void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: describe(tool, input, lang === 'de') })
570    const r = await next(e)
571    steps++
572    if (r.deny) {
573      void say($, { kind: 'ask', title: titled(t('Angehalten', 'Held')), text: cut(String(r.deny), 160) })
574    } else if ((r as any).isError) {
575      void say($, { kind: 'error', title: titled(t('Hoppla', 'Oops')), text: t(`${tool} ist fehlgeschlagen.`, `${tool} failed.`) })
576    } else if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) {
577      changed.add(String(input.file_path ?? input.notebook_path ?? ''))
578    }
579    return r
580  })
581
582  on('turn.complete', {}, async ($, e, next) => {
583    const r = await next(e)
584    if (!statusPath || e.agentId) return r
585    isTurnRunning = false
586    await markRequest($)
587    if (e.reason === 'aborted' || e.isAborted) {
588      void say($, { kind: 'ask', title: titled(t('Abgebrochen', 'Stopped')), text: t('Du hast unterbrochen. Ich warte.', 'You stopped it. Waiting for you.') })
589      return r
590    }
591    if (e.reason !== 'answer') {
592      void say($, { kind: 'error', title: titled(t('Fehler', 'Error')), text: t('Der Turn endete mit einem Fehler.', 'The turn ended with an error.') })
593      return r
594    }
595    const facts = [
596      steps ? t(`${steps} Schritt${steps === 1 ? '' : 'e'}`, `${steps} step${steps === 1 ? '' : 's'}`) : '',
597      changed.size ? t(`${changed.size} Datei${changed.size === 1 ? '' : 'en'} geändert`, `${changed.size} file${changed.size === 1 ? '' : 's'} changed`) : '',
598    ].filter(Boolean)
599    // An answer that ends by asking you something: the pet shows that question (it stays, like
600    // Claude's own questions), with the summary in front when Haiku made one.
601    const question = lastQuestion(e.answer)
602    const isQuestion = question !== null
603    const kind: PetKind = isQuestion ? 'ask' : 'done'
604    const title = titled([isQuestion ? t('Frage an dich', 'A question for you') : t('Fertig', 'Done'), ...facts].join(' · '))
605    const first = question ?? (gist(e.answer) || t('Fertig.', 'Done.'))
606    lastDone = { kind, title, text: first }
607    recap = { kind, title, text: compact(e.answer, question) || first }
608    if (suggestViaPet && !isQuestion) watchStepsUntil = (await $.clock.now()) + 45_000
609    void (async () => {
610      await say($, { kind, title, text: first })
611      // the summary and (for a closing question) reply buttons, both from Haiku, side by side
612      const [said, offers] = await Promise.all([
613        summary === 'haiku' && e.answer.trim().length > 200 ? sum($, e.answer).catch(() => null) : Promise.resolve(null),
614        question && suggestViaPet ? replies($, e.answer, question).catch(() => [] as string[]) : Promise.resolve([] as string[]),
615      ])
616      // a new message came meanwhile: this answer is old news
617      if (isTurnRunning || (!said && offers.length < 2)) return
618      const text = said ? (question ? `${said}\n${question}` : said) : first
619      lastDone = { kind, title, text }
620      if (said) recap = { kind, title, text }
621      if (offers.length < 2) return void (await say($, lastDone))
622      const id = `ns-q${await $.clock.now()}`
623      for (const key of [...pending.keys()]) if (key.startsWith('ns-')) pending.delete(key)
624      pending.set(id, choice => void sendSuggestion($, choice))
625      recap = { ...(recap ?? lastDone), ask: id, choices: offers, free: id }
626      await say($, { ...lastDone, ask: id, choices: offers, free: id })
627    })().catch(() => {})
628    return r
629  })
630
631  // Claude's question: as buttons in the bubble too. Whichever answers first, the pet or the dialog
632  // in Claude Code, counts; the other is closed (a hook returning while next is pending aborts it).
633  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
634    const call = e as unknown as { questions?: AskQuestion[]; tool_use_id?: string; agentId?: string }
635    if (!statusPath || !askViaPet || call.agentId) return next(e)
636    const questions = call.questions ?? []
637    const isPickable = questions.length > 0 && questions.every(q => !q.multiSelect && (((q.type ?? 'choice') === 'choice' && (q.options?.length ?? 0) >= 2) || (canType && q.type === 'text')))
638    const ask = (q: AskQuestion | undefined) => titled(q?.header ? `${t('Frage', 'Question')} · ${q.header}` : t('Frage an dich', 'A question for you'))
639    if (!isPickable) {
640      void say($, { kind: 'ask', title: ask(questions[0]), text: cut(questions[0]?.question ?? t('Claude fragt dich etwas.', 'Claude has a question.'), 200) })
641      return next(e)
642    }
643    const beneath = next(e)
644    beneath.catch(() => {})
645    const answers: Record<string, string> = {}
646    for (const [n, q] of questions.entries()) {
647      const id = `q-${call.tool_use_id ?? 'x'}-${n}`
648      await say($, { kind: 'ask', title: ask(q), text: cut(q.question, 200), ask: id, choices: (q.options ?? []).map(o => o.label), free: id })
649      const won = await Promise.race([beneath.then(r => ({ r, c: null as string | null })), waitPick(id).then(c => ({ r: null, c }))])
650      if (won.c === null) {
651        pending.delete(id)
652        void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: t('Antwort erhalten, weiter geht’s.', 'Got the answer, carrying on.') })
653        return won.r!
654      }
655      answers[q.question] = won.c
656    }
657    void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: t('Antwort erhalten, weiter geht’s.', 'Got the answer, carrying on.') })
658    return { result: { questions, answers } } as any
659  })
660
661  // A permission request: shown, so it is not missed. With option permissions "answer" the bubble
662  // offers "allow once" and "deny" for PERMIT_WAIT_S seconds; no rule is ever added ("always" stays
663  // with Claude Code's dialog). Another hook's decision comes first; a dialog answered meanwhile
664  // takes the buttons away again.
665  on('classic.PermissionRequest', {}, async ($, e, next) => {
666    const r = await next(e)
667    const req = e as unknown as { tool_name?: string; tool_input?: Record<string, unknown> }
668    if (!statusPath || !req.tool_name || (r as { decision?: unknown })?.decision) return r
669    // Claude Code opens its question dialog through a permission request too: the question's own
670    // hook shows its choices, an "allow" here would skip the question unanswered.
671    if (req.tool_name === 'AskUserQuestion') return r
672    // A plan to approve is the person's decision in Claude Code alone: shown, never answered here.
673    const isPersonal = PERSONAL_TOOLS.includes(req.tool_name)
674    const what = describe(req.tool_name, req.tool_input ?? {}, lang === 'de')
675    const title = titled(t('Genehmigung nötig', 'Permission needed'))
676    if (permissions !== 'answer' || isPersonal) {
677      void say($, { kind: 'ask', title, text: `${what} · ${t('bitte in Claude Code bestätigen', 'please confirm in Claude Code')}` })
678      return r
679    }
680    const allow = t('Einmal erlauben', 'Allow once')
681    const deny = t('Ablehnen', 'Deny')
682    const id = `perm-${(await $.clock.now()).toString(36)}-${Math.random().toString(36).slice(2, 8)}`
683    await say($, { kind: 'ask', title, text: what, ask: id, choices: [allow, deny] })
684    const gone = () => void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: t('In Claude Code beantwortet.', 'Answered in Claude Code.') })
685    next.signal?.addEventListener('abort', gone)
686    const env = { PET_ANSWER: `${statusPath}.answer`, PET_STATUS: statusPath, PET_ID: id, PET_WAIT: String(PERMIT_WAIT_S) }
687    const run = await $.process
688      .run(['powershell', '-NoProfile', '-NonInteractive', '-Command', WAIT], { env, timeoutMs: (PERMIT_WAIT_S + 15) * 1000 })
689      .catch(() => null)
690    next.signal?.removeEventListener('abort', gone)
691    const picked = run && run.exitCode === 0 ? parseAnswer(run.stdout) : null
692    const choice = picked && picked.id === id ? picked.choice : null
693    if (choice === allow) {
694      void say($, { kind: 'work', title: titled(t('Erlaubt', 'Allowed')), text: what })
695      return { ...r, decision: { behavior: 'allow' } } as typeof r
696    }
697    if (choice === deny) {
698      void say($, { kind: 'error', title: titled(t('Abgelehnt', 'Denied')), text: what })
699      return { ...r, decision: { behavior: 'deny', message: t('Im Desktop-Pet abgelehnt.', 'Denied in the desktop pet.') } } as typeof r
700    }
701    // no click in time: the dialog decides, as without the pet
702    if (!next.signal?.aborted) void say($, { kind: 'ask', title, text: `${what} · ${t('bitte in Claude Code bestätigen', 'please confirm in Claude Code')}` })
703    return r
704  })
705
706  on('command.run', { command: 'pet' }, async ($, e) => {
707    if (!isWindows) return { text: t('Das Desktop-Pet gibt es nur unter Windows.', 'The desktop pet is Windows only.') }
708    const [first = '', second = ''] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
709    const next = parseLook(String(e.args ?? ''), look)
710    if (first !== 'stop') isAsleep = false
711    if (next) {
712      await ensure($)
713      await applyLook($, next, first, second !== '')
714      const name = `${look.pet}${look.fur !== 'auto' ? ` (${look.fur})` : ''}`
715      return { text: perSession ? t(`Das Pet dieser Sitzung ist jetzt: ${name}.`, `This session's pet is now: ${name}.`) : t(`Das Pet ist jetzt: ${name}.`, `The pet is now: ${name}.`) }
716    }
717    if (first && first !== 'stop') return { text: t(`Figuren: ${PETS.join(', ')}, random. Fell: ${FURS.slice(1).join(', ')}.`, `Figures: ${PETS.join(', ')}, random. Coats: ${FURS.slice(1).join(', ')}.`) }
718    if (first === 'stop') {
719      await say($, { kind: 'bye', title: titled(t('Tschüss!', 'Bye!')), text: t('Bis später.', 'See you later.') })
720      return { text: t('Das Pet verabschiedet sich.', 'The pet says goodbye.') }
721    }
722    const problem = await ensure($)
723    await say($, { kind: 'hello', title: titled(t('Da bin ich', 'Here I am')), text: t('Ich schaue Claude über die Schulter.', "I'm watching over Claude's shoulder.") })
724    return { text: problem ? t(`Das Pet ließ sich nicht starten: ${problem}`, `The pet did not start: ${problem}`) : t('Das Pet ist da (rechte untere Ecke, verschiebbar).', 'The pet is there (bottom right, drag it anywhere).') }
725  })
726
727  on('session.end', {}, async ($, e, next) => {
728    if (statusPath && e.reason !== 'clear') await say($, { kind: 'bye', title: titled(t('Tschüss!', 'Bye!')), text: t('Bis zum nächsten Mal.', 'See you next time.') })
729    return next(e)
730  })
731}
732
mods/settings/hooks/usage.ts 89 lines
1// Token usage of the mods that call a model themselves (Next Steps, Heads-up, Catch Me Up,
2// Handoff Note, keep-warm in the Cache Meter). Pure data and arithmetic: each of those mods records
3// its calls with its own $ (session in $.state "mb_usage", total in $.store "usage-total"),
4// the window only reads. Costs are an estimate from list prices.
5
6export type ModUsage = {
7  calls: number
8  input: number
9  output: number
10  cacheRead: number
11  cacheWrite: number
12  /** Estimated US dollars of the calls whose model had a price. */
13  usd: number
14  /** Calls whose model had no price in the table. */
15  unpriced: number
16}
17
18export type UsageBook = Record<string, ModUsage>
19
20export const USAGE_STORE = 'usage-total'
21
22type Usage = {
23  input_tokens: number
24  output_tokens: number
25  cache_read_input_tokens?: number | null
26  cache_creation_input_tokens?: number | null
27}
28
29// $ per million tokens: [cache read, cache write (1 h), output]; base input is half the 1 h write.
30// List prices as Cache Tax keeps them (September 2026). Longer family names first.
31const PRICES: Array<[string, number, number, number]> = [
32  ['fable-5-1', 0.25, 20, 50],
33  ['fable-5', 1, 20, 50],
34  ['opus-5', 0.5, 10, 25],
35  ['opus-4', 0.5, 10, 25],
36  ['opus', 0.5, 10, 25],
37  ['sonnet-5', 0.2, 4, 10],
38  ['sonnet', 0.3, 6, 15],
39  ['haiku', 0.1, 2, 5],
40]
41
42export function priceOf(model: string): [number, number, number] | null {
43  const m = model.toLowerCase().replace(/[\s.]+/g, '-')
44  for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
45  return null
46}
47
48export function costOf(model: string, u: Usage): number | null {
49  const p = priceOf(model)
50  if (!p) return null
51  const [read, write, output] = p
52  return ((u.cache_read_input_tokens ?? 0) * read + (u.cache_creation_input_tokens ?? 0) * write + u.input_tokens * (write / 2) + u.output_tokens * output) / 1e6
53}
54
55export function addUsage(book: UsageBook | null | undefined, id: string, model: string, u: Usage): UsageBook {
56  const prev = book?.[id] ?? { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, unpriced: 0 }
57  const usd = costOf(model, u)
58  return {
59    ...(book ?? {}),
60    [id]: {
61      calls: prev.calls + 1,
62      input: prev.input + u.input_tokens,
63      output: prev.output + u.output_tokens,
64      cacheRead: prev.cacheRead + (u.cache_read_input_tokens ?? 0),
65      cacheWrite: prev.cacheWrite + (u.cache_creation_input_tokens ?? 0),
66      usd: prev.usd + (usd ?? 0),
67      unpriced: prev.unpriced + (usd == null ? 1 : 0),
68    },
69  }
70}
71
72export function tokensOf(m: ModUsage): number {
73  return m.input + m.output + m.cacheRead + m.cacheWrite
74}
75
76export function sumBook(book: UsageBook | null | undefined): ModUsage {
77  const out = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, unpriced: 0 }
78  for (const m of Object.values(book ?? {})) {
79    out.calls += m.calls
80    out.input += m.input
81    out.output += m.output
82    out.cacheRead += m.cacheRead
83    out.cacheWrite += m.cacheWrite
84    out.usd += m.usd
85    out.unpriced += m.unpriced
86  }
87  return out
88}
89
mods/pet/types/contract.d.ts 17 lines
1/** Model calls the mod made itself: counted per session, priced at list price. */
2export type ModUsage = {
3  calls: number
4  input: number
5  output: number
6  cacheRead: number
7  cacheWrite: number
8  usd: number
9  unpriced: number
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'pet': { usage: Record<string, ModUsage> }
15  }
16}
17