SLOPSHOPPER

buddy

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…

newpanebandrowsguardcommand
v0.9.0MITupdated 2026-10-07joshpocock/claude-buddy/buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy
│ ┃ Buddy ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▣ client module ./pet.tsx │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Read 6 lines │ ┃ [ Recording mode ] Filming or sharing your ⏺ Update(src/auth.ts) │ ┃ first. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Status ] [ Chats ] [ Threads ] [ Skills ⎿ 3 pass, 1 fail │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Welcome! Here is how I work │ │ ┃ │ 1. Keep working as normal. I watch all │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ your chats, your limits and your cache. │ │ ┃ │ │ › /buddy │ ┃ │ 2. When Claude needs you (a key, a │ ⎿ buddy: Buddy is back. Try /buddy pet cat, /buddy name Rex, or /b │ ┃ │ login, an approval), it lands on your │ │ ┃ │ to-do list here. │ │ ┃ │ │ │ ┃ │ 3. Filming or sharing your screen? Press │ │ ┃ │ Recording mode and I cover your secrets. │ │ ┃ │ │ │ ┃ │ 4. Before Claude sends an email or │ │ ┃ │ spends on a paid API, I ask you first. │ │ ┃ │ │ │ ┃ │ Jobs turns things on and off · Settings │ │ ┃ │ sets your email and locked files · Pets │ ( ^.^ )/ Buddy the egg · memory 49% · saved you 0x · /buddy ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
( ^.^ )/ Buddy the egg · memory 49% · saved you 0x · /buddy
Pane · Buddy
╭──────────────────────────────────────────────────────────╮ │ ▣ client module ./pet.tsx │ ╰──────────────────────────────────────────────────────────╯ [ Recording mode ] Filming or sharing your screen? Press th [ Status ] [ Chats ] [ Threads ] [ Skills ] [ Jobs ] [ ╭──────────────────────────────────────────────────────────╮ │ Welcome! Here is how I work │ │ 1. Keep working as normal. I watch all your chats, your │ │ limits and your cache. │ │ │ │ 2. When Claude needs you (a key, a login, an approval), │ │ it lands on your to-do list here. │ │ │ │ 3. Filming or sharing your screen? Press Recording mode │ │ and I cover your secrets. │ │ │ │ 4. Before Claude sends an email or spends on a paid API, │ │ I ask you first. │ │ │ │ Jobs turns things on and off · Settings sets your email │ │ and locked files · Pets lets you pick your pet. │ │ │ │ [ Got it ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Your chats · 0 open │ │ Open Claude Code chats show up here. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Health │ │ ████████░░░░░░░░ 49% chat memory │ │ █████░░░░░░░░░░░ 31% 5-hour limit │ │ Cache warm: 4:59 left [ Price check ON ] │ │ This chat at API rates: $0.42 (not your bill on a │ │ subscription) │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Saved you from │ 0 │ sends held p │ Each number counts a time Claude tried to send, spend or │ delete and Buddy asked you first. │ Last one: nothing yet ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Activity │ │ Nothing yet. Every time I step in, it shows up here with │ │ what you chose. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Agents │ │ None yet. Helper agents Claude starts show up here. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Last task │ │ 3 files changed in 42s: auth.ts, audit.ts, cache.ts │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Do something │ [ Compact now ] Shrinks this chat into a summary so Cl │ and uses less of your limit │ │ [ Save handoff note ] Writes a summary file so a brand ne │ exactly where this one left off │ │ [ Reset counter ] Sets the "saved you from" numbers back ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Switch model │ │ [ Fable ] [ Opus ] [ Sonnet ] │ │ │ │ [ Effort low ] [ Effort high ] │ │ Each model button picks its newest version. Lower effort │ │ is faster and lighter on your limit. │ ╰──────────────────────────────────────────────────────────╯
README

Buddy: a pet sidekick for Claude Code

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:

What Buddy does

Does things for you

  • To-do inbox: when Claude needs you (add an API key, log in, approve something), it lands on Buddy's to-do list instead of getting buried in a long reply. Press Done and that chat carries on by itself, even if it's a different chat.
  • Recording mode: filming or sharing your screen? One button covers API keys, emails, phone numbers, dollar amounts and any names you list (clients, your company) everywhere on screen, in every chat. A red REC chip shows it's on. Claude still sees the real text.
  • Cache price check: after a break, Claude's cache goes cold and your next message re-reads the whole chat at full price. Buddy warns you a minute before it happens, and when it has, tells you what the message will cost and offers to compact first.

Runs a team (Threads tab)

  • Helpers: start a helper agent from the panel (Sonnet, Opus or Fable), or let Claude split a job across helpers. Each one shows who started it, how long it's run, its steps and what it's doing right now, with Peek, Message and Stop.
  • Separate chats: start a whole separate Claude chat in the background with its own model and memory (/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.
  • Message any chat: every open chat on the Chats board gets a message box, and Claude can message your other chats too, so one chat can run the others.

Manages your skills (Skills tab)

  • Every skill you have, global and in this project, with how often Claude actually uses each one (Buddy counts every time a skill loads, across all chats).
  • What they cost: an estimate of the tokens your skill names and descriptions add to every chat, plus a "Never used" sort to find dead weight.
  • One click to make a skill global, add a global one to this project, copy it to another project, move it, or delete it. Moves and deletes ask first and keep a backup. Buddy never touches linked folders.

Sees everything at a glance

  • All your chats: every Claude Code chat you have open, live: working, waiting on you, or your turn. A ping when one finishes or needs you.
  • Health: how full this chat is, your 5-hour and weekly limits, the cache timer, cost at API rates.
  • Agents: every helper agent Claude starts, running or done, with times.

One-click power moves

  • Switch to Fable, Opus or Sonnet, or change effort, from buttons.
  • Compact now, or save a handoff note so a fresh chat picks up where you left off.
  • Ask OpenAI Codex for a read-only second opinion (off by default).

A safety net, only where it matters

  • Send check (on): before Claude sends an email or Slack message, pushes or deploys, Buddy shows who it's going to and what it says: Send, Send to me first, Approve all today or Cancel.
  • Spend check (on): paid API calls (OpenAI, Anthropic, Gemini, ElevenLabs, Replicate, fal, Apify and any you add) wait for your OK.
  • Locked files: Claude can't edit .env (or anything you add). Delete check is there too, off by default.
  • Every job switches on or off in the Jobs tab.

The fun part: pick a bunny, cat, dog, bear, frog, owl, ghost or dragon. Hatch, rename, pet.

Install

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).

Use

  • /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)

Limits

  • Recording mode covers the chat transcript and Buddy's panel. It can't cover the permission pop-up, the prompt box while you type, other mods' panels, or the desktop app's own sidebar titles. Check your screen before you hit record.
  • Separate chats run without anyone to answer pop-ups, so Buddy's send and spend checks cancel anything they would have asked about, and tools that need approval follow the permission mode in Settings (acceptEdits by default).
  • The cache price is an estimate at API rates. On a subscription you don't pay it in dollars; it comes out of your usage limit.
  • Buddy recognizes connectors, git, deploys, known paid APIs and web requests by name. A custom script that sends or spends inside its own code can go around him. Mods are not sandboxed and run with your permissions, so only install mods you've checked.

Update and uninstall

claude plugin marketplace update claude-buddy
claude plugin update buddy@claude-buddy
claude plugin uninstall buddy@claude-buddy

It's a work in progress

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.

Source 11 files
hooks/register.tsx 1291 lines
1import { 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 lines
1import { 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}
169
hooks/jobs/guards.tsx 129 lines
1import { 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}
129
hooks/jobs/power.tsx 84 lines
1import { 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}
84
hooks/jobs/threads.tsx 187 lines
1import { 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}
187
hooks/jobs/skills.tsx 211 lines
1import { 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}
211
hooks/lib/mask.ts 78 lines
1// 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}
78
hooks/jobs/watch.tsx 174 lines
1import { 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}
174
hooks/pet.tsx 157 lines
1// 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}
157
hooks/lib/patterns.ts 181 lines
1// 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}
181
types/index.d.ts 35 lines
1export 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