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…

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.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install pet --marketplace LupusMalusDeviant/claude-mods
| Command | What it does |
|---|---|
/pet | show the desktop pet |
/pet <figure> [coat] | pick a figure, e.g. /pet cat calico |
/pet stop | send it off |
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
bubble | 9 | How long news stays in the bubble. Questions and errors stay until the next news. |
summary | off | off: 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. |
kind | box | Who stands on the desktop. random: a different one each session. /pet cat calico switches it too. |
fur | auto | The coat colour of cat, dog and bunny. |
perSession | off | on: 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. |
cacheWarn | 5 | That 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. |
ask | on | Claude's questions (AskUserQuestion) appear as buttons in the bubble too; the first answer counts, the pet's or the dialog's. |
remind | 60 | A question still open whose bubble you hid (double-click, menu) comes back after that many seconds. Not while the pet is snoozed. |
suggest | on | With 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. |
permissions | show | show: 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. |
typing | off | on: 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. |
hotkey | ctrl+alt+space | Opens the input box from anywhere, also from a game. With several pets the first one gets it. |
language | en | Language of the texts this mod shows and writes. |
signScript | `` | PowerShell script called as <script> -Exe <file>. Empty: ~/.claude/skills/sign-binary/sign.ps1 |
cacheTtl | 60 | 60 on a subscription (Pro/Max), 5 for the API's default cache. |
MIT, see LICENSE.
hooks/entry.ts 9 lines1// 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}
9mods/pet/hooks/register.tsx 732 lines1// 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}
732mods/settings/hooks/usage.ts 89 lines1// 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}
89mods/pet/types/contract.d.ts 17 lines1/** 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