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…

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.
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
PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop, SessionStart, SessionEnd
Desktop Pet needs no other mod. These add to it when installed beside it:
| Mod | What it adds | Without it |
|---|---|---|
| Cache Meter or Cache Tax | a Keep warm (1 h) button in the warning before the prompt cache goes cold (/keepwarm) | the warning comes without the button |
| Next Steps | its suggestions for the next step as buttons in the bubble | no next-step suggestions; a closing question still gets reply buttons (Haiku) |
the sign-binary skill or Sign Guard | signs the pet's program after it is built (option signScript) | the pet runs unsigned |
| 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); a command Blast Radius holds gets "Proceed" and "Cancel" too. 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 968 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
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}
968mods/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