Buddy: a pet sidekick for Claude Code. Sees all your chats, keeps your to-do list when Claude needs you, covers your secrets while you film, runs a team of…

Claude Code once had an April Fools pet called Buddy. He's gone, so this mod brings him back as a sidekick that does real work.
Hatch him from an egg and he lives in a panel next to your chat. He naps, walks around while Claude works, and dances when a task finishes. Meanwhile he does this:
Does things for you
Runs a team (Threads tab)
/thread <task>, or Claude starts them itself). Its answer comes back to the chat that started it, and you can send it follow-ups. Works on Windows and Mac; no tmux.Manages your skills (Skills tab)
Sees everything at a glance
One-click power moves
A safety net, only where it matters
.env (or anything you add). Delete check is there too, off by default.The fun part: pick a bunny, cat, dog, bear, frog, owl, ghost or dragon. Hatch, rename, pet.
You need Claude Code 2.1.287 or later (claude --version; claude update if older). Buddy draws in the terminal and in the Code tab of the Claude desktop app (not in the VS Code chat panel or WSL).
claude plugin marketplace add joshpocock/claude-buddy
claude plugin install buddy@claude-buddy --scope user
Open a new chat and type /buddy. If the installer says options are "not yet set", that's fine: Buddy uses sensible defaults, and you can change everything in his Settings tab.
Check it first, like any mod: after cloning, run claude plugin validate ./buddy. It lists everything Buddy can do: no internet access; he reads and writes small status files in ~/.claude/buddy/ for the chats board; the programs he runs are Claude itself (for separate chats you start), Codex (only if you switch it on), and your system's copy and delete commands (only when you copy, move or delete a skill).
/buddy opens the panel. Tabs: Status, Chats, Jobs, Pets, Settings, Help/buddy rec recording mode on or off/thread <task> start a separate background chat/buddy-skills what the Skills tab runs (scan, copy, move, delete)/buddy hatch new egg · /buddy pet dragon pick a pet · /buddy name Rex rename · /buddy reset zero the counter/handoff save a handoff note · /codex <question> ask Codex (when on)claude plugin marketplace update claude-buddy
claude plugin update buddy@claude-buddy
claude plugin uninstall buddy@claude-buddy
Buddy is a starting point, not a finished product. Fork it, change it, add your own jobs. Every job is its own file in buddy/hooks/jobs/, so adding one is easy. Pull requests welcome.
MIT licensed. Made by Josh Pocock.
hooks/register.tsx 1291 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { registerExtras } from './jobs/extras.tsx'
5import { registerGuards } from './jobs/guards.tsx'
6import { registerPower } from './jobs/power.tsx'
7import { registerThreads } from './jobs/threads.tsx'
8import { frontmatter, norm, registerSkills } from './jobs/skills.tsx'
9import type { SkillItem, SkillScan, SkillUse } from './jobs/skills.tsx'
10import type { AgentLive, ChatThread } from './jobs/threads.tsx'
11import type { Todo } from './jobs/power.tsx'
12import { maskText, nameList } from './lib/mask.ts'
13import { registerWatch } from './jobs/watch.tsx'
14import type { AgentRow, Caught, Limit, Mood, Receipt } from '../types'
15
16// Buddy's shared state (same keys in every file, so every job sees the same values).
17const agents = atom({ plugin: 'buddy', key: 'agents' } as const, [] as AgentRow[])
18const caught = atom({ plugin: 'buddy', key: 'caught' } as const, { send: 0, spend: 0, danger: 0 } as Caught)
19const lastHeld = atom({ plugin: 'buddy', key: 'lastHeld' } as const, '')
20const memory = atom({ plugin: 'buddy', key: 'memory' } as const, 0)
21const mood = atom({ plugin: 'buddy', key: 'mood' } as const, 'sleepy' as Mood)
22const tick = atom({ plugin: 'buddy', key: 'tick' } as const, 0)
23const limits = atom({ plugin: 'buddy', key: 'limits' } as const, [] as Limit[])
24const cost = atom({ plugin: 'buddy', key: 'cost' } as const, 0)
25const receipt = atom({ plugin: 'buddy', key: 'receipt' } as const, null as Receipt | null)
26const codexLog = atom({ plugin: 'buddy', key: 'codexLog' } as const, [] as string[])
27const codexStatus = atom({ plugin: 'buddy', key: 'codexStatus' } as const, '')
28const tab = atom({ plugin: 'buddy', key: 'tab' } as const, 'status' as 'status' | 'chats' | 'threads' | 'skills' | 'jobs' | 'pets' | 'settings' | 'help')
29
30// The pets Buddy can be. A new install starts as an egg that hatches into a random one.
31const SPECIES = ['bunny', 'cat', 'dog', 'bear', 'frog', 'owl', 'ghost', 'dragon'] as const
32type PetInfo = { stage: 'egg' | 'pet'; species: (typeof SPECIES)[number]; name: string; pets: number }
33const NEW_PET: PetInfo = { stage: 'egg', species: 'bunny', name: 'Buddy', pets: 0 }
34const lastTurnAt = atom({ plugin: 'buddy', key: 'lastTurnAt' } as const, 0)
35const recOn = atom({ plugin: 'buddy', key: 'recOn' } as const, false)
36const agentLive = atom({ plugin: 'buddy', key: 'agentLive' } as const, {} as Record<string, AgentLive>)
37const chatThreads = atom({ plugin: 'buddy', key: 'chatThreads' } as const, [] as ChatThread[])
38const threadModel = atom({ plugin: 'buddy', key: 'threadModel' } as const, 'sonnet')
39const threadKind = atom({ plugin: 'buddy', key: 'threadKind' } as const, 'helper' as 'helper' | 'chat')
40const skillScan = atom({ plugin: 'buddy', key: 'skillScan' } as const, null as SkillScan | null)
41const skillView = atom({ plugin: 'buddy', key: 'skillView' } as const, { q: '', sort: 'used' as 'used' | 'unused' | 'big', shown: 15, open: '' })
42const peek = atom({ plugin: 'buddy', key: 'peek' } as const, null as { id: string; text: string } | null)
43
44// Buddy: Claude Code's old pet, back with real jobs.
45// Jobs live in ./jobs; this file draws Buddy and wires the jobs in.
46
47
48// The all-chats board: each chat's Buddy keeps one small status file in ~/.claude/buddy/chats,
49// and every Buddy reads them all. One file per chat, so chats never overwrite each other.
50type ChatStatus = 'working' | 'waiting' | 'done' | 'idle' | 'closed'
51type ChatRow = { id: string; title: string; folder: string; status: ChatStatus; since: number; updatedAt: number }
52
53async function chatDir($: any): Promise<string> {
54 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.') as string
55 return `${home.replace(/\\/g, '/')}/.claude/buddy/chats`
56}
57
58async function writeChat($: any, patch: Partial<ChatRow>) {
59 const id = await $.session.id()
60 const file = `${await chatDir($)}/${id}.json`
61 let cur: Partial<ChatRow> = {}
62 try {
63 cur = JSON.parse(await $.fs.read(file))
64 } catch {
65 cur = {}
66 }
67 const now = Date.now()
68 const next = { ...cur, ...patch, id, updatedAt: now } as ChatRow
69 if (patch.status && patch.status !== cur.status) next.since = now
70 if (!next.since) next.since = now
71 await $.fs.write(file, JSON.stringify(next))
72}
73
74const PANE = 'buddy'
75
76// Statuses seen last time, so Buddy can ping you when another chat finishes or needs you.
77const seenStatus: Record<string, ChatStatus> = {}
78let selfId = ''
79// The reply Buddy already warned about, so the 'cache going cold' ping fires once.
80let warnedTurn = 0
81
82const ago = (ms: number) => {
83 const s = Math.max(0, Math.round(ms / 1000))
84 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`
85}
86
87async function readChats($: any): Promise<ChatRow[]> {
88 const dir = await chatDir($)
89 if (!(await $.fs.exists(dir))) return []
90 const entries = await $.fs.list(dir)
91 const rows: ChatRow[] = []
92 for (const ent of entries) {
93 if (ent.kind !== 'file' || !ent.name.endsWith('.json')) continue
94 try {
95 const row = JSON.parse(await $.fs.read(`${dir}/${ent.name}`)) as ChatRow
96 // A chat that stopped checking in for 3 minutes is gone (closed or crashed).
97 if (row.status !== 'closed' && Date.now() - row.updatedAt < 180000) rows.push(row)
98 } catch {
99 // skip unreadable files
100 }
101 }
102 const rank: Record<string, number> = { waiting: 0, working: 1, done: 2, idle: 3 }
103 return rows.sort((a, b) => (rank[a.status] ?? 9) - (rank[b.status] ?? 9) || b.since - a.since)
104}
105
106
107async function scanSkillDir($: any, dir: string, scope: 'global' | 'project'): Promise<SkillItem[]> {
108 if (!(await $.fs.exists(dir))) return []
109 const out: SkillItem[] = []
110 for (const ent of await $.fs.list(dir)) {
111 if (ent.name.startsWith('.') || (ent.kind !== 'dir' && !ent.isLink)) continue
112 const path = `${dir}/${ent.name}`
113 let text = ''
114 try {
115 text = await $.fs.read(`${path}/SKILL.md`)
116 } catch {
117 continue
118 }
119 const fm = frontmatter(text)
120 const name = fm.name || ent.name
121 out.push({ name, scope, path, isLink: Boolean(ent.isLink), descChars: fm.description.length + name.length, description: fm.description.slice(0, 220) })
122 }
123 return out
124}
125
126async function scanAllSkills($: any) {
127 const home = norm(((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.') as string)
128 const cwd = norm(await $.session.cwd())
129 const global = await scanSkillDir($, `${home}/.claude/skills`, 'global')
130 const project = cwd === home ? [] : await scanSkillDir($, `${cwd}/.claude/skills`, 'project')
131 const codexLink = await $.fs.exists(`${cwd}/.agents/skills`)
132 await update($, skillScan, () => ({ at: Date.now(), cwd, items: [...project, ...global], codexLink }))
133}
134
135const FACES: Record<Mood, [string, string]> = {
136 sleepy: ['( -.- ) zz', 'waiting on you'],
137 working: ['( o.o )', 'on it'],
138 happy: ['( ^.^ )/', 'done!'],
139 holding: ['( O.O )!', 'holding something for you'],
140 tired: ['( x_x )', "I'm full. Time to compact."],
141}
142
143const faceFor = (m: Mood, mem: number): [string, string] => (m !== 'holding' && mem >= 80 ? FACES.tired : FACES[m])
144
145const bar = (percent: number, width = 12) => {
146 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
147 return '█'.repeat(filled) + '░'.repeat(width - filled)
148}
149
150const tone = (percent: number) => (percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green')
151
152const elapsed = (row: AgentRow, now: number) => {
153 const s = Math.max(0, Math.round(((row.endedAt ?? now) - row.startedAt) / 1000))
154 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
155}
156
157const total = (c: Caught) => c.send + c.spend + c.danger
158
159const baseName = (p: string) => p.replace(/\\/g, '/').split('/').pop() ?? p
160
161// Every job Buddy does, for the Jobs and Help tabs. `live` jobs switch instantly from the panel.
162const JOBS: { id: string; group: string; name: string; what: string; live: boolean }[] = [
163 { id: 'sendGate', group: 'Guards', name: 'Send gate', what: 'Holds emails, Slack, invites, shares, git push, deploys and posts to outside APIs until you choose', live: true },
164 { id: 'spendGate', group: 'Guards', name: 'Spend gate', what: 'Holds paid API calls (OpenAI, Anthropic, Gemini, ElevenLabs, Replicate, fal, Apify and any you add in settings)', live: true },
165 { id: 'dangerGuard', group: 'Guards', name: 'Danger guard', what: 'Holds deletes, wipes, force pushes and database drops (Bash and PowerShell)', live: true },
166 { id: 'lockedFiles', group: 'Guards', name: 'Locked files', what: 'Claude cannot edit the files you lock in settings (.env by default)', live: true },
167 { id: 'chatBoard', group: 'Watches', name: 'Chats board', what: 'Shows every open Claude Code chat and pings you when one finishes or needs you', live: true },
168 { id: 'cacheCheck', group: 'Watches', name: 'Cache price check', what: 'After a break, tells you what your next message costs (the cache went cold) and offers to compact first', live: true },
169 { id: 'agentWatcher', group: 'Watches', name: 'Agent watcher', what: 'Lists every helper agent, running or done, with times', live: true },
170 { id: 'donePing', group: 'Watches', name: 'Done ping', what: 'Pops up when a long task finishes, so you can walk away', live: true },
171 { id: 'todoInbox', group: 'Helps', name: 'To-do inbox', what: 'When Claude needs you (add a key, log in, approve), it lands on your to-do list. Press Done and Claude carries on', live: true },
172 { id: 'houseRules', group: 'Helps', name: 'House rules', what: 'Reminds Claude of your rules and flags banned words in replies', live: true },
173 { id: 'codex', group: 'Helps', name: 'Codex sidekick', what: 'Ask Codex for a read-only second opinion: /codex <task>. Turn on in settings (sends the task to OpenAI)', live: false },
174]
175
176const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly', '5h': '5-hour', '7d': 'weekly' }
177
178export const register: Register = (on, options) => {
179 const o = options as any
180 registerGuards(on, o)
181 registerWatch(on, o)
182 registerExtras(on, o)
183 registerPower(on, o)
184 registerThreads(on, o)
185 registerSkills(on)
186
187 on('session.start', async ($, e, next) => {
188 const started = await next(e)
189 await $.command.register({ name: 'buddy', description: 'Open Buddy: your pet who watches and guards Claude' })
190 await $.command.register({ name: 'handoff', description: 'Buddy writes a handoff note for continuing in a fresh chat' })
191 const panel = ((await $.store.get('settings')) ?? {}) as Record<string, unknown>
192 await $.command.register({ name: 'codex', description: 'Ask Codex (read-only) for a second opinion: /codex <task>' })
193 if (o.codexEnabled === true || panel.codexEnabled === true) {
194 await $.tool.register({
195 name: 'codex',
196 description:
197 'Ask OpenAI Codex (a second, independent coding agent) for a read-only second opinion or review. Give it one clear, self-contained task. It cannot edit files. Returns its answer.',
198 inputSchema: { type: 'object', properties: { task: { type: 'string' } }, required: ['task'] },
199 })
200 }
201 await $.tool.register({
202 name: 'todo_for_you',
203 description:
204 "Put a task on the user's Buddy to-do list: something only they can do (add an API key, log in, approve or pay, check something by hand, decide). The user presses Done when finished and you get a message. One task per call.",
205 inputSchema: {
206 type: 'object',
207 properties: {
208 task: { type: 'string', description: 'One short sentence starting with a verb, e.g. "Add your OpenAI key to .env"' },
209 doneWhen: { type: 'string', description: 'Optional: how the user knows it is done' },
210 },
211 required: ['task'],
212 },
213 })
214 if ((await $.env.get('BUDDY_CHILD')) !== '1') {
215 await $.command.register({ name: 'buddy-skills', description: 'Buddy: scan, copy, move or delete skills (the Skills tab uses this)' })
216 await $.command.register({ name: 'thread', description: 'Buddy: start a separate background chat: /thread <task>' })
217 await $.tool.register({
218 name: 'list_chats',
219 description: "List the user's other open Claude Code chats (id, title, folder, status: working, waiting, done, idle) and the separate background chats started from this one, with their answers.",
220 inputSchema: { type: 'object', properties: {} },
221 })
222 await $.tool.register({
223 name: 'message_chat',
224 description:
225 'Send a message to another open Claude Code chat (chatId from list_chats) or to a separate chat started with start_chat (its id). Use it to hand work to another chat or to steer it.',
226 inputSchema: { type: 'object', properties: { chatId: { type: 'string' }, text: { type: 'string' } }, required: ['chatId', 'text'] },
227 })
228 await $.tool.register({
229 name: 'start_chat',
230 description:
231 'Start a separate Claude Code chat in the background with its own model and a self-contained task, so several jobs run in parallel as independent threads. Returns at once; the answer arrives later as a message from Buddy. For quick side tasks inside this chat, use a background subagent instead.',
232 inputSchema: {
233 type: 'object',
234 properties: { task: { type: 'string', description: 'Complete, self-contained instructions' }, model: { type: 'string', description: 'sonnet, opus or fable (default sonnet)' } },
235 required: ['task'],
236 },
237 })
238 }
239 const recSaved = (await $.store.get('rec')) === true
240 await update($, recOn, () => recSaved)
241 // Lifetime caught counter, kept between sessions.
242 const saved = (await $.store.get('caught')) as Caught | undefined
243 if (saved) await update($, caught, () => saved)
244 // First memory reading, so Buddy's face is right before the first turn.
245 const usage = await $.session.usage()
246 if (usage?.context?.window) {
247 const pct = Math.round(usage.context.percent ?? ((usage.context.tokens ?? 0) / usage.context.window) * 100)
248 await update($, memory, () => pct)
249 }
250 // Redraw once a second so agent timers move.
251 $.clock.every(1000, () => update($, tick, n => n + 1))
252 // Join the all-chats board, check in every 20s, and ping when another chat needs you.
253 try {
254 selfId = await $.session.id()
255 const folder = String(e.cwd ?? '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? ''
256 await writeChat($, { status: 'idle', folder })
257 } catch {
258 // board only
259 }
260 $.clock.every(20000, async () => {
261 try {
262 // Recording mode is shared: switching it in one chat masks every chat within 20s.
263 const recNow = (await $.store.get('rec')) === true
264 if (recNow !== (await read($, recOn))) await update($, recOn, () => recNow)
265 await writeChat($, {})
266 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
267 // About a minute before the cache goes cold, say so, once per reply.
268 const last = await read($, lastTurnAt)
269 const panel = ((await $.store.get('settings')) ?? {}) as Record<string, unknown>
270 const ttl = Math.max(1, Number(panel.cacheMinutes ?? o.cacheMinutes ?? 5)) * 60000
271 const left = last + ttl - Date.now()
272 if (live.cacheCheck !== false && last > 0 && last !== warnedTurn && left > 0 && left <= 70000) {
273 warnedTurn = last
274 $.ui.toast('Buddy: your cache goes cold in about a minute. Send your next message now to keep it cheap.')
275 }
276 for (const row of await readChats($)) {
277 const before = seenStatus[row.id]
278 seenStatus[row.id] = row.status
279 if (row.id === selfId || before === undefined || before === row.status || live.chatBoard === false) continue
280 const name = row.title || row.folder || 'another chat'
281 if (row.status === 'waiting') $.ui.toast(`Buddy: "${name}" is waiting on you`)
282 else if (row.status === 'done' && before === 'working') $.ui.toast(`Buddy: "${name}" just finished`)
283 }
284 } catch {
285 // board only
286 }
287 })
288 return started
289 })
290
291 on('command.run', { command: 'buddy' }, async ($, e) => {
292 const [word, ...rest] = String(e.args ?? '').trim().split(/\s+/)
293 const pet = (((await $.store.get('pet')) as PetInfo | undefined) ?? NEW_PET)
294 if (word === 'reset') {
295 await update($, caught, () => ({ send: 0, spend: 0, danger: 0 }))
296 await $.store.set('caught', { send: 0, spend: 0, danger: 0 })
297 return { text: 'Buddy reset his "saved you from" counter.' }
298 }
299 if (word === 'rec') {
300 const turnOn = rest[0] === 'on' ? true : rest[0] === 'off' ? false : !(await read($, recOn))
301 await $.store.set('rec', turnOn)
302 await update($, recOn, () => turnOn)
303 return { text: turnOn ? 'Recording mode ON: keys, emails, phone numbers, money and hidden names are covered on screen in every chat.' : 'Recording mode OFF.' }
304 }
305 if (word === 'hatch') {
306 await $.store.set('pet', { ...pet, stage: 'egg' })
307 await update($, tick, n => n + 1)
308 await $.ui.open({ id: PANE, title: 'Buddy' })
309 return { text: 'A new egg appeared. Click it to hatch.' }
310 }
311 if (word === 'pet' && (SPECIES as readonly string[]).includes(rest[0] ?? '')) {
312 await $.store.set('pet', { ...pet, stage: 'pet', species: rest[0] })
313 await update($, tick, n => n + 1)
314 return { text: `${pet.name} is now a ${rest[0]}.` }
315 }
316 if (word === 'name' && rest.length > 0) {
317 const name = rest.join(' ').slice(0, 20)
318 await $.store.set('pet', { ...pet, name })
319 await update($, tick, n => n + 1)
320 return { text: `Your pet is now called ${name}.` }
321 }
322 await $.ui.open({ id: PANE, title: 'Buddy' })
323 return { text: 'Buddy is back. Try /buddy pet cat, /buddy name Rex, or /buddy hatch.' }
324 })
325
326 // Leaving the board when the chat ends.
327 on('session.end', async ($, e, next) => {
328 try {
329 await writeChat($, { status: 'closed' })
330 } catch {
331 // board only
332 }
333 return next(e)
334 })
335
336 // Any question to you (Claude's or Buddy's own) marks this chat "waiting on you" on the board.
337 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
338 try {
339 await writeChat($, { status: 'waiting' })
340 } catch {
341 // board only
342 }
343 const answered = await next(e)
344 try {
345 await writeChat($, { status: 'working' })
346 } catch {
347 // board only
348 }
349 return answered
350 })
351
352 // The pet posts here: an egg finished hatching, or someone petted it.
353 on('ui.message', async ($, e, next) => {
354 const data = (e.data ?? {}) as { hatched?: boolean; petted?: boolean }
355 const pet = (((await $.store.get('pet')) as PetInfo | undefined) ?? NEW_PET)
356 if (data.hatched && pet.stage === 'egg') {
357 const species = SPECIES[Math.floor(Math.random() * SPECIES.length)]
358 await $.store.set('pet', { ...pet, stage: 'pet', species })
359 $.ui.toast(`Your egg hatched: ${pet.name} is a ${species}!`)
360 await update($, tick, n => n + 1)
361 } else if (data.petted) {
362 await $.store.set('pet', { ...pet, pets: (pet.pets ?? 0) + 1 })
363 }
364 return next(e)
365 })
366
367 // Always-on strip above the prompt: works at any window width.
368 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
369 if (e.props?.hasSurvey) return next(e)
370 const { Box, Text } = $.ui.resolve(e)
371 const m = await read($, mood)
372 const mem = await read($, memory)
373 const c = await read($, caught)
374 const pet = (((await $.store.get('pet')) as PetInfo | undefined) ?? NEW_PET)
375 const [face] = faceFor(m, mem)
376 const rec = await read($, recOn)
377 await read($, tick)
378 const todoCount = (((await $.store.get('todos')) ?? []) as Todo[]).filter(t => !t.done).length
379 return (
380 <Box>
381 {rec && <Text color="red" bold>● REC </Text>}
382 {todoCount > 0 && <Text color="yellow" bold>☐ {todoCount} for you </Text>}
383 <Text color="yellow">{face} </Text>
384 <Text dimColor>
385 {pet.name} the {pet.stage === 'egg' ? 'egg' : pet.species} · memory {mem}% · saved you {total(c)}x · /buddy
386 </Text>
387 </Box>
388 )
389 })
390
391 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
392 const { Box, Button, Text } = $.ui.resolve(e)
393 await read($, tick)
394 const now = await $.clock.now()
395 const view = await read($, tab)
396 const m = await read($, mood)
397 const mem = await read($, memory)
398 const c = await read($, caught)
399 const [face, caption] = faceFor(m, mem)
400 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
401 const isOn = (id: string) => (id === 'codex' ? o.codexEnabled === true : live[id] !== undefined ? live[id] : o[id] !== false)
402
403 const switchTo = async (command: string, args: string) => {
404 try {
405 await $.command.run({ command, args })
406 } catch {
407 $.ui.toast(`Type /${command} ${args} to switch`)
408 }
409 }
410 const toggle = async (id: string) => {
411 const jobs = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
412 const turnedOn = !isOn(id)
413 await $.store.set('jobs', { ...jobs, [id]: turnedOn })
414 $.ui.toast(`Buddy: ${JOBS.find(j => j.id === id)?.name} ${turnedOn ? 'ON' : 'OFF'}`)
415 await update($, tick, n => n + 1)
416 }
417 const compactNow = async () => {
418 try {
419 await $.session.compact({})
420 $.ui.toast('Buddy compacted the chat')
421 } catch {
422 $.ui.toast('Type /compact to compact')
423 }
424 }
425 const handoffNow = async () => {
426 try {
427 await $.command.run({ command: 'handoff', args: '' })
428 } catch {
429 $.ui.toast('Type /handoff')
430 }
431 }
432 const resetCount = async () => {
433 await update($, caught, () => ({ send: 0, spend: 0, danger: 0 }))
434 await $.store.set('caught', { send: 0, spend: 0, danger: 0 })
435 $.ui.toast('Buddy reset his caught counter')
436 }
437
438 // A titled card with a rounded border.
439 const card = (title: string, children: any, color = 'gray') => (
440 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginBottom={1}>
441 <Text bold color={color === 'gray' ? undefined : color}>{title}</Text>
442 {children}
443 </Box>
444 )
445
446 const pet = (((await $.store.get('pet')) as PetInfo | undefined) ?? NEW_PET)
447 const { Client } = $.ui.resolve(e) as any
448 const rec = await read($, recOn)
449 const panelNames = ((await $.store.get('settings')) ?? {}) as Record<string, any>
450 const hide = nameList(panelNames.hideNames ?? o.hideNames)
451 const shown = (s: string) => (rec ? maskText(s, hide) : s)
452 const toggleRec = async () => {
453 const turnOn = !rec
454 await $.store.set('rec', turnOn)
455 await update($, recOn, () => turnOn)
456 $.ui.toast(turnOn ? 'Buddy: recording mode ON. Secrets, emails, money and hidden names are covered.' : 'Buddy: recording mode OFF')
457 }
458 const todos = (((await $.store.get('todos')) ?? []) as Todo[]).filter(t => !t.done)
459 const finishTodo = async (todo: Todo, tell: boolean) => {
460 const all = ((await $.store.get('todos')) ?? []) as Todo[]
461 await $.store.set('todos', all.filter(t => t.id !== todo.id && !t.done).slice(-20))
462 await update($, tick, n => n + 1)
463 if (!tell) return
464 const text = `Done: ${todo.task}. Carry on.`
465 try {
466 if (todo.sessionId === selfId) await $.prompt.submit({ text })
467 else await $.session.send({ to: { sessionId: todo.sessionId }, text })
468 $.ui.toast("Buddy told Claude it's done")
469 } catch {
470 $.ui.toast(`Buddy couldn't reach that chat. Tell it: ${text}`)
471 }
472 }
473 const petMood = m !== 'holding' && mem >= 80 ? 'tired' : m
474
475 const header = (
476 <Box flexDirection="column" marginBottom={1}>
477 <Box borderStyle="round" borderColor="yellow" paddingX={1} flexDirection="column">
478 <Client
479 key="pet"
480 module="./pet.tsx"
481 height={7}
482 props={{ species: pet.species, mood: petMood, stage: pet.stage, name: pet.name, label: pet.stage === 'egg' ? '' : caption }}
483 />
484 </Box>
485 <Box gap={2}>
486 <Button key="rec-switch" variant={rec ? 'primary' : 'secondary'} label={rec ? '● REC ON' : 'Recording mode'} onPress={toggleRec} />
487 <Text dimColor>{rec ? 'Keys, emails, money and hidden names are covered on screen.' : 'Filming or sharing your screen? Press this first.'}</Text>
488 </Box>
489 <Box gap={2} marginTop={1}>
490 <Button key="tab-status" variant={view === 'status' ? 'primary' : 'secondary'} label="Status" onPress={() => update($, tab, () => 'status')} />
491 <Button key="tab-chats" variant={view === 'chats' ? 'primary' : 'secondary'} label="Chats" onPress={() => update($, tab, () => 'chats')} />
492 <Button key="tab-threads" variant={view === 'threads' ? 'primary' : 'secondary'} label="Threads" onPress={() => update($, tab, () => 'threads')} />
493 <Button
494 key="tab-skills"
495 variant={view === 'skills' ? 'primary' : 'secondary'}
496 label="Skills"
497 onPress={async () => {
498 await update($, tab, () => 'skills')
499 await scanAllSkills($)
500 }}
501 />
502 <Button key="tab-jobs" variant={view === 'jobs' ? 'primary' : 'secondary'} label="Jobs" onPress={() => update($, tab, () => 'jobs')} />
503 <Button key="tab-pets" variant={view === 'pets' ? 'primary' : 'secondary'} label="Pets" onPress={() => update($, tab, () => 'pets')} />
504 <Button key="tab-settings" variant={view === 'settings' ? 'primary' : 'secondary'} label="Settings" onPress={() => update($, tab, () => 'settings')} />
505 <Button key="tab-help" variant={view === 'help' ? 'primary' : 'secondary'} label="Help" onPress={() => update($, tab, () => 'help')} />
506 </Box>
507 </Box>
508 )
509
510
511 const chats = await readChats($)
512 const ICON: Record<string, [string, string, string]> = {
513 waiting: ['⏳', 'yellow', 'waiting on you'],
514 working: ['●', 'cyan', 'working'],
515 done: ['✓', 'green', 'your turn'],
516 idle: ['○', 'gray', 'idle'],
517 }
518 const todoBy = (id: string) => todos.filter(t => t.sessionId === id).length
519 const { Input: BoardInput } = $.ui.resolve(e) as any
520 const messageChat = async (id: string, text: string) => {
521 if (!text.trim()) return
522 try {
523 const sent = await $.session.send({ to: { sessionId: id }, text: `[from another chat, via Buddy] ${text.trim()}` })
524 $.ui.toast(sent.isDelivered ? 'Buddy delivered your message' : `Not delivered: ${sent.reason}`)
525 } catch {
526 $.ui.toast("Buddy couldn't reach that chat")
527 }
528 }
529 const chatRow = (row: ChatRow) => {
530 const [icon, color, word] = ICON[row.status] ?? ['○', 'gray', row.status]
531 return (
532 <Box flexDirection="column">
533 <Text>
534 <Text color={color}>{icon} {word}</Text>
535 <Text dimColor> {ago(Date.now() - row.since)}</Text>
536 <Text bold> {shown(row.title || 'new chat')}</Text>
537 {row.id === selfId ? <Text dimColor> (this chat)</Text> : null}
538 </Text>
539 <Text dimColor> {shown(row.folder)}{todoBy(row.id) > 0 ? ` · ☐ ${todoBy(row.id)} for you` : ''}</Text>
540 {view === 'chats' && row.id !== selfId && (
541 <BoardInput key={`msg-${row.id}`} placeholder="Message this chat..." submitLabel="send" onSubmit={(v: string) => messageChat(row.id, v)} />
542 )}
543 </Box>
544 )
545 }
546 const waitingCount = chats.filter(r => r.status === 'waiting').length
547 const boardCard = card(
548 waitingCount > 0 ? `Your chats · ${waitingCount} waiting on you` : `Your chats · ${chats.length} open`,
549 <Box flexDirection="column" gap={1}>
550 {chats.length === 0 && <Text dimColor>Open Claude Code chats show up here.</Text>}
551 {chats.slice(0, view === 'chats' ? 20 : 5).map(chatRow)}
552 {view !== 'chats' && chats.length > 5 && <Text dimColor>+{chats.length - 5} more in the Chats tab</Text>}
553 </Box>,
554 waitingCount > 0 ? 'yellow' : 'cyan',
555 )
556
557 const todoCard =
558 todos.length > 0 &&
559 card(
560 `For you to do · ${todos.length}`,
561 <Box flexDirection="column" gap={1}>
562 {todos.map(todo => (
563 <Box flexDirection="column">
564 <Box gap={2}>
565 <Button key={`todo-done-${todo.id}`} variant="primary" label="Done" onPress={() => finishTodo(todo, true)} />
566 <Button key={`todo-skip-${todo.id}`} label="Skip" onPress={() => finishTodo(todo, false)} />
567 <Text bold>{shown(todo.task)}</Text>
568 </Box>
569 <Text dimColor>
570 {' '}
571 {todo.chat ? `from "${shown(todo.chat)}"` : 'from a chat'}
572 {todo.sessionId === selfId ? ' (this chat)' : ''}
573 {todo.doneWhen ? ` · done when ${shown(todo.doneWhen)}` : ''}
574 </Text>
575 </Box>
576 ))}
577 <Text dimColor>Done tells that chat to carry on. Skip just clears it.</Text>
578 </Box>,
579 'yellow',
580 )
581
582 if (view === 'skills') {
583 const { Input: SInput } = $.ui.resolve(e) as any
584 const scan = await read($, skillScan)
585 const sv = await read($, skillView)
586 const use = ((await $.store.get('skillUse')) ?? {}) as SkillUse
587 const since = (await $.store.get('skillUseSince')) as number | undefined
588 const items = scan?.items ?? []
589 const usesOf = (it: SkillItem) => use[it.name]?.count ?? 0
590 const q = sv.q.trim().toLowerCase()
591 const filtered = items
592 .filter(it => !q || it.name.toLowerCase().includes(q) || it.description.toLowerCase().includes(q))
593 .sort((a, b) =>
594 sv.sort === 'used'
595 ? usesOf(b) - usesOf(a) || a.name.localeCompare(b.name)
596 : sv.sort === 'unused'
597 ? usesOf(a) - usesOf(b) || b.descChars - a.descChars
598 : b.descChars - a.descChars,
599 )
600 const tokens = Math.round(items.reduce((sum, it) => sum + it.descChars, 0) / 4)
601 const neverUsed = items.filter(it => usesOf(it) === 0).length
602 const dupes = new Set(items.filter((it, i) => items.findIndex(x => x.name === it.name) !== i).map(it => it.name))
603 const setView = (patch: Partial<typeof sv>) => update($, skillView, v => ({ ...v, ...patch }))
604 const act = async (args: string) => {
605 try {
606 await $.command.run({ command: 'buddy-skills', args })
607 } catch {
608 $.ui.toast(`Type /buddy-skills ${args}`)
609 }
610 await scanAllSkills($)
611 }
612 const folderOf = (it: SkillItem) => it.path.slice(it.path.lastIndexOf('/') + 1)
613 const sortBtn = (id: 'used' | 'unused' | 'big', label: string) => (
614 <Button key={`ssort-${id}`} variant={sv.sort === id ? 'primary' : 'secondary'} label={label} onPress={() => setView({ sort: id, shown: 15 })} />
615 )
616 const lastUsed = (it: SkillItem) => (use[it.name] ? `used ${use[it.name].count}x · last ${ago(Date.now() - use[it.name].last)} ago` : 'never used')
617 return (
618 <Box flexDirection="column">
619 {header}
620 <Text dimColor>Every skill you have, how often Claude really uses it, and what it costs. Copy, move or delete with one click.</Text>
621 <Text> </Text>
622 {card(
623 'Your skills',
624 <Box flexDirection="column" gap={1}>
625 {!scan && <Text dimColor>Scanning...</Text>}
626 {scan && (
627 <Box flexDirection="column">
628 <Text>
629 <Text bold>{items.filter(i => i.scope === 'project').length}</Text> in this project · <Text bold>{items.filter(i => i.scope === 'global').length}</Text> global ·{' '}
630 <Text bold color={neverUsed > 0 ? 'yellow' : 'green'}>{neverUsed}</Text> never used
631 </Text>
632 <Text color="yellow">About {tokens.toLocaleString()} tokens of skill names and descriptions ride along in every chat (estimate)</Text>
633 <Text dimColor>{since ? `Counting use since ${new Date(since).toLocaleDateString()}. ` : 'Counting starts now: each skill Claude loads is counted. '}Counts cover every chat and project.</Text>
634 {dupes.size > 0 && <Text color="yellow">Same name in two places: {[...dupes].slice(0, 6).join(', ')}</Text>}
635 {scan.codexLink && <Text dimColor>This project shares its skills with Codex (.agents/skills). Moving a skill out of the project removes it for Codex too.</Text>}
636 </Box>
637 )}
638 <Box gap={2} flexWrap="wrap">
639 {sortBtn('used', 'Most used')}
640 {sortBtn('unused', 'Never used')}
641 {sortBtn('big', 'Biggest')}
642 <Button key="sscan" label="Rescan" onPress={() => scanAllSkills($)} />
643 </Box>
644 <SInput key="squery" placeholder="Search skills..." submitLabel="search" onSubmit={(v: string) => setView({ q: v, shown: 15 })} />
645 {sv.q && <Text dimColor>Showing matches for "{sv.q}" · <Text color="cyan">clear by searching for nothing</Text></Text>}
646 </Box>,
647 'magenta',
648 )}
649 {filtered.slice(0, sv.shown).map(it => {
650 const key = `${it.scope}:${folderOf(it)}`
651 const opened = sv.open === key
652 return (
653 <Box flexDirection="column" marginBottom={1}>
654 <Box gap={2}>
655 <Button key={`sopen-${key}`} label={opened ? 'Close' : 'Manage'} onPress={() => setView({ open: opened ? '' : key })} />
656 <Text>
657 <Text bold>{it.name}</Text>
658 <Text color={it.scope === 'project' ? 'cyan' : 'magenta'}> {it.scope === 'project' ? 'this project' : 'global'}</Text>
659 {it.isLink && <Text dimColor> · linked</Text>}
660 <Text color={usesOf(it) > 0 ? 'green' : 'yellow'}> · {lastUsed(it)}</Text>
661 <Text dimColor> · ~{Math.round(it.descChars / 4)} tokens</Text>
662 </Text>
663 </Box>
664 {opened && (
665 <Box flexDirection="column" marginLeft={4} gap={1}>
666 <Text dimColor>{it.description || '(no description)'}</Text>
667 {it.isLink ? (
668 <Text dimColor>This one is a linked folder, managed somewhere else. Buddy won't copy, move or delete it.</Text>
669 ) : (
670 <Box flexDirection="column" gap={1}>
671 <Box gap={2} flexWrap="wrap">
672 {it.scope === 'project' && <Button key={`sg-${key}`} label="Make global (copy)" onPress={() => act(`global ${folderOf(it)}`)} />}
673 {it.scope === 'project' && <Button key={`sm-${key}`} label="Move to global" onPress={() => act(`moveglobal ${folderOf(it)}`)} />}
674 {it.scope === 'global' && <Button key={`sh-${key}`} label="Add to this project" onPress={() => act(`here ${folderOf(it)}`)} />}
675 <Button key={`sd-${key}`} label="Delete" onPress={() => act(`delete ${key}`)} />
676 </Box>
677 <SInput
678 key={`sc-${key}`}
679 placeholder="Copy to another project: paste its folder path"
680 submitLabel="copy"
681 onSubmit={(v: string) => v.trim() && act(`copy ${key} ${v.trim()}`)}
682 />
683 <Text dimColor>Copies keep the original. Move and Delete ask first and keep a backup in ~/.claude/buddy/skill-backups.</Text>
684 </Box>
685 )}
686 </Box>
687 )}
688 </Box>
689 )
690 })}
691 {filtered.length > sv.shown && <Button key="smore" label={`Show more (${filtered.length - sv.shown} left)`} onPress={() => setView({ shown: sv.shown + 25 })} />}
692 </Box>
693 )
694 }
695
696 if (view === 'threads') {
697 const { Input: TInput } = $.ui.resolve(e) as any
698 const model = await read($, threadModel)
699 const kind = await read($, threadKind)
700 const live = await read($, agentLive)
701 const threads = await read($, chatThreads)
702 const peeked = await read($, peek)
703 const metaAll = ((await $.store.get('agentMeta')) ?? {}) as Record<string, { by: string; model: string }>
704 let helpers: { id: string; description: string; type: string; status: string; parentId?: string; spawnedBy?: string }[] = []
705 try {
706 helpers = (await $.agent.list()) as any
707 } catch {
708 helpers = []
709 }
710 const rowsById = Object.fromEntries((await read($, agents)).map(r => [r.id, r]))
711 const startThread = async (task: string) => {
712 const text = task.trim()
713 if (!text) return
714 if (kind === 'chat') {
715 try {
716 await $.command.run({ command: 'thread', args: text })
717 } catch {
718 $.ui.toast('Type /thread followed by the task')
719 }
720 return
721 }
722 try {
723 const started = await $.agent.spawn({ prompt: text, description: text.slice(0, 40), model })
724 if (started.agentId) {
725 const meta = ((await $.store.get('agentMeta')) ?? {}) as Record<string, unknown>
726 await $.store.set('agentMeta', { ...meta, [started.agentId]: { by: 'you', model } })
727 $.ui.toast(`Buddy started a ${model} helper`)
728 } else $.ui.toast(`Not started: ${started.deny ?? 'refused'}`)
729 } catch (err) {
730 $.ui.toast(`Couldn't start it: ${String((err as Error)?.message ?? err).slice(0, 80)}`)
731 }
732 await update($, tick, n => n + 1)
733 }
734 const peekAgent = async (id: string) => {
735 try {
736 const rows = (await $.session.messages({ agentId: id })) as any
737 const list = Array.isArray(rows) ? rows : []
738 const text = list
739 .slice(-4)
740 .map((r: any) => `${r.role === 'assistant' ? 'Helper' : 'Task'}: ${String(r.text ?? '').replace(/\s+/g, ' ').slice(0, 220)}`)
741 .join('\n')
742 await update($, peek, () => ({ id, text: text || 'Nothing to show yet.' }))
743 } catch {
744 await update($, peek, () => ({ id, text: "Couldn't read that helper's messages." }))
745 }
746 }
747 const messageAgent = async (id: string, text: string) => {
748 if (!text.trim()) return
749 try {
750 const sent = await $.session.send({ to: { agentId: id }, text: text.trim() })
751 $.ui.toast(sent.isDelivered ? 'Buddy passed your message on' : `Not delivered: ${sent.reason}`)
752 } catch {
753 $.ui.toast("Buddy couldn't reach that helper")
754 }
755 }
756 const stopAgent = async (id: string) => {
757 try {
758 await $.tool.call({ tool: 'TaskStop', task_id: id } as any)
759 $.ui.toast('Buddy stopped that helper')
760 } catch {
761 $.ui.toast("Buddy couldn't stop it. Press Esc in the chat to stop everything")
762 }
763 await update($, tick, n => n + 1)
764 }
765 const messageThread = async (id: string, text: string) => {
766 if (!text.trim()) return
767 try {
768 await $.command.run({ command: 'thread', args: `msg ${id} ${text.trim()}` })
769 } catch {
770 $.ui.toast(`Type /thread msg ${id} followed by your message`)
771 }
772 }
773 const isRunning = (s: string) => /run|progress|start|pending|active/i.test(s)
774 const runningCount = helpers.filter(ag => isRunning(ag.status)).length + threads.filter(t => t.status === 'running' || t.status === 'starting').length
775 const pick = (m: string, label: string) => (
776 <Button key={`model-${m}`} variant={model === m ? 'primary' : 'secondary'} label={label} onPress={() => update($, threadModel, () => m)} />
777 )
778 return (
779 <Box flexDirection="column">
780 {header}
781 <Text dimColor>One chat, a whole team. Start helpers or separate chats, watch what each is doing, and steer or stop them.</Text>
782 <Text> </Text>
783 {card(
784 'Start a thread',
785 <Box flexDirection="column" gap={1}>
786 <Box gap={2} flexWrap="wrap">
787 <Button key="kind-helper" variant={kind === 'helper' ? 'primary' : 'secondary'} label="Helper in this chat" onPress={() => update($, threadKind, () => 'helper')} />
788 <Button key="kind-chat" variant={kind === 'chat' ? 'primary' : 'secondary'} label="Separate chat" onPress={() => update($, threadKind, () => 'chat')} />
789 </Box>
790 <Box gap={2} flexWrap="wrap">
791 {pick('sonnet', 'Sonnet')}
792 {pick('opus', 'Opus')}
793 {pick('fable', 'Fable')}
794 </Box>
795 <TInput key="thread-task" placeholder="What should it do? e.g. research the top 5 competitors and summarize" submitLabel="start" onSubmit={startThread} />
796 <Text dimColor>
797 {kind === 'helper'
798 ? 'A helper works inside this chat and reports back to it.'
799 : 'A separate chat is its own Claude session in the background, with its own memory. Its answer lands in the card below.'}
800 </Text>
801 </Box>,
802 'magenta',
803 )}
804 {card(
805 runningCount > 0 ? `Your team · ${runningCount} working` : 'Your team',
806 <Box flexDirection="column" gap={1}>
807 <Text bold>● This chat (the lead)</Text>
808 {helpers.length === 0 && threads.length === 0 && <Text dimColor> No helpers yet. Start one above, or ask Claude to split a job across helpers.</Text>}
809 {helpers.map(ag => {
810 const lv = live[ag.id]
811 const row = rowsById[ag.id]
812 const by = metaAll[ag.id]?.by ?? (ag.spawnedBy ? 'Buddy' : 'Claude')
813 const busy = isRunning(ag.status)
814 return (
815 <Box flexDirection="column">
816 <Text>
817 <Text>{ag.parentId ? ' └ ' : ' └ '}</Text>
818 <Text color={busy ? 'cyan' : 'green'}>{busy ? '● working' : `✓ ${ag.status}`}</Text>
819 <Text bold> {shown(ag.description || ag.type)}</Text>
820 <Text dimColor>
821 {' '}· helper · started by {by}
822 {metaAll[ag.id]?.model ? ` · ${metaAll[ag.id]?.model}` : ''}
823 {row ? ` · ${elapsed(row, now)}` : ''}
824 {lv ? ` · ${lv.steps} steps` : ''}
825 {busy && lv?.tool ? ` · now: ${lv.tool}` : ''}
826 </Text>
827 </Text>
828 <Box gap={2} marginLeft={6}>
829 <Button key={`peek-${ag.id}`} label="Peek" onPress={() => peekAgent(ag.id)} />
830 {busy && <Button key={`stop-${ag.id}`} label="Stop" onPress={() => stopAgent(ag.id)} />}
831 </Box>
832 {busy && (
833 <Box marginLeft={6}>
834 <TInput key={`steer-${ag.id}`} placeholder="Message this helper..." submitLabel="send" onSubmit={(v: string) => messageAgent(ag.id, v)} />
835 </Box>
836 )}
837 {peeked && peeked.id === ag.id && <Text dimColor>{shown(peeked.text)}</Text>}
838 </Box>
839 )
840 })}
841 {threads.map(th => {
842 const busy = th.status === 'running' || th.status === 'starting'
843 return (
844 <Box flexDirection="column">
845 <Text>
846 <Text> └ </Text>
847 <Text color={busy ? 'cyan' : th.status === 'failed' ? 'red' : 'green'}>{busy ? '● working' : th.status === 'failed' ? '✗ failed' : '✓ done'}</Text>
848 <Text bold> {shown(th.task.slice(0, 60))}</Text>
849 <Text dimColor>
850 {' '}· separate chat {th.id} · started by {th.by} · {th.model} · {ago((th.endedAt ?? Date.now()) - th.startedAt)}
851 {th.turns > 1 ? ` · ${th.turns} messages` : ''}
852 {busy && th.lastTool ? ` · now: ${th.lastTool}` : ''}
853 </Text>
854 </Text>
855 <Box gap={2} marginLeft={6}>
856 {!busy && th.answer && (
857 <Button
858 key={`answer-${th.id}`}
859 label={peeked?.id === th.id ? 'Hide answer' : 'Answer'}
860 onPress={() => update($, peek, () => (peeked?.id === th.id ? null : { id: th.id, text: th.answer ?? '' }))}
861 />
862 )}
863 </Box>
864 {!busy && (
865 <Box marginLeft={6}>
866 <TInput key={`tmsg-${th.id}`} placeholder="Next message to this chat..." submitLabel="send" onSubmit={(v: string) => messageThread(th.id, v)} />
867 </Box>
868 )}
869 {peeked && peeked.id === th.id && <Text dimColor>{shown(peeked.text.slice(0, 1500))}</Text>}
870 </Box>
871 )
872 })}
873 </Box>,
874 'cyan',
875 )}
876 <Text dimColor>Your other open chats, with a message box on each, are in the Chats tab. Ask Claude to "split this across three helpers" or "start separate chats for each" and they show up here.</Text>
877 </Box>
878 )
879 }
880
881 if (view === 'chats') {
882 return (
883 <Box flexDirection="column">
884 {header}
885 <Text dimColor>Every Claude Code chat you have open, live. I ping you when one finishes or needs you. Type in a chat's box to send it a message from here.</Text>
886 <Text> </Text>
887 {todoCard}
888 {boardCard}
889 <Text dimColor>Waiting on you = it asked you something. Your turn = it finished and is waiting for your next message.</Text>
890 </Box>
891 )
892 }
893
894 if (view === 'pets') {
895 const choose = async (species: string) => {
896 await $.store.set('pet', { ...pet, stage: 'pet', species })
897 $.ui.toast(`${pet.name} is now a ${species}`)
898 await update($, tick, n => n + 1)
899 }
900 const newEgg = async () => {
901 await $.store.set('pet', { ...pet, stage: 'egg' })
902 await update($, tick, n => n + 1)
903 }
904 return (
905 <Box flexDirection="column">
906 {header}
907 {card(
908 'Pick your pet',
909 <Box flexDirection="column" gap={1}>
910 <Box gap={2} flexWrap="wrap">
911 {SPECIES.slice(0, 4).map(sp => (
912 <Button key={`sp-${sp}`} variant={pet.species === sp && pet.stage === 'pet' ? 'primary' : 'secondary'} label={sp} onPress={() => choose(sp)} />
913 ))}
914 </Box>
915 <Box gap={2} flexWrap="wrap">
916 {SPECIES.slice(4).map(sp => (
917 <Button key={`sp-${sp}`} variant={pet.species === sp && pet.stage === 'pet' ? 'primary' : 'secondary'} label={sp} onPress={() => choose(sp)} />
918 ))}
919 </Box>
920 <Text dimColor>Click your pet above to pet it. Petted {pet.pets ?? 0} times.</Text>
921 </Box>,
922 'yellow',
923 )}
924 {card(
925 'Hatch a surprise',
926 <Box flexDirection="column" gap={1}>
927 <Box gap={2}>
928 <Button key="new-egg" label="Get a new egg" onPress={newEgg} />
929 <Text dimColor>Click the egg to hatch a random pet</Text>
930 </Box>
931 <Text dimColor>Rename: /buddy name Rex · Pick by command: /buddy pet dragon</Text>
932 </Box>,
933 )}
934 {card(
935 'How your pet acts',
936 <Box flexDirection="column">
937 <Text dimColor>Napping (z z) while waiting on you</Text>
938 <Text dimColor>Walking around while Claude works</Text>
939 <Text dimColor>Dancing (♪) when a task finishes</Text>
940 <Text dimColor>Shaking (!) when holding something for you</Text>
941 <Text dimColor>Worn out when the chat's memory is nearly full</Text>
942 </Box>,
943 )}
944 </Box>
945 )
946 }
947
948 if (view === 'jobs') {
949 const group = (name: string, color: string) =>
950 card(
951 name,
952 <Box flexDirection="column" gap={1}>
953 {JOBS.filter(j => j.group === name).map(job => (
954 <Box flexDirection="column">
955 <Box gap={2}>
956 <Button
957 key={`job-${job.id}`}
958 variant={isOn(job.id) ? 'primary' : 'secondary'}
959 label={isOn(job.id) ? 'ON' : 'OFF'}
960 onPress={() => (job.live ? toggle(job.id) : $.ui.toast('Change this one in Buddy settings (/config)'))}
961 />
962 <Text bold color={isOn(job.id) ? 'green' : 'gray'}>{job.name}</Text>
963 <Text dimColor>
964 {job.id === 'sendGate' ? `held ${c.send}x` : job.id === 'spendGate' ? `held ${c.spend}x` : job.id === 'dangerGuard' ? `stopped ${c.danger}x` : ''}
965 </Text>
966 </Box>
967 <Text dimColor>{job.what}</Text>
968 </Box>
969 ))}
970 </Box>,
971 color,
972 )
973 return (
974 <Box flexDirection="column">
975 {header}
976 <Text dimColor>Press ON/OFF to switch a job. It takes effect right away.</Text>
977 <Text> </Text>
978 {group('Guards', 'red')}
979 {group('Watches', 'cyan')}
980 {group('Helps', 'magenta')}
981 <Text dimColor>Your email, locked files, house rules, banned words, extra paid APIs and Codex are in Buddy's settings (/config).</Text>
982 </Box>
983 )
984 }
985
986 const { Input } = $.ui.resolve(e) as any
987 const panelSettings = ((await $.store.get('settings')) ?? {}) as Record<string, any>
988 const setting = (k: string) => (panelSettings[k] !== undefined ? panelSettings[k] : o[k])
989 const saveSetting = async (k: string, v: unknown, label: string) => {
990 const cur = ((await $.store.get('settings')) ?? {}) as Record<string, unknown>
991 await $.store.set('settings', { ...cur, [k]: v })
992 $.ui.toast(`Buddy saved: ${label}`)
993 await update($, tick, n => n + 1)
994 }
995
996 if (view === 'settings') {
997 const field = (k: string, label: string, hint: string, placeholder: string) => (
998 <Box flexDirection="column" marginBottom={1}>
999 <Text bold>{label}</Text>
1000 <Text dimColor>{hint}</Text>
1001 <Text color="yellow">Now: {String(setting(k) ?? '') || '(empty)'}</Text>
1002 <Input key={`set-${k}`} placeholder={placeholder} submitLabel="save" onSubmit={(v: string) => saveSetting(k, v.trim(), label)} />
1003 </Box>
1004 )
1005 return (
1006 <Box flexDirection="column">
1007 {header}
1008 <Text dimColor>Type a new value and press Enter to save. Changes apply right away.</Text>
1009 <Text> </Text>
1010 {card(
1011 'Guards',
1012 <Box flexDirection="column">
1013 {field('myEmail', 'My email', "Where 'Send to me first' sends test copies. Empty hides that button.", 'you@example.com')}
1014 {field('lockedFiles', 'Locked files', 'Comma-separated names or folders Claude may not edit.', '.env, contracts/, finances.xlsx')}
1015 {field('hideNames', 'Hide on screen', 'Names recording mode covers, like clients or your company. Comma-separated.', 'Acme Corp, Jane Smith')}
1016 {field('paidApis', 'Extra paid APIs', 'Comma-separated hosts the spend gate should also hold.', 'api.stripe.com, api.twilio.com')}
1017 </Box>,
1018 'red',
1019 )}
1020 {card(
1021 'Helps',
1022 <Box flexDirection="column">
1023 {field('houseRules', 'House rules', 'Rules Claude gets at the start of each new chat. Separate with ;', 'Never use em dashes; Always cite sources')}
1024 {field('bannedPhrases', 'Banned words', 'Buddy flags a reply that uses any of these. Comma-separated.', 'delve, synergy')}
1025 {field('cacheMinutes', 'Cache lifetime (minutes)', 'How long the cache stays warm after a reply: 5 on most setups, 60 with the 1-hour cache.', '5')}
1026 {field('cacheAskAbove', 'Cache check above ($)', 'Buddy only asks when a cold-cache message would cost more than this.', '0.5')}
1027 {field('donePingSeconds', 'Done ping after (seconds)', 'Pop-up when a task takes longer than this.', '60')}
1028 <Box gap={2} marginTop={1}>
1029 <Button
1030 key="codex-switch"
1031 variant={setting('codexEnabled') === true ? 'primary' : 'secondary'}
1032 label={setting('codexEnabled') === true ? 'Codex ON' : 'Codex OFF'}
1033 onPress={() => saveSetting('codexEnabled', setting('codexEnabled') !== true, 'Codex sidekick')}
1034 />
1035 <Text dimColor>Codex sends the task you give it to OpenAI. Read-only. Needs the Codex CLI installed and logged in.</Text>
1036 </Box>
1037 </Box>,
1038 'magenta',
1039 )}
1040 </Box>
1041 )
1042 }
1043
1044 if (view === 'help') {
1045 return (
1046 <Box flexDirection="column">
1047 {header}
1048 {card(
1049 'What Buddy does',
1050 <Text>
1051 I'm your sidekick. I show every chat you have open, your limits, your cache and your agents. I keep your to-do list when Claude needs you, cover your secrets while you film, and ask before Claude sends an email or spends on a paid API.
1052 </Text>,
1053 'yellow',
1054 )}
1055 {card(
1056 'When I pop up, you choose',
1057 <Box flexDirection="column">
1058 <Text>Send: Send · Send to me first · Approve all today · Cancel</Text>
1059 <Text>Spend: Approve · Approve all today · Cancel</Text>
1060 <Text>Delete: Proceed · Cancel</Text>
1061 <Text dimColor>If my check ever breaks, the answer is no. Type your own answer and Claude reads it as an instruction.</Text>
1062 </Box>,
1063 )}
1064 {card(
1065 'Commands',
1066 <Box flexDirection="column">
1067 <Text>/buddy opens me · /buddy rec turns recording mode on or off · /buddy reset clears the caught counter</Text>
1068 <Text>/handoff saves a note so a fresh chat picks up where you left off</Text>
1069 <Text>/thread task starts a separate background chat (see the Threads tab)</Text>
1070 <Text>/codex task asks Codex for a read-only second opinion (when on)</Text>
1071 </Box>,
1072 )}
1073 {card('Try me', <Text>Press Recording mode, then ask Claude to show your .env. Or ask it to set up something that needs an API key and watch your to-do list.</Text>, 'green')}
1074 {card(
1075 "What I can't catch",
1076 <Text dimColor>
1077 I know connectors, git, deploys, known paid APIs and web requests. A custom script that sends or spends inside its own code can go around me.
1078 </Text>,
1079 )}
1080 </Box>
1081 )
1082 }
1083
1084 const list = await read($, agents)
1085 const held = await read($, lastHeld)
1086 const lims = await read($, limits)
1087 const usd = await read($, cost)
1088 const lastRec = await read($, receipt)
1089 const clog = await read($, codexLog)
1090 const cstat = await read($, codexStatus)
1091 const last = await read($, lastTurnAt)
1092 const welcomed = (await $.store.get('welcomed')) === true
1093 const log = ((await $.store.get('log')) ?? []) as { at: number; kind: string; what: string; choice: string }[]
1094 const appr = ((await $.store.get('approvals')) ?? { date: '', targets: [] }) as { date: string; targets: string[] }
1095 const todayKey = new Date().toISOString().slice(0, 10)
1096 const approvedNow = appr.date === todayKey ? appr.targets : []
1097 const undoApproval = async (target: string) => {
1098 await $.store.set('approvals', { date: todayKey, targets: approvedNow.filter(x => x !== target) })
1099 $.ui.toast('Buddy will ask about that again')
1100 await update($, tick, n => n + 1)
1101 }
1102 const codexOn = setting('codexEnabled') === true
1103 const clock = (at: number) => {
1104 const d = new Date(at)
1105 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1106 }
1107 const KIND_LABEL: Record<string, string> = { send: 'send', spend: 'paid call', danger: 'delete', locked: 'locked file' }
1108 const ttl = Math.max(1, Number(setting('cacheMinutes') ?? 5)) * 60000
1109 const left = last ? last + ttl - Date.now() : 0
1110 const cacheLine = !last
1111 ? 'Cache: starts after the first reply'
1112 : left > 0
1113 ? `Cache warm: ${Math.floor(left / 60000)}:${String(Math.floor((left % 60000) / 1000)).padStart(2, '0')} left`
1114 : 'Cache cold: your next message re-reads the whole chat'
1115 const running = list.filter(r => r.endedAt === undefined).length
1116
1117 const action = (key: string, label: string, onPress: () => any, what: string) => (
1118 <Box gap={2}>
1119 <Box width={18}>
1120 <Button key={key} label={label} onPress={onPress} />
1121 </Box>
1122 <Text dimColor>{what}</Text>
1123 </Box>
1124 )
1125
1126 return (
1127 <Box flexDirection="column">
1128 {header}
1129 {!welcomed &&
1130 card(
1131 'Welcome! Here is how I work',
1132 <Box flexDirection="column" gap={1}>
1133 <Text>1. Keep working as normal. I watch all your chats, your limits and your cache.</Text>
1134 <Text>2. When Claude needs you (a key, a login, an approval), it lands on your to-do list here.</Text>
1135 <Text>3. Filming or sharing your screen? Press Recording mode and I cover your secrets.</Text>
1136 <Text>4. Before Claude sends an email or spends on a paid API, I ask you first.</Text>
1137 <Text dimColor>Jobs turns things on and off · Settings sets your email and locked files · Pets lets you pick your pet.</Text>
1138 <Box>
1139 <Button key="welcome-ok" variant="primary" label="Got it" onPress={async () => { await $.store.set('welcomed', true); await update($, tick, n => n + 1) }} />
1140 </Box>
1141 </Box>,
1142 'green',
1143 )}
1144 {todoCard}
1145 {boardCard}
1146 {card(
1147 'Health',
1148 <Box flexDirection="column">
1149 <Text color={tone(mem)}>
1150 {bar(mem, 16)} {mem}% chat memory{mem >= 80 ? ' · compact soon' : ''}
1151 </Text>
1152 {lims.map(l => (
1153 <Text color={tone(l.percentUsed)}>
1154 {bar(l.percentUsed, 16)} {Math.round(l.percentUsed)}% {LIMIT_NAMES[l.kind] ?? l.kind} limit
1155 </Text>
1156 ))}
1157 <Box gap={2}>
1158 <Text color={left > 60000 ? 'green' : left > 0 ? 'yellow' : 'gray'}>{cacheLine}</Text>
1159 <Button
1160 key="cache-toggle"
1161 variant={isOn('cacheCheck') ? 'primary' : 'secondary'}
1162 label={isOn('cacheCheck') ? 'Price check ON' : 'Price check OFF'}
1163 onPress={() => toggle('cacheCheck')}
1164 />
1165 </Box>
1166 {usd > 0 && <Text dimColor>This chat at API rates: ${usd.toFixed(2)} (not your bill on a subscription)</Text>}
1167 </Box>,
1168 'yellow',
1169 )}
1170 {card(
1171 'Saved you from',
1172 <Box flexDirection="column">
1173 <Box gap={4}>
1174 <Box flexDirection="column" alignItems="center">
1175 <Text bold color="red">{c.send}</Text>
1176 <Text dimColor>sends held</Text>
1177 </Box>
1178 <Box flexDirection="column" alignItems="center">
1179 <Text bold color="red">{c.spend}</Text>
1180 <Text dimColor>paid calls held</Text>
1181 </Box>
1182 <Box flexDirection="column" alignItems="center">
1183 <Text bold color="red">{c.danger}</Text>
1184 <Text dimColor>deletes stopped</Text>
1185 </Box>
1186 </Box>
1187 <Text dimColor>Each number counts a time Claude tried to send, spend or delete and Buddy asked you first.</Text>
1188 <Text dimColor>Last one: {held || 'nothing yet'}</Text>
1189 </Box>,
1190 'red',
1191 )}
1192 {approvedNow.length > 0 &&
1193 card(
1194 'Approved for today',
1195 <Box flexDirection="column" gap={1}>
1196 <Text dimColor>These won't ask again until tomorrow. Undo to be asked again.</Text>
1197 {approvedNow.map(target => (
1198 <Box gap={2}>
1199 <Button key={`undo-${target}`} label="Undo" onPress={() => undoApproval(target)} />
1200 <Text>{target.replace(/^(mcp|host|cli|shell|git|gh|vercel|fly|npm):/, '')}</Text>hooks/jobs/extras.tsx 169 lines1import { atom, read, update } from 'claude-code'
2
3import type { Limit } from '../../types'
4
5// Buddy's shared state (same keys in every file, so every job sees the same values).
6const limits = atom({ plugin: 'buddy', key: 'limits' } as const, [] as Limit[])
7const cost = atom({ plugin: 'buddy', key: 'cost' } as const, 0)
8const editing = atom({ plugin: 'buddy', key: 'editing' } as const, [] as string[])
9const codexLog = atom({ plugin: 'buddy', key: 'codexLog' } as const, [] as string[])
10const codexStatus = atom({ plugin: 'buddy', key: 'codexStatus' } as const, '')
11
12type Options = {
13 lockedFiles?: string
14 houseRules?: string
15 codexEnabled?: boolean
16 codexModel?: string
17}
18
19const list = (text: string | undefined, sep: RegExp) =>
20 (text ?? '')
21 .split(sep)
22 .map(s => s.trim())
23 .filter(Boolean)
24
25const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
26
27// A locked entry matches a file name, a path ending, or a folder ("contracts/").
28const isLocked = (path: string, locked: string[]) => {
29 const p = norm(path)
30 return locked.some(raw => {
31 const l = norm(raw)
32 if (l.endsWith('/')) return p.includes('/' + l) || p.startsWith(l)
33 return p === l || p.endsWith('/' + l)
34 })
35}
36
37const short = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + '…' : text)
38
39// Runs Codex read-only on one task and streams its progress into Buddy's pane.
40// Returns Codex's final answer, or why there is none.
41async function runCodex($: any, task: string, model: string, cwd: string): Promise<string> {
42 await update($, codexStatus, () => 'running')
43 await update($, codexLog, () => [`▶ ${short(task, 70)}`])
44 let answer = ''
45 let buffer = ''
46 const run = $.process.spawn({
47 argv: ['codex', 'exec', '-m', model, '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '--json', '-C', cwd, task],
48 input: '',
49 })
50 for await (const chunk of run) {
51 if (chunk.stream !== 'stdout') continue
52 buffer += chunk.text
53 const lines = buffer.split('\n')
54 buffer = lines.pop() ?? ''
55 for (const line of lines) {
56 let ev: any
57 try {
58 ev = JSON.parse(line)
59 } catch {
60 continue
61 }
62 const item = ev.item
63 if (ev.type === 'item.completed' && item?.type === 'agent_message') {
64 answer = String(item.text ?? '')
65 await update($, codexLog, l => [...l, `💬 ${short(answer.replace(/\s+/g, ' '), 70)}`].slice(-8))
66 } else if (ev.type === 'item.started' && item?.type === 'command_execution') {
67 await update($, codexLog, l => [...l, `$ ${short(String(item.command ?? ''), 70)}`].slice(-8))
68 } else if (ev.type === 'turn.completed') {
69 const used = ev.usage?.input_tokens ?? 0
70 await update($, codexLog, l => [...l, `✓ done (${Math.round(used / 1000)}k tokens in)`].slice(-8))
71 }
72 }
73 }
74 await update($, codexStatus, () => (answer ? 'done' : 'no answer'))
75 return answer || 'Codex finished without an answer. Check that `codex` is installed and logged in.'
76}
77
78// Settings saved in Buddy's panel win over the config file.
79async function readSettings($: any, options: Options): Promise<Options> {
80 return { ...options, ...(((await $.store.get('settings')) ?? {}) as Options) }
81}
82
83export function registerExtras(on: any, options: Options) {
84
85 // Locked files and the work receipt both watch file edits.
86 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($: any, e: any, next: any) => {
87 const path = String(e.file_path ?? e.notebook_path ?? '')
88 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
89 const locked = list((await readSettings($, options)).lockedFiles, /,/)
90 if (live.lockedFiles !== false && path && isLocked(path, locked)) {
91 $.ui.toast(`Buddy kept Claude out of a locked file: ${short(path, 60)}`)
92 const log = ((await $.store.get('log')) ?? []) as any[]
93 await $.store.set('log', [{ at: Date.now(), kind: 'locked', what: `edit ${short(path, 80)}`, choice: 'blocked (locked file)' }, ...log].slice(0, 20))
94 return { deny: `${path} is locked in Buddy's settings. Don't edit it; tell the user what you wanted to change.` }
95 }
96 const ran = await next(e)
97 if (path && ran?.deny === undefined && ran?.isError !== true) {
98 await update($, editing, files => (files.includes(path) ? files : [...files, path]))
99 }
100 return ran
101 })
102
103 // Limits and cost, pushed after every turn and when a limit moves a point.
104 on('session.measure', async ($: any, e: any, next: any) => {
105 const before = await read($, limits)
106 const now: Limit[] = (e.rateLimits ?? []).map((r: any) => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
107 await update($, limits, () => now)
108 await update($, cost, () => Number(e.cost?.usd ?? 0))
109 for (const r of now) {
110 const was = before.find(b => b.kind === r.kind)?.percentUsed ?? 0
111 if (r.percentUsed >= 80 && was < 80) $.ui.toast(`Buddy: you've used ${Math.round(r.percentUsed)}% of your ${r.kind} limit`)
112 }
113 return next(e)
114 })
115
116 // House rules ride along with every new chat, unseen.
117 {
118 on('prompt.context', async ($: any, e: any, next: any) => {
119 const got = await next(e)
120 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
121 const rules = list((await readSettings($, options)).houseRules, /[\n;]/)
122 const blocks = [...got.blocks]
123 if (live.todoInbox !== false) {
124 blocks.push({
125 name: 'buddy-todo-inbox',
126 text:
127 'The user runs Buddy, which keeps their to-do list. Whenever something needs the user to act (add or paste an API key or secret, log in, approve or pay for something, install an app, check something by hand, make a decision only they can make), call the mcp__buddy__todo_for_you tool once per task, as well as mentioning it in your reply. Keep each task to one short sentence starting with a verb.',
128 })
129 }
130 if (live.houseRules === false || rules.length === 0) return { ...got, blocks }
131 const text = "The user's house rules (from Buddy). Follow them in every reply:\n" + rules.map(r => `- ${r}`).join('\n')
132 return { ...got, blocks: [...blocks, { name: 'buddy-house-rules', text }] }
133 })
134 }
135
136 // Handoff note: a summary of this chat, saved to a file, ready for a fresh chat.
137 on('command.run', { command: 'handoff' }, async ($: any, e: any) => {
138 const reply = await $.model.fork({
139 prompt:
140 'Write a handoff note for continuing this work in a fresh chat. Sections: Goal, What is done, Decisions made, Files involved, Next steps, Open questions. Be specific and short. Markdown only.',
141 })
142 if (!reply.isAnswered) return { text: `Buddy couldn't write a handoff yet (${reply.reason}).` }
143 const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
144 const file = `handoff-${stamp}.md`
145 await $.fs.write(file, reply.text)
146 return { text: `Handoff saved to ${file}. Start a new chat and say: "Read ${file} and continue."` }
147 })
148
149 // Codex sidekick: opt-in, read-only.
150 {
151 on('tool.call', { tool: 'mcp__buddy__codex' }, async ($: any, e: any) => {
152 const s = await readSettings($, options)
153 if (s.codexEnabled !== true) return { result: 'The Codex sidekick is switched off in Buddy settings.' }
154 const answer = await runCodex($, String(e.task ?? ''), (s.codexModel ?? 'gpt-5.5').trim() || 'gpt-5.5', String(e.cwd ?? '.'))
155 return { result: answer }
156 })
157
158 on('command.run', { command: 'codex' }, async ($: any, e: any) => {
159 const task = String(e.args ?? '').trim()
160 if (!task) return { text: 'Usage: /codex <task>' }
161 const s = await readSettings($, options)
162 if (s.codexEnabled !== true) return { text: 'Turn on the Codex sidekick in Buddy > Settings first.' }
163 await $.ui.open({ id: 'buddy', title: 'Buddy' })
164 const answer = await runCodex($, task, (s.codexModel ?? 'gpt-5.5').trim() || 'gpt-5.5', '.')
165 return { text: `Codex says:\n\n${answer}` }
166 })
167 }
168}
169hooks/jobs/guards.tsx 129 lines1import { atom, update } from 'claude-code'
2
3import { classify, describeArgs, setExtraPaidHosts, toMeOnly } from '../lib/patterns.ts'
4import type { Hit } from '../lib/patterns.ts'
5import type { AgentRow, Caught, Mood } from '../../types'
6
7// Buddy's shared state (same keys in every file, so every job sees the same values).
8const caught = atom({ plugin: 'buddy', key: 'caught' } as const, { send: 0, spend: 0, danger: 0 } as Caught)
9const lastHeld = atom({ plugin: 'buddy', key: 'lastHeld' } as const, '')
10const mood = atom({ plugin: 'buddy', key: 'mood' } as const, 'sleepy' as Mood)
11const agentLive = atom({ plugin: 'buddy', key: 'agentLive' } as const, {} as Record<string, { tool?: string; steps: number; lastAt: number }>)
12
13// The send gate, spend gate and danger guard share one tool.call hook.
14// Each held call is answered through Buddy's own question, so it still asks when
15// Claude Code runs with permissions bypassed. Anything that breaks denies (fail closed).
16
17type Options = { myEmail?: string; sendGate?: boolean; spendGate?: boolean; dangerGuard?: boolean; paidApis?: string }
18
19const today = () => new Date().toISOString().slice(0, 10)
20
21const JOB_FOR: Record<Hit['kind'], 'sendGate' | 'spendGate' | 'dangerGuard'> = { send: 'sendGate', spend: 'spendGate', danger: 'dangerGuard' }
22
23// A job is on unless the settings or the Jobs tab switched it off.
24const enabled = (hit: Hit, o: Options, live: Record<string, boolean> | undefined) => {
25 const job = JOB_FOR[hit.kind]
26 if (live && live[job] !== undefined) return live[job]
27 return (o as any)[job] !== false
28}
29
30const TITLES: Record<Hit['kind'], string> = {
31 send: 'Buddy is holding something that would leave your machine.',
32 spend: 'Buddy is holding a paid API call.',
33 danger: 'Buddy is holding a command that can delete or overwrite work.',
34}
35
36type LogEntry = { at: number; kind: string; what: string; choice: string }
37type Approvals = { date: string; targets: string[] }
38
39// Buddy's activity log: the last 20 things he held and what you chose.
40async function logEvent($: any, entry: LogEntry) {
41 const log = ((await $.store.get('log')) ?? []) as LogEntry[]
42 await $.store.set('log', [entry, ...log].slice(0, 20))
43}
44
45// "Approve all today" list, visible and undoable from the panel.
46async function approvedToday($: any, target: string) {
47 const a = ((await $.store.get('approvals')) ?? { date: '', targets: [] }) as Approvals
48 return a.date === today() && a.targets.includes(target)
49}
50
51async function approveToday($: any, target: string) {
52 const a = ((await $.store.get('approvals')) ?? { date: '', targets: [] }) as Approvals
53 const targets = a.date === today() ? a.targets : []
54 await $.store.set('approvals', { date: today(), targets: targets.includes(target) ? targets : [...targets, target] })
55}
56
57export function registerGuards(on: any, options: Options) {
58 setExtraPaidHosts(options.paidApis)
59 on('tool.call', async ($: any, e: any, next: any) => {
60 // Threads tab: what each helper agent is doing right now.
61 if (e.agentId) {
62 const id = e.agentId as string
63 try {
64 await update($, agentLive, (all: Record<string, { tool?: string; steps: number; lastAt: number }>) => ({ ...all, [id]: { steps: all[id]?.steps ?? 0, tool: String(e.tool), lastAt: Date.now() } }))
65 } catch {
66 // display only
67 }
68 }
69 // Settings saved in Buddy's panel win over the config file.
70 const s = { ...options, ...(((await $.store.get('settings')) ?? {}) as Options) }
71 setExtraPaidHosts(s.paidApis)
72 const hit = classify(String(e.tool), e as Record<string, unknown>)
73 if (!hit) return next(e)
74 const live = (await $.store.get('jobs')) as Record<string, boolean> | undefined
75 if (!enabled(hit, s, live)) return next(e)
76
77 if (hit.kind !== 'danger' && (await approvedToday($, hit.target))) {
78 await logEvent($, { at: Date.now(), kind: hit.kind, what: hit.what, choice: 'approved earlier today' })
79 return next(e)
80 }
81
82 const detail = describeArgs(e as Record<string, unknown>)
83 const summary = detail ? `${hit.what}. ${detail}` : hit.what
84 await update($, lastHeld, () => `${hit.kind}: ${summary}`)
85 await update($, mood, () => 'holding')
86
87 const myEmail = (s.myEmail ?? '').trim()
88 const choices =
89 hit.kind === 'danger'
90 ? ['Proceed', 'Cancel']
91 : hit.kind === 'send' && hit.canSendToMe && myEmail
92 ? ['Send', 'Send to me first', 'Approve all today', 'Cancel']
93 : [hit.kind === 'send' ? 'Send' : 'Approve', 'Approve all today', 'Cancel']
94
95 let answer = 'Cancel'
96 try {
97 answer = await $.ui.ask(`${TITLES[hit.kind]} ${summary}. Let it go?`, choices)
98 } catch {
99 answer = 'Cancel' // dismissed, nobody to ask, or anything broke: nothing goes out
100 }
101 await update($, mood, () => 'working')
102 await logEvent($, { at: Date.now(), kind: hit.kind, what: summary.slice(0, 140), choice: answer.slice(0, 80) })
103
104 if (answer === 'Send' || answer === 'Approve' || answer === 'Proceed') return next(e)
105 if (answer === 'Approve all today') {
106 await approveToday($, hit.target)
107 return next(e)
108 }
109 if (answer === 'Send to me first') {
110 const rewritten = toMeOnly(e as Record<string, unknown>, myEmail)
111 if (!rewritten) return { deny: 'Buddy could not find a recipient to rewrite, so nothing was sent.' }
112 await update($, lastHeld, () => `send: test copy to ${myEmail} only (${hit.what})`)
113 await update($, caught, c => ({ ...c, send: c.send + 1 }))
114 return next(rewritten)
115 }
116
117 await update($, caught, c => ({ ...c, [hit.kind]: c[hit.kind] + 1 }))
118 if (answer !== 'Cancel') {
119 return { deny: `Not sent. The user answered Buddy with: "${answer}". Do what they said, then ask again.` }
120 }
121 return { deny: `Cancelled by the user in Buddy's ${hit.kind} guard. Nothing happened.` }
122 }).catch(($: any, e: any, next: any) => {
123 // Fail closed for anything Buddy guards; every other tool carries on as normal.
124 // When unsure whether a guard is on, treat it as on.
125 const hit = classify(String(e.tool), e as Record<string, unknown>)
126 return hit && enabled(hit, options, undefined) ? { deny: "Buddy's guard hit an error, so nothing was run or sent." } : next(e)
127 })
128}
129hooks/jobs/power.tsx 84 lines1import { atom, read, update } from 'claude-code'
2
3import { maskDeep, maskText, nameList } from '../lib/mask.ts'
4
5// Buddy's shared state (same keys in every file, so every job sees the same values).
6const recOn = atom({ plugin: 'buddy', key: 'recOn' } as const, false)
7const tick = atom({ plugin: 'buddy', key: 'tick' } as const, 0)
8
9type Options = { hideNames?: string }
10
11export type Todo = { id: string; task: string; doneWhen?: string; sessionId: string; chat: string; at: number; done?: boolean }
12
13// Names to hide on screen: the settings panel wins over the install options.
14async function hiddenNames($: any, options: Options): Promise<string[]> {
15 const panel = ((await $.store.get('settings')) ?? {}) as Options
16 return nameList(panel.hideNames ?? options.hideNames)
17}
18
19export function registerPower(on: any, options: Options) {
20 // ---------- Recording mode: mask the transcript while you film or share your screen ----------
21 // Each hook reads `recOn`, so flipping it redraws every row at once.
22 on('ui.render', { component: 'UserMessage' }, async ($: any, e: any, next: any) => {
23 if (!(await read($, recOn))) return next(e)
24 const names = await hiddenNames($, options)
25 return next({ ...e, props: { ...e.props, text: maskText(String(e.props.text ?? ''), names) } })
26 })
27 on('ui.render', { component: 'AssistantMessage' }, async ($: any, e: any, next: any) => {
28 if (!(await read($, recOn))) return next(e)
29 const names = await hiddenNames($, options)
30 return next({ ...e, props: { ...e.props, text: maskText(String(e.props.text ?? ''), names) } })
31 })
32 on('ui.render', { component: 'CommandOutput' }, async ($: any, e: any, next: any) => {
33 if (!(await read($, recOn))) return next(e)
34 const names = await hiddenNames($, options)
35 return next({ ...e, props: { ...e.props, text: maskText(String(e.props.text ?? ''), names) } })
36 })
37 on('ui.render', { component: 'ToolUse' }, async ($: any, e: any, next: any) => {
38 if (!(await read($, recOn))) return next(e)
39 const names = await hiddenNames($, options)
40 const props = { ...e.props, input: maskDeep(e.props.input, names) }
41 if (e.props.output !== undefined) props.output = maskDeep(e.props.output, names)
42 return next({ ...e, props })
43 })
44 on('ui.render', { component: 'ToolResult' }, async ($: any, e: any, next: any) => {
45 if (!(await read($, recOn))) return next(e)
46 const names = await hiddenNames($, options)
47 return next({ ...e, props: { ...e.props, output: maskDeep(e.props.output, names) } })
48 })
49
50 // ---------- To-do inbox: things only you can do, handed to Buddy instead of buried in a reply ----------
51 on('tool.call', { tool: 'mcp__buddy__todo_for_you' }, async ($: any, e: any) => {
52 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
53 if (live.todoInbox === false) return { result: 'The to-do inbox is off in Buddy. Tell the user in your reply instead.' }
54 const task = String(e.task ?? '').trim().slice(0, 140)
55 if (!task) return { result: 'Give the task in one short sentence.' }
56 const sessionId = await $.session.id()
57 let chat = ''
58 try {
59 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.') as string
60 const row = JSON.parse(await $.fs.read(`${home.replace(/\\/g, '/')}/.claude/buddy/chats/${sessionId}.json`))
61 chat = row.title || row.folder || ''
62 } catch {
63 chat = ''
64 }
65 const todo: Todo = {
66 id: `${Date.now().toString(36)}${Math.floor(Math.random() * 1e4)}`,
67 task,
68 doneWhen: e.doneWhen ? String(e.doneWhen).slice(0, 140) : undefined,
69 sessionId,
70 chat,
71 at: Date.now(),
72 }
73 const list = ((await $.store.get('todos')) ?? []) as Todo[]
74 await $.store.set('todos', [...list.filter(t => !t.done), todo].slice(-20))
75 await $.ui.open({ id: 'buddy', title: 'Buddy' })
76 $.ui.toast(`Buddy: new to-do for you: ${task}`)
77 await update($, tick, (n: number) => n + 1)
78 return {
79 result:
80 'Added to the user\'s Buddy to-do list. They press Done when finished and you will get a message saying so. Carry on with anything that does not depend on it; otherwise stop and wait.',
81 }
82 })
83}
84hooks/jobs/threads.tsx 187 lines1import { atom, read, update } from 'claude-code'
2
3// Threads: one chat runs a team. Helper agents inside this chat, separate background
4// Claude chats, and messages to any other open chat, all managed from Buddy's Threads tab.
5
6export type AgentLive = { tool?: string; steps: number; lastAt: number }
7export type AgentMeta = { by: string; model: string; task: string }
8export type ChatThread = {
9 id: string
10 sessionId?: string
11 task: string
12 model: string
13 by: string
14 status: 'starting' | 'running' | 'done' | 'failed'
15 startedAt: number
16 endedAt?: number
17 lastTool?: string
18 answer?: string
19 turns: number
20}
21
22const agentLive = atom({ plugin: 'buddy', key: 'agentLive' } as const, {} as Record<string, AgentLive>)
23const chatThreads = atom({ plugin: 'buddy', key: 'chatThreads' } as const, [] as ChatThread[])
24const threadModel = atom({ plugin: 'buddy', key: 'threadModel' } as const, 'sonnet')
25
26type Options = { threadPermission?: string }
27
28const short = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + '…' : text)
29
30async function boardDir($: any): Promise<string> {
31 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.') as string
32 return `${home.replace(/\\/g, '/')}/.claude/buddy/chats`
33}
34
35// Every open chat from the board files (the same files the Chats tab reads).
36async function listBoard($: any): Promise<{ id: string; title: string; folder: string; status: string }[]> {
37 const dir = await boardDir($)
38 if (!(await $.fs.exists(dir))) return []
39 const self = await $.session.id()
40 const rows: { id: string; title: string; folder: string; status: string }[] = []
41 for (const ent of await $.fs.list(dir)) {
42 if (ent.kind !== 'file' || !ent.name.endsWith('.json')) continue
43 try {
44 const row = JSON.parse(await $.fs.read(`${dir}/${ent.name}`))
45 if (row.status !== 'closed' && Date.now() - row.updatedAt < 180000 && row.id !== self) rows.push({ id: row.id, title: row.title ?? '', folder: row.folder ?? '', status: row.status })
46 } catch {
47 // skip unreadable files
48 }
49 }
50 return rows
51}
52
53// Runs one turn of a separate background Claude chat (a fresh one, or the next message to one
54// that already exists via --resume) and keeps its row in the Threads tab up to date.
55async function runChatThread($: any, id: string, prompt: string, options: Options, tellLead: boolean) {
56 const rows = await read($, chatThreads)
57 const row = rows.find(r => r.id === id)
58 if (!row) return
59 const panel = ((await $.store.get('settings')) ?? {}) as Options
60 const mode = String(panel.threadPermission ?? options.threadPermission ?? 'acceptEdits').trim() || 'acceptEdits'
61 const argv = ['claude', '-p', '--output-format', 'stream-json', '--verbose', '--model', row.model, '--permission-mode', mode]
62 if (row.sessionId) argv.push('--resume', row.sessionId)
63 argv.push(prompt)
64 const set = (patch: Partial<ChatThread>) => update($, chatThreads, (list: ChatThread[]) => list.map(r => (r.id === id ? { ...r, ...patch } : r)))
65 await set({ status: 'running', endedAt: undefined })
66 let answer = ''
67 let failed = ''
68 try {
69 const child = $.process.spawn({ argv, cwd: await $.session.cwd(), env: { BUDDY_CHILD: '1' }, input: '' })
70 let buffer = ''
71 for await (const piece of child) {
72 if (piece.stream !== 'stdout' || typeof piece.text !== 'string') continue
73 buffer += piece.text
74 const lines = buffer.split('\n')
75 buffer = lines.pop() ?? ''
76 for (const line of lines) {
77 let ev: any
78 try {
79 ev = JSON.parse(line)
80 } catch {
81 continue
82 }
83 if (ev.type === 'system' && ev.session_id) await set({ sessionId: ev.session_id })
84 if (ev.type === 'assistant') {
85 const tools = (ev.message?.content ?? []).filter((c: any) => c.type === 'tool_use').map((c: any) => c.name)
86 if (tools.length > 0) await set({ lastTool: tools[tools.length - 1] })
87 }
88 if (ev.type === 'result') {
89 answer = String(ev.result ?? '')
90 if (ev.is_error) failed = answer || 'the chat ended with an error'
91 if (ev.session_id) await set({ sessionId: ev.session_id })
92 }
93 }
94 }
95 } catch (err) {
96 failed = String((err as Error)?.message ?? err).slice(0, 200)
97 }
98 const cur = (await read($, chatThreads)).find(r => r.id === id)
99 await set({ status: failed ? 'failed' : 'done', endedAt: Date.now(), answer: short(failed || answer, 4000), turns: (cur?.turns ?? 0) + 1 })
100 $.ui.toast(failed ? `Buddy: "${short(row.task, 40)}" failed` : `Buddy: separate chat "${short(row.task, 40)}" finished`)
101 if (tellLead) {
102 const text = failed
103 ? `[Buddy] Separate chat ${id} ("${short(row.task, 60)}") failed: ${short(failed, 400)}`
104 : `[Buddy] Separate chat ${id} ("${short(row.task, 60)}") finished. Its answer:\n\n${short(answer, 6000)}`
105 try {
106 await $.prompt.submit({ text })
107 } catch {
108 // the answer stays in the Threads tab
109 }
110 }
111}
112
113export function registerThreads(on: any, options: Options) {
114 // Live steps for each helper agent: one step per model request in its loop.
115 on('turn.step', async function* ($: any, e: any, next: any) {
116 if (e.agentId) {
117 const id = e.agentId as string
118 try {
119 await update($, agentLive, (all: Record<string, AgentLive>) => ({ ...all, [id]: { ...(all[id] ?? { steps: 0 }), steps: (all[id]?.steps ?? 0) + 1, lastAt: Date.now() } }))
120 } catch {
121 // display only
122 }
123 }
124 return yield* next(e)
125 })
126
127 // The lead chat's tools: see the other chats, message them, and start separate ones.
128 on('tool.call', { tool: 'mcp__buddy__list_chats' }, async ($: any) => {
129 const rows = await listBoard($)
130 const threads = await read($, chatThreads)
131 return {
132 result: JSON.stringify({
133 openChats: rows,
134 separateChatsStartedHere: threads.map(t => ({ id: t.id, task: t.task, model: t.model, status: t.status, answer: t.answer ? short(t.answer, 500) : undefined })),
135 }),
136 }
137 })
138
139 on('tool.call', { tool: 'mcp__buddy__message_chat' }, async ($: any, e: any) => {
140 const target = String(e.chatId ?? '').trim()
141 const text = String(e.text ?? '').trim()
142 if (!target || !text) return { result: 'Give chatId and text.' }
143 const threads = await read($, chatThreads)
144 const mine = threads.find(t => t.id === target || t.sessionId === target)
145 if (mine) {
146 if (mine.status === 'running' || mine.status === 'starting') return { result: `Separate chat ${mine.id} is still working. Wait for its answer, then message it.` }
147 void runChatThread($, mine.id, text, options, true)
148 return { result: `Sent to separate chat ${mine.id}. Its reply will arrive as a new message from Buddy.` }
149 }
150 const sent = await $.session.send({ to: { sessionId: target }, text: `[from another chat, via Buddy] ${text}` })
151 $.ui.toast(sent.isDelivered ? 'Buddy delivered your message' : `Buddy couldn't deliver it: ${sent.reason}`)
152 return { result: sent.isDelivered ? 'Delivered to that chat.' : `Not delivered: ${sent.reason}` }
153 })
154
155 // /thread <task> starts a separate chat from the panel; /thread msg <id> <text> sends it the next message.
156 on('command.run', { command: 'thread' }, async ($: any, e: any) => {
157 const args = String(e.args ?? '').trim()
158 const m = args.match(/^msg\s+(\S+)\s+([\s\S]+)$/)
159 if (m) {
160 const row = (await read($, chatThreads)).find(r => r.id === m[1])
161 if (!row) return { text: `No separate chat called ${m[1]}.` }
162 if (row.status === 'running' || row.status === 'starting') return { text: 'That chat is still working. Message it when it finishes.' }
163 void runChatThread($, row.id, m[2], options, false)
164 return { text: `Sent to ${row.id}.` }
165 }
166 if (!args) return { text: 'Usage: /thread <task> · /thread msg <id> <text>' }
167 const model = await read($, threadModel)
168 const id = `t${Date.now().toString(36).slice(-5)}`
169 await update($, chatThreads, (list: ChatThread[]) => [...list, { id, task: short(args, 300), model, by: 'you', status: 'starting' as const, startedAt: Date.now(), turns: 0 }].slice(-12))
170 void runChatThread($, id, args, options, false)
171 return { text: `Started separate chat ${id} on ${model}. Watch it in Buddy's Threads tab.` }
172 })
173
174 on('tool.call', { tool: 'mcp__buddy__start_chat' }, async ($: any, e: any) => {
175 const task = String(e.task ?? '').trim()
176 if (!task) return { result: 'Give the task.' }
177 const model = String(e.model ?? 'sonnet').trim() || 'sonnet'
178 const id = `t${Date.now().toString(36).slice(-5)}`
179 await update($, chatThreads, (list: ChatThread[]) => [...list, { id, task: short(task, 300), model, by: 'Claude', status: 'starting' as const, startedAt: Date.now(), turns: 0 }].slice(-12))
180 await $.ui.open({ id: 'buddy', title: 'Buddy' })
181 void runChatThread($, id, task, options, true)
182 return {
183 result: `Started separate chat ${id} on ${model}. It runs in the background; its answer will arrive as a new message from Buddy. Message it later with mcp__buddy__message_chat using chatId "${id}". Carry on meanwhile.`,
184 }
185 })
186}
187hooks/jobs/skills.tsx 211 lines1import { atom, read, update } from 'claude-code'
2
3// Skills manager: every skill you have (global and this project), how often Claude actually
4// uses each one, what their descriptions cost every chat, and copy / move / delete between
5// projects. Copies and deletes back up first, never touch linked folders, and ask before deleting.
6
7export type SkillItem = {
8 name: string
9 scope: 'global' | 'project'
10 path: string
11 isLink: boolean
12 descChars: number
13 description: string
14}
15export type SkillScan = { at: number; cwd: string; items: SkillItem[]; codexLink: boolean }
16export type SkillUse = Record<string, { count: number; last: number }>
17
18const skillScan = atom({ plugin: 'buddy', key: 'skillScan' } as const, null as SkillScan | null)
19const tick = atom({ plugin: 'buddy', key: 'tick' } as const, 0)
20
21export const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
22const quotePs = (p: string) => `'${p.replace(/'/g, "''")}'`
23
24// Reads the name and description from a SKILL.md's frontmatter (single line, quoted, or a | / > block).
25export function frontmatter(text: string): { name?: string; description: string } {
26 const m = text.match(/^---\s*\n([\s\S]*?)\n---/)
27 if (!m) return { description: '' }
28 const lines = m[1].split('\n')
29 let name: string | undefined
30 let description = ''
31 for (let i = 0; i < lines.length; i++) {
32 const line = lines[i]
33 const nm = line.match(/^name:\s*(.+)$/)
34 if (nm) name = nm[1].trim().replace(/^["']|["']$/g, '')
35 const dm = line.match(/^description:\s*(.*)$/)
36 if (dm) {
37 const first = dm[1].trim()
38 if (first === '|' || first === '>' || first === '|-' || first === '>-' || first === '') {
39 const block: string[] = []
40 for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) block.push(lines[j].trim())
41 description = block.join(' ')
42 } else description = first.replace(/^["']|["']$/g, '')
43 }
44 }
45 return { name, description }
46}
47
48async function homeDir($: any): Promise<string> {
49 return norm(((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.') as string)
50}
51
52async function scanDir($: any, dir: string, scope: 'global' | 'project'): Promise<SkillItem[]> {
53 if (!(await $.fs.exists(dir))) return []
54 const out: SkillItem[] = []
55 for (const ent of await $.fs.list(dir)) {
56 if (ent.name.startsWith('.')) continue
57 const path = `${dir}/${ent.name}`
58 if (ent.kind !== 'dir' && !ent.isLink) continue
59 let text = ''
60 try {
61 text = await $.fs.read(`${path}/SKILL.md`)
62 } catch {
63 continue
64 }
65 const fm = frontmatter(text)
66 out.push({
67 name: fm.name || ent.name,
68 scope,
69 path,
70 isLink: Boolean(ent.isLink),
71 descChars: fm.description.length + (fm.name || ent.name).length,
72 description: fm.description.slice(0, 220),
73 })
74 }
75 return out
76}
77
78async function runScan($: any): Promise<SkillScan> {
79 const home = await homeDir($)
80 const cwd = norm(await $.session.cwd())
81 const global = await scanDir($, `${home}/.claude/skills`, 'global')
82 const project = cwd === home ? [] : await scanDir($, `${cwd}/.claude/skills`, 'project')
83 const codexLink = await $.fs.exists(`${cwd}/.agents/skills`)
84 const scan: SkillScan = { at: Date.now(), cwd, items: [...project, ...global], codexLink }
85 await update($, skillScan, () => scan)
86 return scan
87}
88
89// True when the folder, or anything inside it, is a link: Buddy never moves or deletes through one.
90async function hasLink($: any, dir: string, depth = 0): Promise<boolean> {
91 if (depth > 6) return true
92 const st = await $.fs.stat(dir)
93 if (st.isLink) return true
94 for (const ent of await $.fs.list(dir)) {
95 if (ent.isLink || ent.kind === 'other') return true
96 if (ent.kind === 'dir' && (await hasLink($, `${dir}/${ent.name}`, depth + 1))) return true
97 }
98 return false
99}
100
101async function copyDir($: any, src: string, dst: string): Promise<string> {
102 const windows = (await $.env.get('OS')) === 'Windows_NT'
103 const parent = dst.slice(0, dst.lastIndexOf('/'))
104 const r = windows
105 ? await $.process.run([
106 'powershell',
107 '-NoProfile',
108 '-NonInteractive',
109 '-Command',
110 `New-Item -ItemType Directory -Force -Path ${quotePs(parent)} | Out-Null; Copy-Item -LiteralPath ${quotePs(src)} -Destination ${quotePs(dst)} -Recurse -Force`,
111 ])
112 : await $.process.run(['sh', '-c', 'mkdir -p "$1" && cp -R "$2" "$3"', 'sh', parent, src, dst])
113 return r.exitCode === 0 ? '' : (r.stderr || r.stdout || 'copy failed').slice(0, 200)
114}
115
116async function removeDir($: any, path: string): Promise<string> {
117 const windows = (await $.env.get('OS')) === 'Windows_NT'
118 const r = windows
119 ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `Remove-Item -LiteralPath ${quotePs(path)} -Recurse -Force`])
120 : await $.process.run(['rm', '-rf', path])
121 return r.exitCode === 0 ? '' : (r.stderr || r.stdout || 'delete failed').slice(0, 200)
122}
123
124// Copies a skill to a destination skills folder; with `move`, backs up and removes the original after asking.
125async function placeSkill($: any, item: SkillItem, destSkillsDir: string, move: boolean, label: string): Promise<string> {
126 const folder = item.path.slice(item.path.lastIndexOf('/') + 1)
127 const dst = `${norm(destSkillsDir)}/${folder}`
128 if (norm(dst) === norm(item.path)) return 'It is already there.'
129 if (await $.fs.exists(dst)) return `${label} already has a skill folder called ${folder}. Nothing changed.`
130 if (await hasLink($, item.path)) return `${item.name} is (or contains) a linked folder. Buddy only copies and moves real folders, so nothing changed.`
131 const err = await copyDir($, item.path, dst)
132 if (err) return `Copy failed: ${err}`
133 if (!move) return `Copied ${item.name} to ${label}.`
134 let answer = ''
135 try {
136 answer = await $.ui.ask(`Buddy copied ${item.name} to ${label}. Remove the original from ${item.scope === 'project' ? 'this project' : 'your global skills'}? A backup is kept in ~/.claude/buddy/skill-backups.`, ['Remove the original', 'Keep both'])
137 } catch {
138 answer = ''
139 }
140 if (answer !== 'Remove the original') return `Copied ${item.name} to ${label} and kept the original.`
141 const backup = `${await homeDir($)}/.claude/buddy/skill-backups/${folder}-${new Date().toISOString().replace(/[:.]/g, '-')}`
142 const berr = await copyDir($, item.path, backup)
143 if (berr) return `Copied, but the backup failed (${berr}), so the original was kept.`
144 const rerr = await removeDir($, item.path)
145 return rerr ? `Copied and backed up, but removing the original failed: ${rerr}` : `Moved ${item.name} to ${label}. Backup kept.`
146}
147
148async function deleteSkill($: any, item: SkillItem): Promise<string> {
149 if (await hasLink($, item.path)) return `${item.name} is (or contains) a linked folder. Buddy won't delete it.`
150 let answer = ''
151 try {
152 answer = await $.ui.ask(`Delete the skill ${item.name} from ${item.scope === 'project' ? 'this project' : 'your global skills'}? Buddy keeps a backup in ~/.claude/buddy/skill-backups.`, ['Delete it', 'Cancel'])
153 } catch {
154 answer = ''
155 }
156 if (answer !== 'Delete it') return 'Nothing deleted.'
157 const folder = item.path.slice(item.path.lastIndexOf('/') + 1)
158 const backup = `${await homeDir($)}/.claude/buddy/skill-backups/${folder}-${new Date().toISOString().replace(/[:.]/g, '-')}`
159 const berr = await copyDir($, item.path, backup)
160 if (berr) return `Backup failed (${berr}), so nothing was deleted.`
161 const rerr = await removeDir($, item.path)
162 return rerr ? `Backed up, but the delete failed: ${rerr}` : `Deleted ${item.name}. Backup kept.`
163}
164
165export function registerSkills(on: any) {
166 // Usage: every time Claude loads a skill, count it (kept across chats and projects).
167 on('tool.call', { tool: 'Skill' }, async ($: any, e: any, next: any) => {
168 try {
169 const name = String(e.skill ?? '').replace(/^\//, '')
170 if (name) {
171 const use = ((await $.store.get('skillUse')) ?? {}) as SkillUse
172 const since = (await $.store.get('skillUseSince')) as number | undefined
173 if (!since) await $.store.set('skillUseSince', Date.now())
174 await $.store.set('skillUse', { ...use, [name]: { count: (use[name]?.count ?? 0) + 1, last: Date.now() } })
175 }
176 } catch {
177 // counting only
178 }
179 return next(e)
180 })
181
182 // /buddy-skills scan | copy <scope>:<folder> <project path> | here <folder> | global <folder> | moveglobal <folder> | delete <scope>:<folder>
183 on('command.run', { command: 'buddy-skills' }, async ($: any, e: any) => {
184 const args = String(e.args ?? '').trim()
185 const [verb, ref, ...rest] = args.split(/\s+/)
186 const scan = verb === 'scan' || !verb ? await runScan($) : ((await read($, skillScan)) ?? (await runScan($)))
187 if (verb === 'scan' || !verb) {
188 await update($, tick, (n: number) => n + 1)
189 return { text: `Buddy found ${scan.items.length} skills.` }
190 }
191 const [scope, folder] = (ref ?? '').includes(':') ? (ref ?? '').split(':') : ['', ref ?? '']
192 const item = scan.items.find(i => i.path.endsWith(`/${folder}`) && (!scope || i.scope === scope))
193 if (!item) return { text: `No skill folder called ${folder}.` }
194 const home = await homeDir($)
195 let msg = ''
196 if (verb === 'copy') {
197 const target = norm(rest.join(' ').replace(/^["']|["']$/g, ''))
198 if (!target || !(await $.fs.exists(target))) msg = `That project folder doesn't exist: ${target || '(empty)'}`
199 else msg = await placeSkill($, item, `${target}/.claude/skills`, false, target.split('/').pop() ?? target)
200 } else if (verb === 'here') msg = await placeSkill($, item, `${scan.cwd}/.claude/skills`, false, 'this project')
201 else if (verb === 'global') msg = await placeSkill($, item, `${home}/.claude/skills`, false, 'your global skills')
202 else if (verb === 'moveglobal') msg = await placeSkill($, item, `${home}/.claude/skills`, true, 'your global skills')
203 else if (verb === 'delete') msg = await deleteSkill($, item)
204 else return { text: 'Usage: /buddy-skills scan | here <folder> | global <folder> | moveglobal <folder> | copy <scope>:<folder> <project path> | delete <scope>:<folder>' }
205 await runScan($)
206 await update($, tick, (n: number) => n + 1)
207 $.ui.toast(`Buddy: ${msg}`)
208 return { text: msg }
209 })
210}
211hooks/lib/mask.ts 78 lines1// Recording mode: hide secrets, emails, phone numbers, money and named clients on screen.
2// Display only: Claude still reads the real text. Pure functions, no `$`.
3
4const BAR = (n: number) => '█'.repeat(Math.max(4, Math.min(n, 12)))
5
6// Known key shapes first, then "NAME=value" for anything called a key, token, secret or password.
7const SECRETS: RegExp[] = [
8 /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g,
9 /\bAIza[0-9A-Za-z_-]{30,}/g,
10 /\bgh[pousr]_[A-Za-z0-9]{30,}/g,
11 /\bgithub_pat_[A-Za-z0-9_]{30,}/g,
12 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
13 /\bAKIA[0-9A-Z]{16}\b/g,
14 /\b(?:rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
15 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
16 /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g,
17]
18const NAMED_SECRET = /\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD)[A-Za-z0-9_]*["']?\s*[=:]\s*)(["']?)([^\s"',;]{6,})/gi
19const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
20const PHONE = /(?:\+\d{1,3}[\s.-]?)?\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g
21const MONEY = /(?:[$€£]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:k|K|m|M|bn|million|billion|thousand))?)|(?:\b\d[\d,]*(?:\.\d+)?\s?(?:USD|CAD|EUR|GBP|dollars)\b)/g
22
23const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
24
25export function maskText(text: string, names: string[] = []): string {
26 if (!text) return text
27 let out = text
28 for (const re of SECRETS) out = out.replace(re, m => BAR(m.length))
29 out = out.replace(NAMED_SECRET, (_m, name: string, quote: string, value: string) => `${name}${quote}${BAR(value.length)}`)
30 out = out.replace(EMAIL, '███@███')
31 out = out.replace(PHONE, '███-███-████')
32 out = out.replace(MONEY, m => (/^[$€£]/.test(m) ? `${m[0]}███` : '███'))
33 for (const name of names) {
34 if (name.length < 2) continue
35 out = out.replace(new RegExp(`\\b${escapeRe(name)}\\b`, 'gi'), m => BAR(m.length))
36 }
37 return out
38}
39
40// Masks every string inside a value and keeps its shape, so tool rows still draw.
41export function maskDeep<T>(value: T, names: string[] = [], depth = 0): T {
42 if (depth > 8) return value
43 if (typeof value === 'string') return maskText(value, names) as unknown as T
44 if (Array.isArray(value)) return value.map(v => maskDeep(v, names, depth + 1)) as unknown as T
45 if (value && typeof value === 'object') {
46 const out: Record<string, unknown> = {}
47 for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = maskDeep(v, names, depth + 1)
48 return out as T
49 }
50 return value
51}
52
53export const nameList = (raw: unknown) =>
54 String(raw ?? '')
55 .split(',')
56 .map(s => s.trim())
57 .filter(Boolean)
58
59// What a cold cache costs: the whole chat re-written to cache at 1.25x (5 min) or 2x (1 hour) the input price.
60// Per-million input prices, US dollars, from Anthropic's published pricing (2026-10).
61export function inputPrice(model: string): number {
62 const m = model.toLowerCase()
63 if (/fable|mythos/.test(m)) return 10
64 if (/opus-5-5|opus-5\.5/.test(m)) return 4
65 if (/opus/.test(m)) return 5
66 if (/sonnet/.test(m)) return 2
67 if (/haiku/.test(m)) return 1
68 return 4
69}
70
71export function coldCost(tokens: number, model: string, cacheMinutes: number) {
72 const price = inputPrice(model)
73 const writeRate = cacheMinutes >= 60 ? 2 : 1.25
74 const cold = (tokens / 1e6) * price * writeRate
75 const warm = (tokens / 1e6) * price * (/fable|mythos/i.test(model) ? 0.025 : /opus-5-5|opus-5\.5/i.test(model) ? 0.05 : 0.1)
76 return { cold, warm }
77}
78hooks/jobs/watch.tsx 174 lines1import { atom, read, update } from 'claude-code'
2
3import { coldCost } from '../lib/mask.ts'
4import type { AgentRow, Caught, Mood, Receipt } from '../../types'
5
6// Buddy's shared state (same keys in every file, so every job sees the same values).
7const agents = atom({ plugin: 'buddy', key: 'agents' } as const, [] as AgentRow[])
8const caught = atom({ plugin: 'buddy', key: 'caught' } as const, { send: 0, spend: 0, danger: 0 } as Caught)
9const memory = atom({ plugin: 'buddy', key: 'memory' } as const, 0)
10const mood = atom({ plugin: 'buddy', key: 'mood' } as const, 'sleepy' as Mood)
11const editing = atom({ plugin: 'buddy', key: 'editing' } as const, [] as string[])
12const receipt = atom({ plugin: 'buddy', key: 'receipt' } as const, null as Receipt | null)
13const lastTurnAt = atom({ plugin: 'buddy', key: 'lastTurnAt' } as const, 0)
14
15
16// The all-chats board: each chat's Buddy keeps one small status file in ~/.claude/buddy/chats,
17// and every Buddy reads them all. One file per chat, so chats never overwrite each other.
18type ChatStatus = 'working' | 'waiting' | 'done' | 'idle' | 'closed'
19type ChatRow = { id: string; title: string; folder: string; status: ChatStatus; since: number; updatedAt: number }
20
21async function chatDir($: any): Promise<string> {
22 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.') as string
23 return `${home.replace(/\\/g, '/')}/.claude/buddy/chats`
24}
25
26async function writeChat($: any, patch: Partial<ChatRow>) {
27 const id = await $.session.id()
28 const file = `${await chatDir($)}/${id}.json`
29 let cur: Partial<ChatRow> = {}
30 try {
31 cur = JSON.parse(await $.fs.read(file))
32 } catch {
33 cur = {}
34 }
35 const now = Date.now()
36 const next = { ...cur, ...patch, id, updatedAt: now } as ChatRow
37 if (patch.status && patch.status !== cur.status) next.since = now
38 if (!next.since) next.since = now
39 await $.fs.write(file, JSON.stringify(next))
40}
41
42type Options = { agentWatcher?: boolean; donePingSeconds?: number; bannedPhrases?: string; cacheMinutes?: number; cacheAskAbove?: number }
43
44const short = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + '…' : text)
45
46// Reads how full the chat is and keeps Buddy's face in step with it.
47export async function readMemory($: any) {
48 const usage = await $.session.usage()
49 const context = usage?.context
50 if (!context || !context.window) return
51 const tokens = context.tokens ?? 0
52 const percent = Math.round(context.percent ?? (tokens / context.window) * 100)
53 await update($, memory, () => percent)
54}
55
56export function registerWatch(on: any, options: Options) {
57 on('prompt.submit', async ($: any, e: any, next: any) => {
58 // Cache price check: after a break, the next message re-reads the whole chat at full price.
59 // Buddy says what that costs and offers to compact first. Never blocks if anything fails.
60 try {
61 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
62 const s = { ...options, ...(((await $.store.get('settings')) ?? {}) as Options) }
63 const last = await read($, lastTurnAt)
64 const ttl = Math.max(1, Number(s.cacheMinutes ?? 5)) * 60000
65 const text = String(e.text ?? '').trim()
66 if (live.cacheCheck !== false && e.origin?.kind === 'composer' && last > 0 && Date.now() - last > ttl && !text.startsWith('/')) {
67 const usage = await $.session.usage()
68 const tokens = Number(usage?.context?.tokens ?? 0)
69 const model = String(await $.session.model())
70 const { cold, warm } = coldCost(tokens, model, Number(s.cacheMinutes ?? 5))
71 const askAbove = Number(s.cacheAskAbove ?? 0.5)
72 if (tokens > 0 && cold >= askAbove) {
73 const away = Math.round((Date.now() - last) / 60000)
74 await update($, mood, () => 'holding')
75 const choice = String(
76 await $.ui.ask(
77 `Buddy: your cache went cold ${away} min ago. This message re-reads your whole chat (${Math.round(tokens / 1000)}K tokens): about $${cold.toFixed(2)} at API rates, vs $${warm.toFixed(2)} warm. On a subscription it comes out of your limit instead. Compact first to shrink it? (Stop asking turns this check off; switch it back on in the Health card or Jobs.)`,
78 ['Send anyway', 'Compact first, then send', 'Cancel', 'Stop asking'],
79 ),
80 )
81 await update($, mood, () => 'working')
82 const log = ((await $.store.get('log')) ?? []) as unknown[]
83 await $.store.set('log', [{ at: Date.now(), kind: 'cache', what: `cold cache, ~$${cold.toFixed(2)}`, choice: choice.startsWith('Compact') ? 'Compacted first' : choice === 'Cancel' ? 'Cancelled' : choice === 'Stop asking' ? 'Turned the check off' : 'Sent anyway' }, ...log].slice(0, 20))
84 if (choice === 'Stop asking') {
85 await $.store.set('jobs', { ...live, cacheCheck: false })
86 $.ui.toast('Buddy: cache price check OFF. Your message is going out. Switch it back on in the Health card.')
87 }
88 if (choice === 'Cancel') {
89 await $.prompt.fill({ text })
90 return { drop: 'Buddy kept your message in the box. Nothing was sent.' }
91 }
92 if (choice.startsWith('Compact')) {
93 $.ui.toast('Buddy: compacting first...')
94 await $.session.compact({})
95 }
96 }
97 }
98 } catch {
99 // the price check is advice; a failure never blocks your message
100 }
101 await update($, mood, () => 'working')
102 try {
103 const text = String(e.text ?? '').replace(/\s+/g, ' ').trim()
104 const file = `${await chatDir($)}/${await $.session.id()}.json`
105 let title = ''
106 try {
107 title = JSON.parse(await $.fs.read(file)).title ?? ''
108 } catch {
109 title = ''
110 }
111 await writeChat($, { status: 'working', ...(title || !text || text.startsWith('/') ? {} : { title: short(text, 48) }) })
112 } catch {
113 // the board is a nice-to-have; never block a prompt over it
114 }
115 return next(e)
116 })
117
118 on('turn.complete', async ($: any, e: any, next: any) => {
119 const done = await next(e)
120 if (e.agentId !== undefined) {
121 const now = await $.clock.now()
122 await update($, agents, (list: AgentRow[]) =>
123 list.map(row => (row.id === e.agentId && row.endedAt === undefined ? { ...row, endedAt: now } : row)),
124 )
125 return done
126 }
127 await readMemory($)
128 await update($, mood, () => 'happy')
129 try {
130 await writeChat($, { status: 'done' })
131 } catch {
132 // board only
133 }
134 await update($, lastTurnAt, () => Date.now())
135 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
136 // Work receipt: the files this task changed.
137 const files = await read($, editing)
138 const seconds = Math.round(Number(e.durationMs ?? 0) / 1000)
139 if (files.length > 0) {
140 const at = await $.clock.now()
141 await update($, receipt, () => ({ files, seconds, at }))
142 await update($, editing, () => [])
143 }
144 // Done ping for long tasks, so you can walk away.
145 const s = { ...options, ...(((await $.store.get('settings')) ?? {}) as Options) }
146 const pingAfter = Number(s.donePingSeconds ?? 60)
147 if (live.donePing !== false && !e.isAborted && pingAfter > 0 && seconds >= pingAfter) {
148 const took = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
149 $.ui.toast(`( ^.^ )/ Buddy: done in ${took}${files.length ? `, ${files.length} file${files.length === 1 ? '' : 's'} changed` : ''}`)
150 }
151 // House rules check: flag banned words in the reply.
152 const banned = (s.bannedPhrases ?? '').split(',').map(s => s.trim()).filter(Boolean)
153 const answer = String(e.answer ?? '')
154 const hits = banned.filter(b => answer.toLowerCase().includes(b.toLowerCase()))
155 if (live.houseRules !== false && hits.length > 0) $.ui.toast(`Buddy: that reply used ${hits.map(h => `"${h}"`).join(', ')}, which your house rules ban`)
156 // Keep the lifetime caught counter between sessions.
157 await $.store.set('caught', await read($, caught))
158 return done
159 })
160
161 if (options.agentWatcher !== false) {
162 on('agent.spawn', async ($: any, e: any, next: any) => {
163 const started = await next(e)
164 const live = ((await $.store.get('jobs')) ?? {}) as Record<string, boolean>
165 if (live.agentWatcher !== false && started?.agentId !== undefined) {
166 const now = await $.clock.now()
167 const row: AgentRow = { id: started.agentId, task: short(String(e.description ?? e.prompt ?? 'agent'), 44), startedAt: now }
168 await update($, agents, (list: AgentRow[]) => [...list, row].slice(-10))
169 }
170 return started
171 })
172 }
173}
174hooks/pet.tsx 157 lines1// Buddy's animated pet: a Client surface module with its own frame clock.
2// Props come from the hooks module (species, mood, stage, name); clicks post back.
3
4type Species = 'bunny' | 'cat' | 'dog' | 'bear' | 'frog' | 'owl' | 'ghost' | 'dragon'
5type Mood = 'sleepy' | 'working' | 'happy' | 'holding' | 'tired'
6type Props = { species?: Species; mood?: Mood; stage?: 'egg' | 'pet'; name?: string; label?: string }
7type State = { frame: number; hearts: number; cracking: number }
8
9// Two poses per species; {e} = eye, {m} = mouth. Three lines each, kept narrow.
10const ART: Record<Species, [string[], string[]]> = {
11 bunny: [
12 [' (\\_/) ', ' ({e}{m}{e}) ', ' (")(") '],
13 [' (\\ /) ', ' ({e}{m}{e}) ', 'o(")(") '],
14 ],
15 cat: [
16 [' /\\_/\\ ', '( {e}{m}{e} )', ' > ^ < '],
17 [' /\\_/\\ ', '( {e}{m}{e} )', ' (") (")~'],
18 ],
19 dog: [
20 [' __ ', '({e}{m}{e})_', ' U U ~'],
21 [' __ ', '({e}{m}{e})_', ' U U ~'],
22 ],
23 bear: [
24 [' ʕ•ᴥ•ʔ '.replace('•ᴥ•', '{e}{m}{e}'), ' / \\ ', ' U U '],
25 [' ʕ•ᴥ•ʔ '.replace('•ᴥ•', '{e}{m}{e}'), ' \\ / ', ' U U '],
26 ],
27 frog: [
28 [' @..@ '.replace('..', '{e}{e}'), '({m}{m}{m}{m})', ' ^ ^ '],
29 [' @..@ '.replace('..', '{e}{e}'), '({m}{m}{m}{m})', '^ ^'],
30 ],
31 owl: [
32 [' {o,o} '.replace('o,o', '{e},{e}'), ' /)_) ', ' " " '],
33 [' {o,o} '.replace('o,o', '{e},{e}'), ' (_(\\ ', ' " " '],
34 ],
35 ghost: [
36 [' .-. ', ' ({e} {e}) ', ' | {m} | ', ' \'~~~\' '].slice(0, 3),
37 [' .-. ', ' ({e} {e}) ', ' |{m} | '],
38 ],
39 dragon: [
40 [' /\\_/\\_ ', ' ({e} {e} >', ' ^^ ~~ '],
41 [' /\\_/\\_ ', ' ({e} {e} >~', ' ^^ ~~'],
42 ],
43}
44
45const FACE: Record<Mood, { e: string; m: string }> = {
46 sleepy: { e: '-', m: '.' },
47 working: { e: 'o', m: '_' },
48 happy: { e: '^', m: 'w' },
49 holding: { e: 'O', m: 'o' },
50 tired: { e: 'x', m: '~' },
51}
52
53const EGG = [
54 [' .--. ', ' / \\ ', ' \\____/ '],
55 [' .--. ', ' / . \\ ', ' \\____/ '],
56 [' .--. ', ' / /\\ \\ ', ' \\_\\/_/ '],
57 [' * . * ', ' \\/ \\/ ', ' \\____/ '],
58]
59
60const NOTES = ['♪', '♫', '♪ ♫', '♫ ♪']
61const fill = (line: string, f: { e: string; m: string }) => line.split('{e}').join(f.e).split('{m}').join(f.m)
62const pad = (n: number) => ' '.repeat(Math.max(0, n))
63
64export default function Pet(props: Props, surface: any) {
65 const { Box, Text } = surface.elements
66 const s: State = surface.state ?? { frame: 0, hearts: 0, cracking: 0 }
67
68 if (surface.state === undefined) {
69 surface.setState({ frame: 0, hearts: 0, cracking: 0 })
70 surface.every(220, () => {
71 const cur: State = surface.state ?? { frame: 0, hearts: 0, cracking: 0 }
72 surface.setState({
73 frame: cur.frame + 1,
74 hearts: Math.max(0, cur.hearts - 1),
75 cracking: cur.cracking > 0 ? cur.cracking + 1 : 0,
76 })
77 if (cur.cracking >= 12) surface.post({ hatched: true })
78 })
79 surface.onPointer((ev: any) => {
80 if (ev.type !== 'down') return
81 const cur: State = surface.state ?? { frame: 0, hearts: 0, cracking: 0 }
82 if ((props.stage ?? 'pet') === 'egg') {
83 if (cur.cracking === 0) surface.setState({ ...cur, cracking: 1 })
84 } else {
85 surface.setState({ ...cur, hearts: 8 })
86 surface.post({ petted: true })
87 }
88 })
89 }
90
91 const width = Math.max(14, surface.columns || 24)
92 const f = s.frame
93
94 if ((props.stage ?? 'pet') === 'egg') {
95 const crack = s.cracking > 0 ? Math.min(3, Math.floor(s.cracking / 3)) : 0
96 const wobble = s.cracking > 0 ? (f % 2 ? 1 : 0) : f % 6 === 0 ? 1 : 0
97 const art = EGG[crack]
98 return (
99 <Box flexDirection="column">
100 <Text color="yellow">{pad(4)}{s.cracking > 0 ? ' crack!' : ''}</Text>
101 {art.map(line => (
102 <Text color="yellow">{pad(4 + wobble)}{line}</Text>
103 ))}
104 <Text dimColor>{s.cracking > 0 ? 'hatching...' : 'Click the egg to hatch your Buddy'}</Text>
105 </Box>
106 )
107 }
108
109 const species: Species = props.species ?? 'bunny'
110 const mood: Mood = props.mood ?? 'sleepy'
111 const poses = ART[species] ?? ART.bunny
112 const face = FACE[mood]
113 const pose = poses[f % 2]
114 const artWidth = Math.max(...pose.map(l => l.length))
115 const room = Math.max(0, width - artWidth - 4)
116
117 // Movement per mood.
118 let x = 2
119 let lift = 0
120 let top = ' '
121 let color = 'yellow'
122 if (mood === 'working') {
123 const span = Math.max(1, room)
124 const step = f % (span * 2)
125 x = 1 + (step < span ? step : span * 2 - step) // walk back and forth
126 } else if (mood === 'happy') {
127 lift = f % 2 // bounce
128 x = 2 + (f % 4 < 2 ? 0 : 2) // sway
129 top = pad(x + 1) + NOTES[f % NOTES.length]
130 color = 'green'
131 } else if (mood === 'holding') {
132 x = 2 + (f % 2) // shake
133 top = pad(x + 3) + '!'
134 color = 'red'
135 } else if (mood === 'tired') {
136 top = pad(x + artWidth) + (f % 3 === 0 ? 'z' : f % 3 === 1 ? 'zZ' : 'zZz')
137 color = 'gray'
138 } else {
139 top = pad(x + artWidth) + (f % 4 < 2 ? 'z' : ' z')
140 }
141 if (s.hearts > 0) top = pad(x + 1) + (s.hearts % 2 ? '♥ ♥' : ' ♥ ♥ ')
142
143 const lines = pose.map(l => fill(l, face))
144 return (
145 <Box flexDirection="column">
146 <Text color={s.hearts > 0 ? 'magenta' : 'yellow'}>{lift ? ' ' : top}</Text>
147 {lift ? <Text color="yellow">{top}</Text> : null}
148 {lines.map(l => (
149 <Text color={color}>{pad(x)}{l}</Text>
150 ))}
151 {lift ? null : <Text> </Text>}
152 <Text bold>{props.name ?? 'Buddy'}</Text>
153 <Text dimColor>{props.label ?? ''}</Text>
154 </Box>
155 )
156}
157hooks/lib/patterns.ts 181 lines1// What counts as a send, a spend or a danger. Plain logic, no mods API, so it can be unit-tested.
2// Pattern-based by design: it catches the known tools and commands, not every possible script.
3
4export type Kind = 'danger' | 'spend' | 'send'
5
6export type Hit = {
7 kind: Kind
8 // A short stable key for "approve all today", e.g. "gmail:send_message" or "host:api.apify.com".
9 target: string
10 // One line for the dialog.
11 what: string
12 // True when the call has a recipient field Buddy can rewrite to "me first".
13 canSendToMe: boolean
14}
15
16const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor'])
17
18// Connector (MCP) actions, matched on the part after the last "__".
19const MCP_SEND = /^(send_message|send|reply|forward|post|publish|share_file|schedule_message|slack_send_message|slack_schedule_message|create_event|respond_to_event|upload)/i
20const MCP_DANGER = /^(trash_|delete_|remove_|purge|destroy)/i
21// Paid services, matched on the connector (server) name, plus the action that runs work.
22const PAID_SERVER = /(apify|wavespeed|kie|elevenlabs|heygen|fal|replicate|openai|runway|luma|suno)/i
23const PAID_ACTION = /(call-actor|call_actor|run|generate|create|text_to|speech|image|video|rag-web-browser|web-fetch)/i
24
25// Hosts of paid APIs Josh and his audience call from scripts.
26const PAID_HOSTS = [
27 'api.apify.com',
28 'api.wavespeed.ai',
29 'api.kie.ai',
30 'api.elevenlabs.io',
31 'api.heygen.com',
32 'fal.run',
33 'queue.fal.run',
34 'api.replicate.com',
35 'api.openai.com',
36 'api.runwayml.com',
37 'api.anthropic.com',
38 'generativelanguage.googleapis.com',
39 'api.stability.ai',
40 'api.mistral.ai',
41 'api.together.xyz',
42 'api.deepgram.com',
43 'api.assemblyai.com',
44 'api.lumalabs.ai',
45 'api.suno.ai',
46]
47
48const DANGER_SHELL: [RegExp, string][] = [
49 // Deleting one named file is routine; folders and wildcards are where work gets lost.
50 [/\brm\s+([^\n;&|]*\s)?(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\b/, 'delete a folder and everything in it (rm -r)'],
51 [/\brm\s+[^\n;&|]*[*?]/, 'delete every file matching a wildcard (rm *)'],
52 [/\bRemove-Item\b[^\n|;]*-Recurse\b/i, 'delete a folder and everything in it (Remove-Item -Recurse)'],
53 [/\bRemove-Item\b[^\n|;]*[*?]/i, 'delete every file matching a wildcard (Remove-Item *)'],
54 [/\b(rmdir|rd)\s+\/s\b/i, 'delete a folder (rmdir /s)'],
55 [/\bdel\s+([^\n;&|]*\s)?\/s\b/i, 'delete files in every subfolder (del /s)'],
56 [/\bdel\s+[^\n;&|]*\*/i, 'delete every file matching a wildcard (del *)'],
57 [/\bgit\s+reset\s+--hard\b/i, 'wipe uncommitted changes (git reset --hard)'],
58 [/\bgit\s+clean\s+-[a-z]*f/i, 'delete untracked files (git clean -f)'],
59 [/\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/i, 'discard all changes (git checkout/restore .)'],
60 [/\bgit\s+push\b[^\n;|]*(--force\b|--force-with-lease\b|\s-f\b|\s\+\S)/i, 'force push (rewrites remote history)'],
61 [/\bdrop\s+(table|database|schema)\b/i, 'drop a database table'],
62 [/\b(prisma\s+migrate\s+reset|db:reset|supabase\s+db\s+reset)\b/i, 'reset a database'],
63]
64
65const SEND_SHELL: [RegExp, string, string][] = [
66 [/\bgit\s+push\b/i, 'git:push', 'push code to the remote (git push)'],
67 [/\bgh\s+(pr\s+(create|merge)|release\s+create|issue\s+create)\b/i, 'gh:publish', 'publish on GitHub (gh)'],
68 [/\bvercel\b[^\n;|]*(--prod\b|\bdeploy\b)/i, 'vercel:deploy', 'deploy to Vercel'],
69 [/\b(fly|flyctl)\s+deploy\b/i, 'fly:deploy', 'deploy to Fly.io'],
70 [/\bnpm\s+publish\b/i, 'npm:publish', 'publish a package to npm'],
71]
72
73const WRITE_METHOD = /(-X\s*(POST|PUT|PATCH|DELETE)\b|--request\s+(POST|PUT|PATCH|DELETE)\b|\s(-d|--data\S*|-F|--form)\s|-Method\s+(Post|Put|Patch|Delete)\b|-Body\b)/i
74const HTTP_CLIENT = /\b(curl|wget|Invoke-RestMethod|Invoke-WebRequest|irm|iwr|httpie|http)\b/i
75const URL_HOST = /https?:\/\/([a-z0-9.-]+)/gi
76const LOCAL_HOST = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[a-z0-9-]+\.localhost|[a-z0-9.-]+\.test)$/i
77
78const short = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + '…' : text)
79
80export function hostsIn(command: string): string[] {
81 const out: string[] = []
82 for (const m of command.matchAll(URL_HOST)) out.push(m[1].toLowerCase())
83 return out
84}
85
86// Text fed to a program through a heredoc (<<EOF ... EOF) is data, not a command:
87// a script that merely mentions "rm -rf" or "git push" should not trip a guard.
88export function stripHeredocs(command: string): string {
89 return command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '<<HEREDOC')
90}
91
92// Extra paid hosts from Buddy's settings (e.g. "api.stripe.com, api.twilio.com").
93let extraPaidHosts: string[] = []
94export function setExtraPaidHosts(list: string | undefined) {
95 extraPaidHosts = (list ?? '')
96 .split(',')
97 .map(s => s.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''))
98 .filter(Boolean)
99}
100
101export function classifyShell(command: string): Hit | undefined {
102 const cmd = stripHeredocs(command).trim()
103 for (const [re, what] of DANGER_SHELL) {
104 if (re.test(cmd)) return { kind: 'danger', target: 'shell:' + what, what: `${what}: ${short(cmd, 120)}`, canSendToMe: false }
105 }
106 const hosts = hostsIn(cmd)
107 const paid = hosts.find(h => [...PAID_HOSTS, ...extraPaidHosts].some(p => h === p || h.endsWith('.' + p)))
108 if (paid && HTTP_CLIENT.test(cmd)) {
109 return { kind: 'spend', target: 'host:' + paid, what: `paid API call to ${paid}: ${short(cmd, 120)}`, canSendToMe: false }
110 }
111 if (/\bapify\s+(call|run|actors\s+call)\b/i.test(cmd)) {
112 return { kind: 'spend', target: 'cli:apify', what: `paid Apify run: ${short(cmd, 120)}`, canSendToMe: false }
113 }
114 for (const [re, target, what] of SEND_SHELL) {
115 if (re.test(cmd)) return { kind: 'send', target, what: `${what}: ${short(cmd, 120)}`, canSendToMe: false }
116 }
117 const external = hosts.find(h => !LOCAL_HOST.test(h))
118 if (external && HTTP_CLIENT.test(cmd) && WRITE_METHOD.test(cmd)) {
119 return { kind: 'send', target: 'host:' + external, what: `send data to ${external}: ${short(cmd, 120)}`, canSendToMe: false }
120 }
121 return undefined
122}
123
124export function classifyMcp(tool: string, args: Record<string, unknown>): Hit | undefined {
125 if (!tool.startsWith('mcp__')) return undefined
126 const parts = tool.split('__')
127 const server = parts[1] ?? ''
128 const action = parts[parts.length - 1] ?? ''
129 const label = `${server.replace(/^claude_ai_/, '')}:${action}`
130 // Drafts never leave your account, so creating, updating or deleting one is not held.
131 if (/draft/i.test(action)) return undefined
132 if (MCP_DANGER.test(action)) {
133 return { kind: 'danger', target: 'mcp:' + label, what: `${label}`, canSendToMe: false }
134 }
135 const readOnly = /^(get|list|search|abort|fetch-|read|describe|status)/i.test(action)
136 if (PAID_SERVER.test(server) && !readOnly && PAID_ACTION.test(action)) {
137 return { kind: 'spend', target: 'mcp:' + label, what: `${label}`, canSendToMe: false }
138 }
139 if (MCP_SEND.test(action) || /send|post|publish/i.test(action)) {
140 const hasRecipient = 'to' in args || 'recipient' in args
141 return { kind: 'send', target: 'mcp:' + label, what: `${label}`, canSendToMe: hasRecipient }
142 }
143 return undefined
144}
145
146export function classify(tool: string, input: Record<string, unknown>): Hit | undefined {
147 if (SHELL_TOOLS.has(tool)) {
148 const command = typeof input.command === 'string' ? input.command : ''
149 return command ? classifyShell(command) : undefined
150 }
151 return classifyMcp(tool, input)
152}
153
154// What the call is about, for the dialog: recipient, subject and body when the tool has them.
155export function describeArgs(args: Record<string, unknown>): string {
156 const pick = (...keys: string[]) => {
157 for (const k of keys) if (args[k] !== undefined && args[k] !== '') return args[k]
158 return undefined
159 }
160 const fmt = (v: unknown, max: number) => short(typeof v === 'string' ? v : JSON.stringify(v), max)
161 const to = pick('to', 'recipient', 'recipients', 'channel', 'channel_id', 'attendees')
162 const subject = pick('subject', 'title', 'summary', 'name')
163 const body = pick('body', 'text', 'message', 'content', 'description', 'input')
164 const parts: string[] = []
165 if (to !== undefined) parts.push(`To: ${fmt(to, 80)}`)
166 if (subject !== undefined) parts.push(`Subject: ${fmt(subject, 80)}`)
167 if (body !== undefined) parts.push(`"${fmt(body, 160)}"`)
168 return parts.join(' | ')
169}
170
171// Rewrite a send so it reaches only me: recipient replaced, cc and bcc removed.
172export function toMeOnly(args: Record<string, unknown>, myEmail: string): Record<string, unknown> | undefined {
173 if (!myEmail) return undefined
174 const out: Record<string, unknown> = { ...args }
175 if ('to' in args) out.to = Array.isArray(args.to) ? [myEmail] : myEmail
176 else if ('recipient' in args) out.recipient = myEmail
177 else return undefined
178 for (const k of ['cc', 'bcc', 'Cc', 'Bcc', 'ccRecipients', 'bccRecipients']) delete out[k]
179 return out
180}
181types/index.d.ts 35 lines1export type Mood = 'sleepy' | 'working' | 'happy' | 'holding' | 'tired'
2export type AgentRow = { id: string; task: string; startedAt: number; endedAt?: number }
3export type Caught = { send: number; spend: number; danger: number }
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5export type Receipt = { files: string[]; seconds: number; at: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 buddy: {
10 mood: Mood
11 memory: number
12 agents: AgentRow[]
13 tick: number
14 lastHeld: string
15 caught: Caught
16 limits: Limit[]
17 cost: number
18 editing: string[]
19 receipt: Receipt | null
20 codexLog: string[]
21 codexStatus: string
22 tab: 'status' | 'chats' | 'threads' | 'skills' | 'jobs' | 'pets' | 'settings' | 'help'
23 skillScan: { at: number; cwd: string; items: { name: string; scope: 'global' | 'project'; path: string; isLink: boolean; descChars: number; description: string }[]; codexLink: boolean } | null
24 skillView: { q: string; sort: 'used' | 'unused' | 'big'; shown: number; open: string }
25 agentLive: Record<string, { tool?: string; steps: number; lastAt: number }>
26 chatThreads: { id: string; sessionId?: string; task: string; model: string; by: string; status: 'starting' | 'running' | 'done' | 'failed'; startedAt: number; endedAt?: number; lastTool?: string; answer?: string; turns: number }[]
27 threadModel: string
28 threadKind: 'helper' | 'chat'
29 peek: { id: string; text: string } | null
30 recOn: boolean
31 lastTurnAt: number
32 }
33 }
34}
35