SLOPSHOPPER

pet

A desktop pet and little companion on the Windows desktop, always on top and in front of borderless games: a cardboard box or one of twelve figures (cat, dog…

newguardcommandtoastmodelprocess
★ 1v0.7.1MITupdated 2026-10-26LupusMalusDeviant/mod-box/plugins/pet
A shopper browsing a rack in a slop shop
README

Desktop Pet

A desktop pet and little companion on the Windows desktop, always on top and in front of borderless games: 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 and sums up each answer; Claude's questions, Next Steps' suggestions and reply options are buttons in the bubble, and with typing on a hotkey opens a box to answer without leaving the game. A click on the pet shows the last answer in short; an unanswered bubble shrinks to one line. Optionally one pet per active session, each with its own figure and a name tag, asleep while its prompt cache is cold; it warns before the cache goes cold, and its menu keeps the cache warm (1 h, 3 h, always, off). Other mods at the pet: a command Blast Radius holds, a task from Human in the Loop (done or an option with one click) and the CI Watch result come up in the bubble, and stand in short beside its name. Built once with the C# compiler that ships with Windows and signed when a signing script is there. /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/mod-box

Or from a shell:

claude plugin marketplace add LupusMalusDeviant/mod-box
claude plugin install pet@mod-box

Hooks into

PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop, SessionStart, SessionEnd

Works with

Desktop Pet needs no other mod. These add to it when installed beside it:

ModWhat it addsWithout it
Cache Meter or Cache Taxa Keep warm (1 h) button in the warning before the prompt cache goes cold (/keepwarm)the warning comes without the button
Next Stepsits suggestions for the next step as buttons in the bubbleno next-step suggestions; a closing question still gets reply buttons (Haiku)
the sign-binary skill or Sign Guardsigns the pet's program after it is built (option signScript)the pet runs unsigned

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); a command Blast Radius holds gets "Proceed" and "Cancel" too. 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 968 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
66// Next Steps' suggestions: inside the Mod Box, or the original plugin installed beside the pet.
67// (Two references with literal names: the engine's scan reads each one.)
68const NEXT_STEPS = { plugin: 'mod-box', key: 'ns_steps' } as const
69const NEXT_STEPS_OWN = { plugin: 'next-steps', key: 'steps' } as const
70const POLL_MS = 600
71// The prompt cache: how long it stays warm (the Cache Meter's setting), when the pet warns.
72let cacheTtlMs = 60 * 60_000
73let warnMs = 5 * 60_000
74let lastRequestAt = 0
75let isTurnRunning = false
76let warnedFor = 0
77let coldFor = 0
78let isAsleep = false
79let cacheCheckedAt = 0
80const CACHE_CHECK_MS = 5_000
81const KEEP_WARM_ARGS = '1h'
82// Tools whose permission request is the person's own decision (a plan to approve): never answered by the pet.
83const PERSONAL_TOOLS = ['ExitPlanMode', 'EnterPlanMode']
84// How long a permission request waits for a click in the pet (option permissions "answer").
85const PERMIT_WAIT_S = 90
86const changed = new Set<string>()
87
88const MODEL = 'haiku'
89const USAGE = { plugin: 'pet', key: 'usage' } as const
90// The pet is started again at most this often (it exits after two quiet hours or a "bye").
91const RESTART_MS = 60_000
92
93const base = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() || p
94const cut = (s: string, n: number) => {
95  const one = s.replace(/\s+/g, ' ').trim()
96  return one.length > n ? `${one.slice(0, n - 1)}…` : one
97}
98
99/** One line on a tool call, in the person's words. Pure, for the tests. */
100export function describe(tool: string, input: Record<string, unknown>, de: boolean): string {
101  const tr = (a: string, b: string) => (de ? a : b)
102  const s = (k: string) => String(input[k] ?? '')
103  switch (tool) {
104    case 'Read':
105      return tr(`Lese ${base(s('file_path'))}`, `Reading ${base(s('file_path'))}`)
106    case 'Edit':
107    case 'MultiEdit':
108    case 'NotebookEdit':
109      return tr(`Ändere ${base(s('file_path') || s('notebook_path'))}`, `Changing ${base(s('file_path') || s('notebook_path'))}`)
110    case 'Write':
111      return tr(`Schreibe ${base(s('file_path'))}`, `Writing ${base(s('file_path'))}`)
112    case 'Bash':
113    case 'PowerShell':
114      return s('description') ? cut(s('description'), 90) : tr(`Führe aus: ${cut(s('command'), 70)}`, `Running: ${cut(s('command'), 70)}`)
115    case 'Grep':
116      return tr(`Suche nach „${cut(s('pattern'), 40)}“`, `Searching for "${cut(s('pattern'), 40)}"`)
117    case 'Glob':
118      return tr(`Suche Dateien: ${cut(s('pattern'), 50)}`, `Looking for files: ${cut(s('pattern'), 50)}`)
119    case 'WebFetch': {
120      const host = /^https?:\/\/([^/]+)/.exec(s('url'))?.[1] ?? s('url')
121      return tr(`Lese Webseite ${cut(host, 50)}`, `Reading ${cut(host, 50)}`)
122    }
123    case 'WebSearch':
124      return tr(`Suche im Web: ${cut(s('query'), 60)}`, `Searching the web: ${cut(s('query'), 60)}`)
125    case 'Agent':
126    case 'Task':
127      return tr(`Schicke einen Helfer los: ${cut(s('description'), 60)}`, `Sending a helper: ${cut(s('description'), 60)}`)
128    case 'TodoWrite':
129      return tr('Plane die nächsten Schritte', 'Planning the next steps')
130    default: {
131      const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
132      if (mcp) return tr(`Nutze ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}`, `Using ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}`)
133      return tr(`Benutze ${tool}`, `Using ${tool}`)
134    }
135  }
136}
137
138/** The first sentence or two of an answer, without markdown. Pure, for the tests. */
139export function gist(answer: string, max = 170): string {
140  const plain = answer
141    .replace(/```[\s\S]*?```/g, ' ')
142    .replace(/`([^`]*)`/g, '$1')
143    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
144    .replace(/^#+\s*/gm, '')
145    .replace(/^\s*[-*]\s+/gm, '')
146    .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
147    .replace(/\|/g, ' ')
148    .replace(/\s+/g, ' ')
149    .trim()
150  const sentences = plain.match(/[^.!?]+[.!?]+/g) ?? [plain]
151  let out = ''
152  for (const sentence of sentences) {
153    if (out && (out + sentence).length > max) break
154    out += sentence
155  }
156  return cut(out || plain, max)
157}
158
159/** An answer in short for a click on the pet: its gist, and the question it ends with. Pure. */
160export function compact(answer: string, question: string | null, max = 320): string {
161  const head = gist(answer, question ? 200 : max)
162  return question && !head.includes(question) ? `${head}\n${question}` : head
163}
164
165/** The question an answer ends with (in its last paragraph), without markdown; null when none. Pure. */
166export function lastQuestion(answer: string, max = 200): string | null {
167  const paragraphs = answer.replace(/```[\s\S]*?```/g, ' ').split(/\n\s*\n/).map(p => p.trim()).filter(Boolean)
168  const last = paragraphs.at(-1) ?? ''
169  if (!last.includes('?')) return null
170  const sentences = gist(last, 100_000).match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? []
171  const question = [...sentences].reverse().find(s => s.trim().endsWith('?'))
172  return question ? cut(question.trim(), max) : null
173}
174
175/** The status file the pet reads: key=value lines, newlines in the text escaped. */
176export function statusText(note: PetNote, at: number, figure?: PetLook, extra: Record<string, string> = {}): string {
177  const esc = (s: string) => s.replace(/\r?\n/g, '\\n')
178  const lines = [`at=${at}`, `kind=${note.kind}`, `title=${esc(note.title)}`, `text=${esc(note.text)}`]
179  if (figure) lines.push(`pet=${figure.pet}`, `fur=${figure.fur}`)
180  if (note.ask && note.choices?.length) lines.push(`ask=${note.ask}`, ...note.choices.map(c => `choice=${esc(c)}`))
181  if (note.free) lines.push(`free=${note.free}`)
182  for (const [k, v] of Object.entries(extra)) if (v) lines.push(`${k}=${esc(v)}`)
183  return [...lines, ''].join('\n')
184}
185
186/** What the pet wrote after a click: the id it answers and the choice. Pure, for the tests. */
187export function parseAnswer(text: string): { id: string; choice: string } | null {
188  const get = (k: string) => new RegExp(`^${k}=(.*)$`, 'm').exec(text)?.[1]?.trim() ?? ''
189  const id = get('id')
190  const choice = get('choice').replace(/\\n/g, '\n')
191  return id && choice ? { id, choice } : null
192}
193
194/** The figure for a session: the one chosen, or any one for "random" (kept for the session). */
195export function pickLook(pet: string, fur: string, roll = Math.random): PetLook {
196  const figure = pet === 'random' ? PETS[1 + Math.floor(roll() * (PETS.length - 1))]! : PETS.includes(pet) ? pet : 'box'
197  const coat = fur === 'random' ? FURS[1 + Math.floor(roll() * (FURS.length - 1))]! : FURS.includes(fur) ? fur : 'auto'
198  return { pet: figure, fur: coat }
199}
200
201// One pet per session: each session's own figure, kept in $.store (the latest LOOKS_KEPT sessions).
202const LOOKS_STORE = 'pet:looks'
203const LOOKS_KEPT = 40
204let sessionId = ''
205// ...and when each one last sent a request, so a session that only starts again (the plugin
206// reloaded, the app reopened) knows whether its cache is still warm.
207const ACTIVE_STORE = 'pet:active'
208
209async function markRequest($: EngineInterface) {
210  lastRequestAt = (await $.clock.now()) || 1
211  if (!perSession || !sessionId) return
212  const all = ((await $.store.get(ACTIVE_STORE).catch(() => null)) ?? {}) as Record<string, number>
213  delete all[sessionId]
214  const kept = Object.entries(all).slice(-(LOOKS_KEPT - 1))
215  await $.store.set(ACTIVE_STORE, Object.fromEntries([...kept, [sessionId, lastRequestAt]])).catch(() => {})
216}
217
218// One pet per active session, at the start: a session whose cache is cold (or an old one taken up
219// again, its time unknown) has no pet until its next message; a pet still running for it leaves.
220async function startsAsleep($: EngineInterface, now: number): Promise<boolean> {
221  if (!perSession || !sessionId) return false
222  const known = (((await $.store.get(ACTIVE_STORE).catch(() => null)) ?? {}) as Record<string, number>)[sessionId]
223  if (typeof known === 'number') {
224    lastRequestAt = known
225    return cacheState(known, now, cacheTtlMs, 0) === 'cold'
226  }
227  const isOld = (await $.session.messages().catch(() => [])).length > 0
228  if (isOld) lastRequestAt = 0
229  return isOld
230}
231
232/** "cat calico", "random", "fox": the figure and coat a menu click or /pet names. Pure, for the tests. */
233export function parseLook(text: string, current: PetLook): PetLook | null {
234  const [first = '', second = ''] = text.trim().toLowerCase().split(/\s+/)
235  if (first !== 'random' && !PETS.includes(first)) return null
236  return pickLook(first, FURS.includes(second) || second === 'random' ? second : current.fur)
237}
238
239// The figure changes: for this session alone (one pet per session) or as the setting for all.
240async function applyLook($: EngineInterface, next: PetLook, named: string, hasCoat: boolean) {
241  look = next
242  if (perSession && sessionId) {
243    const all = ((await $.store.get(LOOKS_STORE).catch(() => null)) ?? {}) as Record<string, PetLook>
244    delete all[sessionId]
245    const kept = Object.entries(all).slice(-(LOOKS_KEPT - 1))
246    await $.store.set(LOOKS_STORE, Object.fromEntries([...kept, [sessionId, next]])).catch(() => {})
247  } else {
248    await $.config.set({ key: 'mod-box.petKind', value: named }).catch(() => null)
249    if (hasCoat) await $.config.set({ key: 'mod-box.petFur', value: next.fur }).catch(() => null)
250  }
251  await say($, { kind: 'hello', title: titled(t('Da bin ich', 'Here I am')), text: t('Wie gefalle ich dir?', 'How do I look?') })
252}
253
254// The menu's pick waits for a click at any time.
255// A click on the pet asks for the last answer again (id "recap").
256function listenForRecap($: EngineInterface) {
257  pending.set('recap', () => {
258    listenForRecap($)
259    const last: PetNote = recap ?? { kind: 'hello', title: titled(t('Zuletzt', 'Last')), text: t('In dieser Sitzung gab es noch keine Antwort.', 'No answer in this session yet.') }
260    void (async () => {
261      const lines = modLines(held, openTasks, ci, await $.clock.now(), lang === 'de')
262      const note = lines.length ? { ...last, text: `${last.text}\n${lines.join('\n')}` } : last
263      // no buttons of its own: the first open task's, so it can be done from here
264      const task = !note.choices?.length && !held ? openTasks[0] : undefined
265      if (!task) return void (await say($, note))
266      const own = taskFor($, task)
267      await say($, { ...note, ask: own.ask, choices: own.choices, free: own.free ?? note.free })
268    })().catch(() => {})
269  })
270}
271
272function listenForLook($: EngineInterface) {
273  pending.set('look', choice => {
274    listenForLook($)
275    const next = parseLook(choice, look)
276    const [named = '', coat = ''] = choice.trim().toLowerCase().split(/\s+/)
277    if (next) void applyLook($, next, named, coat !== '').catch(() => {})
278  })
279}
280
281const promptId = () => `prompt-${sessionId.replace(/[^\w-]/g, '').slice(0, 8) || 'x'}`
282
283// Typed in the pet with nothing open: the session's next prompt (queued when a turn runs).
284function listenForPrompt($: EngineInterface) {
285  if (!canType) return
286  pending.set(promptId(), text => {
287    listenForPrompt($)
288    void sendSuggestion($, text).catch(() => {})
289  })
290}
291
292const waitPick = (id: string) => new Promise<string>(resolve => pending.set(id, resolve))
293
294// Reads the pet's answer file while something waits for a click, and offers Next Steps'
295// suggestions once they are there.
296/** Where the cache stands: minutes left while warm, or cold; null before the first request. Pure. */
297export function cacheState(lastAt: number, now: number, ttlMs: number, warnBeforeMs: number): 'warm' | 'cooling' | 'cold' | null {
298  if (!lastAt) return null
299  const left = lastAt + ttlMs - now
300  return left <= 0 ? 'cold' : warnBeforeMs > 0 && left <= warnBeforeMs ? 'cooling' : 'warm'
301}
302
303// Keep-warm (the Cache Meter's /keepwarm) holds the cache: no warning, no sleep meanwhile.
304async function isKeptWarm($: EngineInterface, now: number): Promise<boolean> {
305  if ((await $.store.get('always').catch(() => null)) === true) return true
306  const deadline = await $.store.get(`deadline:${sessionId}`).catch(() => null)
307  return typeof deadline === 'number' && deadline > now
308}
309
310async function checkCache($: EngineInterface) {
311  if (isTurnRunning || !lastRequestAt) return
312  const now = await $.clock.now()
313  if (now - cacheCheckedAt < CACHE_CHECK_MS) return
314  cacheCheckedAt = now
315  const state = cacheState(lastRequestAt, now, cacheTtlMs, warnMs)
316  if (state === 'warm' || state === null) return
317  if (await isKeptWarm($, now)) return
318  if (state === 'cooling' && warnedFor !== lastRequestAt) {
319    warnedFor = lastRequestAt
320    const minutes = Math.max(1, Math.ceil((lastRequestAt + cacheTtlMs - now) / 60_000))
321    const keep = t('Warm halten (1 Std.)', 'Keep warm (1 h)')
322    const canKeep = (await $.command.list().catch(() => [] as Array<{ name: string }>)).some(c => c.name === 'keepwarm')
323    const id = `warm-${lastRequestAt}`
324    if (canKeep) {
325      pending.set(id, choice => {
326        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.') })
327        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}.`)))
328        void say($, { kind: 'work', title: titled(t('Cache', 'Cache')), text: t('Ich halte ihn eine Stunde warm.', 'Keeping it warm for an hour.') })
329      })
330    }
331    await say($, {
332      kind: 'ask',
333      title: titled(t('Cache kühlt ab', 'Cache is cooling')),
334      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.`),
335      ...(canKeep ? { ask: id, choices: [keep, t('Abkühlen lassen', 'Let it cool')] } : {}),
336    })
337    return
338  }
339  if (state === 'cold' && coldFor !== lastRequestAt) {
340    coldFor = lastRequestAt
341    pending.delete(`warm-${lastRequestAt}`)
342    if (perSession) {
343      // One pet per active session: this one goes to sleep until the next message.
344      isAsleep = true
345      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.") })
346    } else {
347      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.') })
348    }
349  }
350}
351
352// ---- Other Mod Box mods at the pet --------------------------------------------------------------
353// Blast Radius puts a held command in $.store (blast:held:<session>) and takes the pet's answer
354// beside it (blast:pet:<session>); Human in the loop takes a click on a task the same way
355// (hitl:pet:<session>); CI Watch's state is read. What they have to say stands beside the name
356// (badge); a new held command, task or CI result comes up in the bubble with its buttons.
357type HeldCall = { id: string; label: string; summary: string; command: string }
358export type PetTask = { id: number; title: string; why?: string; kind: 'do' | 'answer' | 'choose'; options?: readonly string[]; state: string }
359export type PetCi = {
360  sha: string
361  phase: string
362  message: string
363  endedAt: number
364  isDismissed: boolean
365  failed: { name: string; url: string } | null
366  pr: { number: number; url: string } | null
367  runs: Array<{ url: string }>
368} | null
369
370const HITL_TASKS = { plugin: 'mod-box', key: 'hitl_tasks' } as const
371const HITL_TASKS_OWN = { plugin: 'human-in-the-loop', key: 'tasks' } as const
372const CI_WATCH = { plugin: 'mod-box', key: 'ci_watch' } as const
373const CI_WATCH_OWN = { plugin: 'ci-watch', key: 'watch' } as const
374const MOD_CHECK_MS = 1500
375// A green CI result stays in the badge this long; a red one until the next run or it is dismissed.
376const CI_GREEN_MS = 10 * 60_000
377const WARM_ARGS = ['1h', '3h', 'always', 'off']
378const isUrl = (url: string) => /^https:\/\/[\w.-]+\//.test(url)
379
380// The note on screen, so a changed badge rewrites it without a new bubble.
381let shown: { note: PetNote; at: number; extra: Record<string, string> } | null = null
382let badge = ''
383let warmText = ''
384let modsCheckedAt = 0
385let canKeepWarm: boolean | null = null
386let held: HeldCall | null = null
387let heldShown = ''
388let openTasks: PetTask[] = []
389const tasksShown = new Set<number>()
390let ci: PetCi = null
391let ciShown = ''
392
393/** What stands beside the pet's name: a held command, open tasks, CI. Pure, for the tests. */
394export function modBadge(isHeld: boolean, tasks: number, watch: PetCi, now: number, de: boolean): string {
395  const tr = (a: string, b: string) => (de ? a : b)
396  const parts: string[] = []
397  if (isHeld) parts.push(tr('angehalten', 'held'))
398  if (tasks) parts.push(tr(`${tasks} Aufgabe${tasks === 1 ? '' : 'n'}`, `${tasks} task${tasks === 1 ? '' : 's'}`))
399  if (watch && !watch.isDismissed) {
400    if (watch.phase === 'laeuft' || watch.phase === 'warte') parts.push(tr('CI läuft', 'CI running'))
401    else if (watch.phase === 'rot') parts.push(tr('CI rot', 'CI red'))
402    else if (watch.phase === 'gruen' && now - watch.endedAt < CI_GREEN_MS) parts.push(tr('CI grün', 'CI green'))
403  }
404  return parts.join(' · ')
405}
406
407/** How keep-warm stands, for the pet's menu. Pure, for the tests. */
408export function warmLabel(always: boolean, deadline: number, now: number, de: boolean): string {
409  if (always) return de ? 'immer an' : 'always on'
410  if (deadline > now) {
411    const min = Math.ceil((deadline - now) / 60_000)
412    const left = min >= 60 ? `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : ''}` : `${min} min`
413    return de ? `an, noch ${left}` : `on, ${left} left`
414  }
415  return de ? 'aus' : 'off'
416}
417
418/** A task Claude gave the person, as a note: buttons for "done" or the options. Pure, for the tests. */
419export function taskNote(task: PetTask, de: boolean, canAnswer: boolean, canWrite: boolean): PetNote {
420  const tr = (a: string, b: string) => (de ? a : b)
421  const id = `hitl-${task.id}`
422  const text = cut(`#${task.id} ${task.title}`, 160) + (task.why ? `\n${cut(task.why, 160)}` : '')
423  const note: PetNote = { kind: 'ask', title: tr('Aufgabe für dich', 'A task for you'), text }
424  const elsewhere = { ...note, text: `${text}\n${tr('In Claude Code unter My tasks.', 'In Claude Code under My tasks.')}` }
425  if (!canAnswer) return elsewhere
426  if (task.kind === 'do') return { ...note, ask: id, choices: [tr('Erledigt', 'Done')] }
427  if (task.kind === 'choose' && task.options?.length) return { ...note, ask: id, choices: [...task.options] }
428  return task.kind === 'answer' && canWrite ? { ...note, free: id } : elsewhere
429}
430
431/** A finished CI run as a note, and the page its button opens (empty: no button). Pure, for the tests. */
432export function ciNote(watch: NonNullable<PetCi>, de: boolean): { note: PetNote; button: string; url: string } {
433  const tr = (a: string, b: string) => (de ? a : b)
434  const isRed = watch.phase === 'rot'
435  const url = (isRed ? watch.failed?.url : '') || watch.pr?.url || watch.runs[0]?.url || ''
436  const button = isRed ? tr('Fehler ansehen', 'Show the failure') : watch.pr ? tr(`PR #${watch.pr.number} öffnen`, `Open PR #${watch.pr.number}`) : tr('Lauf öffnen', 'Open the run')
437  const fallback = isRed ? watch.failed?.name || tr('Ein Lauf ist fehlgeschlagen.', 'A run failed.') : tr('Alle Läufe sind durch.', 'All runs passed.')
438  const note: PetNote = { kind: isRed ? 'error' : 'done', title: isRed ? tr('CI ist rot', 'CI is red') : tr('CI ist grün', 'CI is green'), text: cut(watch.message || fallback, 200) }
439  return { note, button, url: isUrl(url) ? url : '' }
440}
441
442/** The lines a click on the pet adds under the last answer: held command, tasks, CI. Pure. */
443export function modLines(call: HeldCall | null, tasks: readonly PetTask[], watch: PetCi, now: number, de: boolean): string[] {
444  const tr = (a: string, b: string) => (de ? a : b)
445  const lines: string[] = []
446  if (call) lines.push(`⚠ ${tr('Angehalten', 'Held')}: ${cut(call.summary, 70)}`)
447  for (const task of tasks.slice(0, 2)) lines.push(`☐ #${task.id} ${cut(task.title, 60)}`)
448  if (tasks.length > 2) lines.push(tr(`… und ${tasks.length - 2} weitere Aufgaben`, `… and ${tasks.length - 2} more tasks`))
449  const isRecent = watch && (watch.phase !== 'gruen' || now - watch.endedAt < CI_GREEN_MS)
450  if (watch && !watch.isDismissed && isRecent && ['rot', 'laeuft', 'warte', 'gruen'].includes(watch.phase)) lines.push(`CI: ${cut(watch.message || watch.phase, 70)}`)
451  return lines
452}
453
454async function openUrl($: EngineInterface, url: string) {
455  if (!isUrl(url)) return
456  await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Start-Process $env:PET_URL'], { env: { PET_URL: url }, timeoutMs: 15_000 }).catch(() => null)
457}
458
459// The note on screen again, with the new badge and keep-warm state: the pet takes it without a new bubble.
460async function rewrite($: EngineInterface) {
461  if (!statusPath || !shown) return
462  await $.fs.write(statusPath, statusText(shown.note, shown.at, look, { ...shown.extra, badge, warm: warmText })).catch(() => {})
463}
464
465// A task's note, its click handed to Human in the loop.
466function taskFor($: EngineInterface, task: PetTask): PetNote {
467  const note = taskNote(task, lang === 'de', askViaPet, canType)
468  const done = t('Erledigt', 'Done')
469  if (note.ask || note.free) {
470    pending.set(`hitl-${task.id}`, choice => {
471      const given = task.kind === 'do' && choice === done ? 'done' : choice
472      void $.store.set(`hitl:pet:${sessionId}`, { id: task.id, choice: given }).catch(() => {})
473      void say($, { kind: 'work', title: titled(t('Aufgabe', 'Task')), text: t(`#${task.id} ist an Claude weitergegeben.`, `#${task.id} passed on to Claude.`) })
474    })
475  }
476  return { ...note, title: titled(note.title) }
477}
478
479async function checkMods($: EngineInterface) {
480  const now = await $.clock.now()
481  if (now - modsCheckedAt < MOD_CHECK_MS) return
482  modsCheckedAt = now
483  const de = lang === 'de'
484
485  // Blast Radius: a held command; the pet may answer it only with option permissions "answer".
486  const h = (await $.store.get(`blast:held:${sessionId}`).catch(() => null)) as HeldCall | null
487  held = h && typeof h === 'object' && h.id ? h : null
488  if (held && held.id !== heldShown) {
489    heldShown = held.id
490    const call = held
491    const id = `blast-${call.id}`
492    const go = t('Ausführen', 'Proceed')
493    const text = cut(call.summary, 120) + `\n${cut(call.command, 120)}`
494    const title = titled(`Blast Radius · ${call.label}`)
495    if (permissions === 'answer') {
496      pending.set(id, choice => void $.store.set(`blast:pet:${sessionId}`, { id: call.id, choice: choice === go ? 'proceed' : 'cancel' }).catch(() => {}))
497      await say($, { kind: 'ask', title, text, ask: id, choices: [go, t('Abbrechen', 'Cancel')] })
498    } else {
499      await say($, { kind: 'ask', title, text: `${text}\n${t('Bitte in Claude Code entscheiden.', 'Please decide in Claude Code.')}` })
500    }
501  } else if (!held && heldShown) {
502    pending.delete(`blast-${heldShown}`)
503    heldShown = ''
504  }
505
506  // Human in the loop: this session's open tasks; a new one comes up (not over a held command).
507  type Tasks = readonly PetTask[] | null
508  const inBox = (await read($, HITL_TASKS as any).catch(() => null)) as Tasks
509  const list = inBox ?? ((await read($, HITL_TASKS_OWN as any).catch(() => null)) as Tasks) ?? []
510  openTasks = list.filter(x => x.state === 'open')
511  for (const id of [...tasksShown]) {
512    if (openTasks.some(x => x.id === id)) continue
513    tasksShown.delete(id)
514    pending.delete(`hitl-${id}`)
515  }
516  const fresh = openTasks.find(x => !tasksShown.has(x.id))
517  if (fresh && !held) {
518    tasksShown.add(fresh.id)
519    await say($, taskFor($, fresh))
520  }
521
522  // CI Watch: a finished run once, with a button that opens it.
523  const asWatch = (v: unknown): PetCi => (v && typeof v === 'object' && 'phase' in v ? (v as PetCi) : null)
524  ci = asWatch(await read($, CI_WATCH as any).catch(() => null)) ?? asWatch(await read($, CI_WATCH_OWN as any).catch(() => null))
525  const ciKey = ci ? `${ci.sha}:${ci.phase}` : ''
526  const isOver = ci !== null && (ci.phase === 'gruen' || ci.phase === 'rot')
527  if (ci && ciKey !== ciShown && (!isOver || (!ci.isDismissed && !held))) {
528    const isFirst = ciShown === ''
529    ciShown = ciKey
530    // a result that was there before the pet started is old news
531    if (isOver && (!isFirst || now - ci.endedAt < 60_000)) {
532      const { note, button, url } = ciNote(ci, de)
533      const id = `ci-${ciKey}`
534      if (url) pending.set(id, () => void openUrl($, url))
535      await say($, { ...note, title: titled(note.title), ...(url ? { ask: id, choices: [button] } : {}) })
536    }
537  }
538
539  // Keep-warm: offered in the menu when the Cache Tax is there.
540  if (canKeepWarm === null) canKeepWarm = (await $.command.list().catch(() => [] as Array<{ name: string }>)).some(c => c.name === 'keepwarm')
541  const always = (await $.store.get('always').catch(() => null)) === true
542  const deadline = Number(await $.store.get(`deadline:${sessionId}`).catch(() => 0)) || 0
543  const nextWarm = canKeepWarm ? warmLabel(always, deadline, now, de) : ''
544  const nextBadge = modBadge(held !== null, openTasks.length, ci, now, de)
545  if (nextBadge !== badge || nextWarm !== warmText) {
546    badge = nextBadge
547    warmText = nextWarm
548    await rewrite($)
549  }
550}
551
552// The pet's menu: keep the cache warm (the Cache Tax's /keepwarm).
553function listenForWarm($: EngineInterface) {
554  pending.set('warm', choice => {
555    listenForWarm($)
556    if (!WARM_ARGS.includes(choice)) return
557    modsCheckedAt = 0
558    const text =
559      choice === 'off'
560        ? t('Ich lasse ihn abkühlen.', 'I let it cool down.')
561        : choice === 'always'
562          ? t('Ich halte ihn in jeder Sitzung warm.', 'Keeping it warm in every session.')
563          : t(`Ich halte ihn ${choice.replace('h', ' Std.')} warm.`, `Keeping it warm for ${choice}.`)
564    void $.command
565      .run({ command: 'keepwarm', args: choice })
566      .then(() => say($, { kind: 'work', title: titled(t('Cache', 'Cache')), text }))
567      .catch(() => $.ui.toast(t(`Bitte /keepwarm ${choice} eingeben.`, `Please type /keepwarm ${choice}.`)))
568  })
569}
570
571async function poll($: EngineInterface) {
572  if (!statusPath) return
573  await checkCache($).catch(() => {})
574  await checkMods($).catch(() => {})
575  if (pending.size > 0) {
576    const at = await $.fs.stat(`${statusPath}.answer`).then(x => x.mtimeMs).catch(() => 0)
577    if (at && at !== answerAt) {
578      answerAt = at
579      const a = parseAnswer(String(await $.fs.read(`${statusPath}.answer`).catch(() => '')))
580      const done = a ? pending.get(a.id) : undefined
581      if (a && done) {
582        pending.delete(a.id)
583        done(a.choice)
584      }
585    }
586  }
587  if (suggestViaPet && watchStepsUntil && lastDone) {
588    if ((await $.clock.now()) > watchStepsUntil) {
589      watchStepsUntil = 0
590      return
591    }
592    type Steps = { turnId: string; items: string[] } | null
593    const inBox = (await read($, NEXT_STEPS as any).catch(() => null)) as Steps
594    const s = inBox?.items?.length ? inBox : ((await read($, NEXT_STEPS_OWN as any).catch(() => null)) as Steps)
595    if (!s || !s.items?.length || s.turnId === stepsShownFor) return
596    stepsShownFor = s.turnId
597    watchStepsUntil = 0
598    const id = `ns-${s.turnId}`
599    for (const key of [...pending.keys()]) if (key.startsWith('ns-')) pending.delete(key)
600    pending.set(id, text => void sendSuggestion($, text))
601    recap = { ...(recap ?? lastDone), ask: id, choices: s.items, free: id }
602    await say($, { ...lastDone, ask: id, choices: s.items, free: id })
603  }
604}
605
606async function sendSuggestion($: EngineInterface, text: string) {
607  await say($, { kind: 'work', title: titled(t('Los geht’s', 'On it')), text: cut(text, 120) })
608  await $.prompt.submit({ text }).catch(() => $.prompt.fill({ text }).catch(() => {}))
609}
610
611async function say($: EngineInterface, note: PetNote) {
612  if (!statusPath) return
613  const now = await $.clock.now()
614  // Typing: a note that answers something names its id; everything else goes to this session's prompt.
615  const typed = canType ? { ...note, free: note.free ?? promptId() } : { ...note, free: undefined }
616  // name: the project, shown under the pet all the time
617  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 …') : '' } : {}) }
618  shown = { note: typed, at: now, extra }
619  await $.fs.write(statusPath, statusText(typed, now, look, { ...extra, badge, warm: warmText })).catch(() => {})
620  // asleep (one pet per active session, cache cold): the note only reaches a pet that still runs
621  if (now - started > RESTART_MS && !isAsleep) void ensure($).catch(() => {})
622}
623
624type AskQuestion = { question: string; header?: string; multiSelect?: boolean; type?: string; options?: Array<{ label: string }> }
625
626const titled = (what: string) => (project ? `${project} · ${what}` : what)
627
628// Builds the pet when it is missing or older than its source, and starts it (a second start
629// exits at once: the pet holds a named mutex).
630const BUILD =
631  "Get-Process ModBoxPet -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; " +
632  'New-Item -ItemType Directory -Force $env:PET_DIR | Out-Null; ' +
633  '$csc = Join-Path $env:WINDIR "Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe"; ' +
634  'if (-not (Test-Path $csc)) { $csc = Join-Path $env:WINDIR "Microsoft.NET\\Framework\\v4.0.30319\\csc.exe" }; ' +
635  '& $csc -nologo -target:winexe -optimize+ "-out:$env:PET_EXE" -r:System.Windows.Forms.dll -r:System.Drawing.dll $env:PET_SRC; exit $LASTEXITCODE'
636// Waits for the pet's click on one permission request: prints the answer file once it names the
637// request, gives up when the status no longer offers it (answered elsewhere) or the time is over.
638const WAIT =
639  '$end = (Get-Date).AddSeconds([int]$env:PET_WAIT); ' +
640  'while ((Get-Date) -lt $end) { ' +
641  "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 } }; " +
642  "$s = Get-Content -Raw $env:PET_STATUS -ErrorAction SilentlyContinue; if ($s -notmatch ('(?m)^ask=' + [regex]::Escape($env:PET_ID) + '\\r?$')) { exit 1 }; " +
643  'Start-Sleep -Milliseconds 250 }; exit 2'
644const START = "Start-Process -FilePath $env:PET_EXE -ArgumentList ('\"' + $env:PET_STATUS + '\"'), $env:PET_SECS"
645
646// The script that signs the pet: Sign Guard's, or as there the sign-binary skill's when none is
647// set; nothing when that is not there either.
648async function signer($: EngineInterface): Promise<string> {
649  if (signScript) return signScript
650  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
651  const skill = `${home}\\.claude\\skills\\sign-binary\\sign.ps1`
652  return home && (await $.fs.exists(skill).catch(() => false)) ? skill : ''
653}
654
655async function ensure($: EngineInterface): Promise<string | null> {
656  if (!isWindows || !statusPath) return null
657  started = await $.clock.now()
658  const home = (await $.env.get('LOCALAPPDATA')) || ''
659  if (!home) return t('LOCALAPPDATA fehlt', 'LOCALAPPDATA is missing')
660  const dir = `${home}\\mod-box\\pet`
661  const exe = `${dir}\\ModBoxPet.exe`
662  const src = `${$.plugin.root}/mods/pet/overlay/Pet.cs`.replace(/\//g, '\\')
663  const exeAt = await $.fs.stat(exe).then(s => s.mtimeMs).catch(() => 0)
664  const srcAt = await $.fs.stat(src).then(s => s.mtimeMs).catch(() => 0)
665  const env = { PET_DIR: dir, PET_EXE: exe, PET_SRC: src, PET_STATUS: statusPath, PET_SECS: bubbleSec }
666  if (!exeAt || exeAt < srcAt) {
667    const built = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', BUILD], { env, timeoutMs: 60_000 })
668    if (built.exitCode !== 0) return cut(built.stdout || built.stderr || 'csc failed', 200)
669    const script = await signer($)
670    if (script) {
671      await $.process
672        .run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Exe', exe], { timeoutMs: 120_000 })
673        .catch(() => null)
674    }
675  }
676  const run = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', START], { env, timeoutMs: 15_000 })
677  return run.exitCode === 0 ? null : cut(run.stderr || 'start failed', 200)
678}
679
680// One short Haiku call, its tokens booked for the pet.
681async function haiku($: EngineInterface, system: string, prompt: string, maxTokens: number): Promise<string | null> {
682  const r = await $.model.complete({ model: MODEL, maxTokens, timeoutMs: 15_000, system, prompt })
683  if (r.usage) {
684    await update($, USAGE, b => addUsage(b, 'pet', MODEL, r.usage!)).catch(() => {})
685    const total = await $.store.get(USAGE_STORE).catch(() => null)
686    await $.store.set(USAGE_STORE, addUsage(total as UsageBook | null, 'pet', MODEL, r.usage)).catch(() => {})
687  }
688  return r.isAnswered && r.text.trim() ? r.text : null
689}
690
691async function sum($: EngineInterface, answer: string): Promise<string | null> {
692  const text = await haiku(
693    $,
694    'You are a friendly desktop pet watching a coding assistant. In at most THREE short sentences (max 45 words) say what ' +
695      'the assistant just did or found and what is still open, for the person at the desk, so they can answer without ' +
696      `reading the whole reply. ${lang === 'de' ? 'Answer in German, informal (du).' : 'Answer in English.'} ` +
697      'No markdown, no quotes, no greeting, no question at the end.',
698    `<reply>\n${answer.slice(0, 4000)}\n</reply>`,
699    140,
700  )
701  return text ? cut(text.replace(/\s*\n+\s*/g, ' '), 320) : null
702}
703
704/** Haiku's reply options, one per line: without bullets, numbers or quotes, at most three. Pure. */
705export function parseReplies(text: string): string[] {
706  const lines = text
707    .split(/\r?\n/)
708    .map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').replace(/^["'„“]+|["'“”]+$/g, '').trim())
709    .filter(l => l.length > 0 && l.length <= 80)
710  return [...new Set(lines)].slice(0, 3)
711}
712
713// An answer that ends with a question: two or three short replies the person might give, as buttons.
714async function replies($: EngineInterface, answer: string, question: string): Promise<string[]> {
715  const text = await haiku(
716    $,
717    'A coding assistant ended its reply with a question to the person at the desk. Write the 2 or 3 most likely short ' +
718      'answers that person would send back, one per line, each at most 8 words, the most likely first, different from ' +
719      `each other. ${lang === 'de' ? 'In German, informal (du).' : 'In English.'} Only the answers: no numbering, no quotes.`,
720    `<reply>\n${answer.slice(-2500)}\n</reply>\n<question>${question}</question>`,
721    80,
722  )
723  return text ? parseReplies(text) : []
724}
725
726export const register: Register = (on, options) => {
727  lang = options.language === 'de' ? 'de' : 'en'
728  bubbleSec = String(options.bubble ?? '9')
729  summary = String(options.summary ?? 'off')
730  signScript = String(options.signScript ?? '').trim()
731  petChoice = String(options.kind ?? 'box')
732  look = pickLook(petChoice, String(options.fur ?? 'auto'))
733  perSession = options.perSession === 'on'
734  askViaPet = options.ask !== 'off'
735  suggestViaPet = options.suggest !== 'off'
736  permissions = options.permissions === 'answer' ? 'answer' : 'show'
737  cacheTtlMs = (Number(options.cacheTtl) || 60) * 60_000
738  canType = options.typing === 'on'
739  remindSec = options.remind === 'off' ? 0 : Number(options.remind) || 60
740  hotkey = String(options.hotkey ?? 'ctrl+alt+space') === 'off' ? '' : String(options.hotkey ?? 'ctrl+alt+space')
741  warnMs = options.cacheWarn === 'off' ? 0 : (Number(options.cacheWarn) || 5) * 60_000
742
743  on('session.start', {}, async ($, e, next) => {
744    const r = await next(e)
745    isWindows = (await $.env.get('OS')) === 'Windows_NT'
746    if (!isWindows) return r
747    const temp = (await $.env.get('TEMP')) || (await $.env.get('TMP')) || ''
748    // One pet for all sessions, or (option) one each: then every session has its own status file.
749    const sid = perSession ? `-${String(await $.session.id().catch(() => '')).replace(/[^\w-]/g, '').slice(0, 8)}` : ''
750    statusPath = temp ? `${temp}\\mod-box-pet${sid}.txt` : ''
751    sessionId = String(await $.session.id().catch(() => ''))
752    if (perSession && sessionId) {
753      const mine = (((await $.store.get(LOOKS_STORE).catch(() => null)) ?? {}) as Record<string, PetLook>)[sessionId]
754      if (mine && PETS.includes(mine.pet)) look = mine
755    }
756    listenForLook($)
757    listenForRecap($)
758    listenForPrompt($)
759    listenForWarm($)
760    const now = (await $.clock.now()) || 1
761    lastRequestAt = now
762    isAsleep = await startsAsleep($, now)
763    coldFor = isAsleep ? lastRequestAt : 0
764    $.clock.every(POLL_MS, () => void poll($).catch(() => {}))
765    project = base(await $.session.cwd().catch(() => ''))
766    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]]' })
767    if (isAsleep) {
768      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.") })
769      return r
770    }
771    void (async () => {
772      await say($, { kind: 'hello', title: titled(t('Hallo!', 'Hello!')), text: t('Ich schaue Claude über die Schulter.', "I'm watching over Claude's shoulder.") })
773      await ensure($)
774    })().catch(() => {})
775    return r
776  })
777
778  on('turn.start', {}, async ($, e, next) => {
779    const r = await next(e)
780    if ((e as { agentId?: string }).agentId) return r
781    isTurnRunning = true
782    await markRequest($)
783    if (isAsleep && statusPath) {
784      isAsleep = false
785      void ensure($).catch(() => {})
786    }
787    steps = 0
788    changed.clear()
789    watchStepsUntil = 0
790    lastDone = null
791    // the last answer stays on a click, its buttons do not (the turn they answered is over)
792    if (recap) recap = { kind: recap.kind === 'ask' ? 'done' : recap.kind, title: recap.title, text: recap.text }
793    for (const key of [...pending.keys()]) if (key.startsWith('ns-')) pending.delete(key)
794    if (e.text.trim()) void say($, { kind: 'work', title: titled(t('Los geht’s', 'On it')), text: cut(e.text, 120) })
795    return r
796  })
797
798  on('tool.call', {}, async ($, e, next) => {
799    if (!statusPath || (e as any).agentId) return next(e)
800    const tool = String(e.tool)
801    // Claude's question: its own hook puts it in the bubble; a "Using AskUserQuestion" here would
802    // race it and could cover the choices.
803    if (tool === 'AskUserQuestion' && askViaPet) return next(e)
804    const input = e as unknown as Record<string, unknown>
805    void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: describe(tool, input, lang === 'de') })
806    const r = await next(e)
807    steps++
808    if (r.deny) {
809      void say($, { kind: 'ask', title: titled(t('Angehalten', 'Held')), text: cut(String(r.deny), 160) })
810    } else if ((r as any).isError) {
811      void say($, { kind: 'error', title: titled(t('Hoppla', 'Oops')), text: t(`${tool} ist fehlgeschlagen.`, `${tool} failed.`) })
812    } else if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) {
813      changed.add(String(input.file_path ?? input.notebook_path ?? ''))
814    }
815    return r
816  })
817
818  on('turn.complete', {}, async ($, e, next) => {
819    const r = await next(e)
820    if (!statusPath || e.agentId) return r
821    isTurnRunning = false
822    await markRequest($)
823    if (e.reason === 'aborted' || e.isAborted) {
824      void say($, { kind: 'ask', title: titled(t('Abgebrochen', 'Stopped')), text: t('Du hast unterbrochen. Ich warte.', 'You stopped it. Waiting for you.') })
825      return r
826    }
827    if (e.reason !== 'answer') {
828      void say($, { kind: 'error', title: titled(t('Fehler', 'Error')), text: t('Der Turn endete mit einem Fehler.', 'The turn ended with an error.') })
829      return r
830    }
831    const facts = [
832      steps ? t(`${steps} Schritt${steps === 1 ? '' : 'e'}`, `${steps} step${steps === 1 ? '' : 's'}`) : '',
833      changed.size ? t(`${changed.size} Datei${changed.size === 1 ? '' : 'en'} geändert`, `${changed.size} file${changed.size === 1 ? '' : 's'} changed`) : '',
834    ].filter(Boolean)
835    // An answer that ends by asking you something: the pet shows that question (it stays, like
836    // Claude's own questions), with the summary in front when Haiku made one.
837    const question = lastQuestion(e.answer)
838    const isQuestion = question !== null
839    const kind: PetKind = isQuestion ? 'ask' : 'done'
840    const title = titled([isQuestion ? t('Frage an dich', 'A question for you') : t('Fertig', 'Done'), ...facts].join(' · '))
841    const first = question ?? (gist(e.answer) || t('Fertig.', 'Done.'))
842    lastDone = { kind, title, text: first }
843    recap = { kind, title, text: compact(e.answer, question) || first }
844    if (suggestViaPet && !isQuestion) watchStepsUntil = (await $.clock.now()) + 45_000
845    void (async () => {
846      await say($, { kind, title, text: first })
847      // the summary and (for a closing question) reply buttons, both from Haiku, side by side
848      const [said, offers] = await Promise.all([
849        summary === 'haiku' && e.answer.trim().length > 200 ? sum($, e.answer).catch(() => null) : Promise.resolve(null),
850        question && suggestViaPet ? replies($, e.answer, question).catch(() => [] as string[]) : Promise.resolve([] as string[]),
851      ])
852      // a new message came meanwhile: this answer is old news
853      if (isTurnRunning || (!said && offers.length < 2)) return
854      const text = said ? (question ? `${said}\n${question}` : said) : first
855      lastDone = { kind, title, text }
856      if (said) recap = { kind, title, text }
857      if (offers.length < 2) return void (await say($, lastDone))
858      const id = `ns-q${await $.clock.now()}`
859      for (const key of [...pending.keys()]) if (key.startsWith('ns-')) pending.delete(key)
860      pending.set(id, choice => void sendSuggestion($, choice))
861      recap = { ...(recap ?? lastDone), ask: id, choices: offers, free: id }
862      await say($, { ...lastDone, ask: id, choices: offers, free: id })
863    })().catch(() => {})
864    return r
865  })
866
867  // Claude's question: as buttons in the bubble too. Whichever answers first, the pet or the dialog
868  // in Claude Code, counts; the other is closed (a hook returning while next is pending aborts it).
869  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
870    const call = e as unknown as { questions?: AskQuestion[]; tool_use_id?: string; agentId?: string }
871    if (!statusPath || !askViaPet || call.agentId) return next(e)
872    const questions = call.questions ?? []
873    const isPickable = questions.length > 0 && questions.every(q => !q.multiSelect && (((q.type ?? 'choice') === 'choice' && (q.options?.length ?? 0) >= 2) || (canType && q.type === 'text')))
874    const ask = (q: AskQuestion | undefined) => titled(q?.header ? `${t('Frage', 'Question')} · ${q.header}` : t('Frage an dich', 'A question for you'))
875    if (!isPickable) {
876      void say($, { kind: 'ask', title: ask(questions[0]), text: cut(questions[0]?.question ?? t('Claude fragt dich etwas.', 'Claude has a question.'), 200) })
877      return next(e)
878    }
879    const beneath = next(e)
880    beneath.catch(() => {})
881    const answers: Record<string, string> = {}
882    for (const [n, q] of questions.entries()) {
883      const id = `q-${call.tool_use_id ?? 'x'}-${n}`
884      await say($, { kind: 'ask', title: ask(q), text: cut(q.question, 200), ask: id, choices: (q.options ?? []).map(o => o.label), free: id })
885      const won = await Promise.race([beneath.then(r => ({ r, c: null as string | null })), waitPick(id).then(c => ({ r: null, c }))])
886      if (won.c === null) {
887        pending.delete(id)
888        void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: t('Antwort erhalten, weiter geht’s.', 'Got the answer, carrying on.') })
889        return won.r!
890      }
891      answers[q.question] = won.c
892    }
893    void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: t('Antwort erhalten, weiter geht’s.', 'Got the answer, carrying on.') })
894    return { result: { questions, answers } } as any
895  })
896
897  // A permission request: shown, so it is not missed. With option permissions "answer" the bubble
898  // offers "allow once" and "deny" for PERMIT_WAIT_S seconds; no rule is ever added ("always" stays
899  // with Claude Code's dialog). Another hook's decision comes first; a dialog answered meanwhile
900  // takes the buttons away again.
901  on('classic.PermissionRequest', {}, async ($, e, next) => {
902    const r = await next(e)
903    const req = e as unknown as { tool_name?: string; tool_input?: Record<string, unknown> }
904    if (!statusPath || !req.tool_name || (r as { decision?: unknown })?.decision) return r
905    // Claude Code opens its question dialog through a permission request too: the question's own
906    // hook shows its choices, an "allow" here would skip the question unanswered.
907    if (req.tool_name === 'AskUserQuestion') return r
908    // A plan to approve is the person's decision in Claude Code alone: shown, never answered here.
909    const isPersonal = PERSONAL_TOOLS.includes(req.tool_name)
910    const what = describe(req.tool_name, req.tool_input ?? {}, lang === 'de')
911    const title = titled(t('Genehmigung nötig', 'Permission needed'))
912    if (permissions !== 'answer' || isPersonal) {
913      void say($, { kind: 'ask', title, text: `${what} · ${t('bitte in Claude Code bestätigen', 'please confirm in Claude Code')}` })
914      return r
915    }
916    const allow = t('Einmal erlauben', 'Allow once')
917    const deny = t('Ablehnen', 'Deny')
918    const id = `perm-${(await $.clock.now()).toString(36)}-${Math.random().toString(36).slice(2, 8)}`
919    await say($, { kind: 'ask', title, text: what, ask: id, choices: [allow, deny] })
920    const gone = () => void say($, { kind: 'work', title: titled(t('Arbeite', 'Working')), text: t('In Claude Code beantwortet.', 'Answered in Claude Code.') })
921    next.signal?.addEventListener('abort', gone)
922    const env = { PET_ANSWER: `${statusPath}.answer`, PET_STATUS: statusPath, PET_ID: id, PET_WAIT: String(PERMIT_WAIT_S) }
923    const run = await $.process
924      .run(['powershell', '-NoProfile', '-NonInteractive', '-Command', WAIT], { env, timeoutMs: (PERMIT_WAIT_S + 15) * 1000 })
925      .catch(() => null)
926    next.signal?.removeEventListener('abort', gone)
927    const picked = run && run.exitCode === 0 ? parseAnswer(run.stdout) : null
928    const choice = picked && picked.id === id ? picked.choice : null
929    if (choice === allow) {
930      void say($, { kind: 'work', title: titled(t('Erlaubt', 'Allowed')), text: what })
931      return { ...r, decision: { behavior: 'allow' } } as typeof r
932    }
933    if (choice === deny) {
934      void say($, { kind: 'error', title: titled(t('Abgelehnt', 'Denied')), text: what })
935      return { ...r, decision: { behavior: 'deny', message: t('Im Desktop-Pet abgelehnt.', 'Denied in the desktop pet.') } } as typeof r
936    }
937    // no click in time: the dialog decides, as without the pet
938    if (!next.signal?.aborted) void say($, { kind: 'ask', title, text: `${what} · ${t('bitte in Claude Code bestätigen', 'please confirm in Claude Code')}` })
939    return r
940  })
941
942  on('command.run', { command: 'pet' }, async ($, e) => {
943    if (!isWindows) return { text: t('Das Desktop-Pet gibt es nur unter Windows.', 'The desktop pet is Windows only.') }
944    const [first = '', second = ''] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
945    const next = parseLook(String(e.args ?? ''), look)
946    if (first !== 'stop') isAsleep = false
947    if (next) {
948      await ensure($)
949      await applyLook($, next, first, second !== '')
950      const name = `${look.pet}${look.fur !== 'auto' ? ` (${look.fur})` : ''}`
951      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}.`) }
952    }
953    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(', ')}.`) }
954    if (first === 'stop') {
955      await say($, { kind: 'bye', title: titled(t('Tschüss!', 'Bye!')), text: t('Bis später.', 'See you later.') })
956      return { text: t('Das Pet verabschiedet sich.', 'The pet says goodbye.') }
957    }
958    const problem = await ensure($)
959    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.") })
960    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).') }
961  })
962
963  on('session.end', {}, async ($, e, next) => {
964    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.') })
965    return next(e)
966  })
967}
968
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