SLOPSHOPPER

agent-cats

A dashboard of the session's agents as coloured cats: their tasks, skills and run time, their questions answered in place with a meow, and the skills each task…

newpanerowsguardcommandtoast
v0.2.0MITupdated 2026-10-05anthonyzng/agent-cats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-cats
│ ┃ Agent Cats ✕ › fix the failing auth test and add an audit log call │ ┃ 🐾 Agent Cats Working 0 Asleep 1 Waiting │ ┃ ● agent-cats: agent-cats: skills: 0 (context listing 0, no breakdown; │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ Skills for this task [ Mode: prefer the c ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ None chosen (every skill works as usual) ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ No installed skills found. Try "Refresh ⎿ 3 pass, 1 fail │ ┃ │ list". │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Find a new skill: say what you need, Enter │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /cats │ ┃ │ ᓚᘏᗢᶻ Main agent ✎ Rename │ ⎿ agent-cats: Agent Cats dashboard opened. skills: 0 (context list │ ┃ │ Asleep (can be woken) ⏱ --:-- main │ │ ┃ │ Skills: none yet │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Cats
🐾 Agent Cats Working 0 Asleep 1 Waiting for you 0 ╭─────────────────────────────────────────────────────────── │ Skills for this task [ Mode: prefer the chosen ones ] Re │ │ None chosen (every skill works as usual) │ │ No installed skills found. Try "Refresh list". │ │ Find a new skill: say what you need, Enter asks the main a ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ ᓚᘏᗢᶻ Main agent ✎ Rename │ │ Asleep (can be woken) ⏱ --:-- main │ │ Skills: none yet │ ╰──────────────────────────────────────────────────────────╯
README

Agent Cats

English | 繁體中文

A Claude Code mod that turns the agents of a session into a dashboard of cats.

  • One cat per agent. The main agent and every subagent each get their own colour: an SVG cat on the desktop app and VS Code, ᓚᘏᗢ in the terminal.
  • What each cat is doing. Every cat shows its task, its status, how long it has run, its agent type, the tool it is using now and the skills it has loaded.
  • Asleep or done. When a subagent finishes, its cat changes status:
  • Asleep if SendMessage can still reach it. Give it a new task in its field and the main agent wakes it.
  • Done if it failed, was stopped or cannot be addressed.
  • Close it when you no longer need to wake it.
  • Questions answered in place. When an agent asks you something (AskUserQuestion), the question appears at the top of the dashboard with a meow, played once per question.
  • Pick an option, or type your own answer.
  • The engine's own dialog is replaced by a pointer to the dashboard. Use the native dialog is a fallback.
  • Permission prompts appear too, with the same meow. Allow / Deny stays in the engine's own dialog: only the engine may grant a permission.
  • Rename cats. Give any cat a name of your own with ✎ Rename.
  • Skills:
  • +Skill on a cat hands it one of your installed skills. A running agent reads the request at its next step. A sleeping subagent is woken with it. An idle main agent gets it as a prompt.
  • Find a new skill asks the main agent to search the skill and plugin catalogs and show install cards. Nothing is installed until you approve a card.
  • Skills for this task picks the skills for the work at hand, in one of two modes:
  • Prefer: every prompt and new subagent is told to use them.
  • Only: every other skill is refused.
  • English or Traditional Chinese. The dashboard's language is a setting (see below).

Requirements

Claude Code 2.1.286 or later: the first release with mods (function hooks).

Install

In Claude Code:

/plugin marketplace add anthonyzng/agent-cats
/plugin install agent-cats@agent-cats

Then open the dashboard with /cats. The pane also opens by itself when a session starts on a wide enough window, and whenever an agent asks you something.

To update later:

/plugin marketplace update agent-cats

From a local copy

git clone https://github.com/anthonyzng/agent-cats.git
claude --plugin-dir ./agent-cats

Hosts that cannot take a flag, such as the desktop app, read the same folders from CLAUDE_CODE_PLUGIN_DIRS. Set it in the env block of ~/.claude/settings.json to an absolute path.

Language

The dashboard is in English by default. To switch it to Traditional Chinese (Cantonese), open /config, find the Language row of agent-cats and pick zh-Hant. Claude Code saves the choice under pluginConfigs in ~/.claude/settings.json, and the mod reloads with the new language at once. Text the agents read, such as the skill instructions, stays in English.

Things to know

  • Mods are not sandboxed. This one runs inside Claude Code with your permissions, like every mod. Read hooks/register.tsx before you install it. It makes no network calls and writes no files. The only external thing it touches is the bundled sound, sounds/meow.wav.
  • "Only" mode applies to every agent of the session, the main one included.
  • The dashboard holds still while you use it. While you are in one of its dropdowns or text fields, its once-a-second updates wait, so an open dropdown does not jump back to its top.
  • This works in the terminal.
  • The desktop app does not say which element took focus, so there an open dropdown can still be redrawn while an agent is working.
  • When every agent is idle, the dashboard does not redraw at all.
  • /cats prints a diagnostic line: how many skills it found, how often the pane drew and the last focus moves.
  • Playing the meow. It uses Claude Code's own audio player. Where none is available you still get the toast.

Development

claude plugin validate .   # what the module hooks and calls, and anything the engine would refuse
claude plugin test .       # tests/*.test.tsx against the engine itself
python scripts/make-meow.py  # regenerate sounds/meow.wav (stdlib only)

Claude Code writes the type declarations into .claude-plugin/types/ when it loads the mod. tsconfig.json extends them, so tsc -p . type-checks the module. The interface words live in hooks/strings.ts, one table per language.

License

MIT

Source 3 files
hooks/register.tsx 886 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CatAgent, CatPending, CatQuestion, CatStatus, SkillMode } from '../types'
5
6import { STRINGS, languageOf } from './strings'
7import type { Strings } from './strings'
8
9const PANE = 'agent-cats'
10// The words of the language the `language` option names; set by `register`
11// (a change of the option reloads the module).
12let t: Strings = STRINGS.en
13const MAIN = 'main'
14const MAIN_COLOR = '#F4A261'
15const PALETTE = ['#2B2D42', '#F5F5F5', '#E76F51', '#8E7DBE', '#2A9D8F', '#E9C46A', '#6C757D', '#F28482', '#4D96FF']
16
17const agents = atom({ plugin: 'agent-cats', key: 'agents' } as const, [])
18const pending = atom({ plugin: 'agent-cats', key: 'pending' } as const, [])
19const clockNow = atom({ plugin: 'agent-cats', key: 'now' } as const, 0)
20const native = atom({ plugin: 'agent-cats', key: 'native' } as const, [])
21const skills = atom({ plugin: 'agent-cats', key: 'skills' } as const, [])
22const activeSkills = atom({ plugin: 'agent-cats', key: 'activeSkills' } as const, [])
23const skillMode = atom({ plugin: 'agent-cats', key: 'skillMode' } as const, 'prefer')
24
25const skillFilter = atom({ plugin: 'agent-cats', key: 'skillFilter' } as const, '')
26
27const renaming = atom({ plugin: 'agent-cats', key: 'renaming' } as const, '')
28
29// While the person is in one of the dashboard's pickers or text fields, the
30// once-a-second clock redraw would redraw it under them (an open dropdown
31// jumps back to its top), so the clock holds until focus leaves, at most this long.
32const HOLD_MS = 60_000
33const HOLD_ELEMENT = /^(give-|active-add|skill-filter|skill-search|wake-|other-|rename-)/
34let holdUntil = 0
35// Diagnostics for /cats: how often the pane drew, and the last focus moves.
36let renders = 0
37const writes: Record<string, number> = {}
38const wrote = (key: string) => {
39  writes[key] = (writes[key] ?? 0) + 1
40}
41const focusSeen: string[] = []
42
43async function isHolding($: EngineInterface): Promise<boolean> {
44  return holdUntil > 0 && (await $.clock.now()) < holdUntil
45}
46
47const PICK_HINT = '__pick__'
48// A Select takes at most 64 options: the hint plus this many skills.
49const MAX_SKILL_OPTIONS = 63
50
51// The hint first, then the skills that match the filter, capped to what a
52// Select can hold; the hint says how many the filter is hiding.
53function skillOptions(names: readonly string[], filter: string, hint: string): { value: string; label?: string }[] {
54  const needle = filter.trim().toLowerCase()
55  const matches = needle ? names.filter(n => n.toLowerCase().includes(needle)) : [...names]
56  const shown = matches.slice(0, MAX_SKILL_OPTIONS)
57  const hidden = matches.length - shown.length
58  const label =
59    matches.length === 0
60      ? t.noMatch(filter.trim())
61      : hidden > 0
62        ? t.moreHidden(hint, hidden)
63        : hint
64
65  return [{ value: PICK_HINT, label }, ...shown.map(n => ({ value: n }))]
66}
67
68type Answers = Record<string, string>
69
70// Lives with this module: after a reload the old waits are gone, so their
71// questions fall back to the engine's own dialog.
72const resolvers = new Map<string, (answers: Answers) => void>()
73
74const STATUS_COLOR: Record<CatStatus, string> = {
75  working: '#2A9D8F',
76  sleeping: '#8E7DBE',
77  closed: '#888888',
78}
79
80function catStatusOf(info: { status: string; name?: string }): CatStatus {
81  if (info.status === 'running' || info.status === 'pending') {
82    return 'working'
83  }
84  // A finished agent that SendMessage can still address sleeps until woken;
85  // one that failed, was killed or cannot be addressed is done for good.
86  if ((info.status === 'completed' || info.status === 'idle') && info.name) {
87    return 'sleeping'
88  }
89
90  return 'closed'
91}
92
93function elapsed(from: number, to: number): string {
94  const total = Math.max(0, Math.floor((to - from) / 1000))
95  const h = Math.floor(total / 3600)
96  const m = Math.floor((total % 3600) / 60)
97  const s = total % 60
98  const two = (n: number) => String(n).padStart(2, '0')
99
100  return h > 0 ? `${h}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`
101}
102
103function catSvg(color: string, isAsleep: boolean): string {
104  const eyes = isAsleep
105    ? '<path d="M9 17 q2 2 4 0 M19 17 q2 2 4 0" stroke="#333" stroke-width="1.2" fill="none"/>'
106    : '<circle cx="11" cy="17" r="1.8" fill="#333"/><circle cx="21" cy="17" r="1.8" fill="#333"/>'
107
108  return (
109    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="28" height="28">' +
110    `<path d="M5 14 L7 3 L14 9 Z M27 14 L25 3 L18 9 Z" fill="${color}" stroke="#333" stroke-width="1"/>` +
111    `<ellipse cx="16" cy="19" rx="12" ry="10" fill="${color}" stroke="#333" stroke-width="1"/>` +
112    eyes +
113    '<path d="M15 21 L17 21 L16 22.4 Z" fill="#E88"/>' +
114    '<path d="M3 20 L10 21 M3 23 L10 22 M29 20 L22 21 M29 23 L22 22" stroke="#555" stroke-width="0.6"/>' +
115    '</svg>'
116  )
117}
118
119function toQuestions(raw: readonly unknown[]): CatQuestion[] {
120  return raw.map(item => {
121    const q = item as {
122      question?: string
123      header?: string
124      kind?: string
125      multiSelect?: boolean
126      options?: { label?: string }[]
127    }
128    const kind = q.kind === 'text' || q.kind === 'number' ? q.kind : 'choice'
129
130    return {
131      question: q.question ?? '',
132      header: q.header ?? '',
133      kind,
134      isMulti: q.multiSelect === true,
135      options: (q.options ?? []).map(o => o.label ?? '').filter(Boolean),
136    }
137  })
138}
139
140function answerOf(p: CatPending, index: number): string {
141  const picked = p.picks[index] ?? []
142  const other = (p.others[index] ?? '').trim()
143
144  return [...picked, ...(other ? [other] : [])].join(', ')
145}
146
147function describeInput(input: unknown): string {
148  if (input && typeof input === 'object') {
149    const o = input as Record<string, unknown>
150    for (const key of ['command', 'file_path', 'url', 'path', 'pattern', 'description', 'prompt']) {
151      if (typeof o[key] === 'string') {
152        return String(o[key]).slice(0, 300)
153      }
154    }
155  }
156
157  return ''
158}
159
160async function meow($: EngineInterface, text: string): Promise<void> {
161  $.ui.toast(`🐱 ${text}`)
162  try {
163    await $.audio.play({ asset: 'sounds/meow.wav' })
164  } catch (error) {
165    $.ui.log(`agent-cats: could not play the meow (${String(error)})`, { to: 'debug' })
166  }
167}
168
169// Every write redraws the pane (an open dropdown jumps back to its top), so
170// the agents are written only when the change actually changed something.
171async function changeAgents($: EngineInterface, change: (rows: CatAgent[]) => CatAgent[]): Promise<void> {
172  const ref = { plugin: 'agent-cats', key: 'agents' } as const
173  for (let attempt = 0; attempt < 5; attempt += 1) {
174    const { value = [], version } = await $.state.get(ref)
175    const next = change(value)
176    if (JSON.stringify(next) === JSON.stringify(value)) {
177      return
178    }
179    wrote('agents')
180    const { isSet } = await $.state.set(ref, next, { ifVersion: version })
181    if (isSet) {
182      return
183    }
184  }
185}
186
187async function setMain($: EngineInterface, change: (row: CatAgent) => CatAgent): Promise<void> {
188  await changeAgents($, rows => {
189    const list = rows ?? []
190    const main =
191      list.find(r => r.id === MAIN) ??
192      ({
193        id: MAIN,
194        // Empty until the first turn; drawn as the main agent's name meanwhile.
195        label: '',
196        type: 'main',
197        status: 'sleeping',
198        rawStatus: 'idle',
199        color: MAIN_COLOR,
200        startedAt: 0,
201        skills: [],
202      } satisfies CatAgent)
203
204    return [change(main), ...list.filter(r => r.id !== MAIN)]
205  })
206}
207
208async function patchAgent($: EngineInterface, id: string, change: (row: CatAgent) => CatAgent): Promise<void> {
209  if (id === MAIN) {
210    return setMain($, change)
211  }
212  await changeAgents($, rows => rows.map(r => (r.id === id ? change(r) : r)))
213}
214
215// The installed skills: the context listing (counted locally, no API call)
216// where the host computes it, plus the plugin and user slash commands, which
217// is where skills show up when it does not. Answers a line for diagnostics.
218async function loadSkills($: EngineInterface): Promise<string> {
219  let fromContext: string[] = []
220  let contextNote = 'ok'
221  try {
222    const usage = await $.session.usage({ breakdown: 'summary' })
223    const breakdown = usage.context.breakdown
224    fromContext = (breakdown?.skills?.skillFrontmatter ?? []).map(s => s.name)
225    if (!breakdown) {
226      contextNote = 'no breakdown'
227    } else if (!breakdown.skills) {
228      contextNote = 'no skills in breakdown'
229    }
230  } catch (error) {
231    contextNote = `error: ${String(error)}`
232  }
233  let fromCommands: string[] = []
234  let commandNote = 'ok'
235  try {
236    fromCommands = (await $.command.list())
237      .filter(c => (c.source === 'plugin' || c.source === 'user') && c.name !== 'cats')
238      .map(c => c.name)
239  } catch (error) {
240    commandNote = `error: ${String(error)}`
241  }
242  const names = [...new Set([...fromContext, ...fromCommands])].sort((a, b) => a.localeCompare(b))
243  if (JSON.stringify(names) !== JSON.stringify(await read($, skills))) {
244    await $.state.set({ plugin: 'agent-cats', key: 'skills' }, names)
245  }
246
247  return `skills: ${names.length} (context listing ${fromContext.length}, ${contextNote}; commands ${fromCommands.length}, ${commandNote})`
248}
249
250function skillNote(active: readonly string[], mode: SkillMode): string {
251  const list = active.join(', ')
252
253  return mode === 'only'
254    ? `The user chose the skills for this task in the Agent Cats dashboard: ${list}. Use only these skills (load each with the Skill tool when it applies); any other skill is refused.`
255    : `The user chose the skills for this task in the Agent Cats dashboard: ${list}. Prefer these skills and load each with the Skill tool when it applies.`
256}
257
258// Hands one installed skill to one cat: a running loop reads it at its next
259// step, a sleeping subagent is woken with it, an idle main agent gets a prompt.
260async function giveSkill($: EngineInterface, row: CatAgent, skill: string): Promise<void> {
261  const ask = `The user asks you, from the Agent Cats dashboard, to load the skill "${skill}" with the Skill tool now and use it for your current task.`
262  if (row.status === 'working') {
263    await $.session.append({
264      message: { type: 'user', content: [{ type: 'text', text: ask }] },
265      ...(row.id === MAIN ? {} : { agentId: row.id }),
266    })
267  } else if (row.id === MAIN) {
268    await $.prompt.submit({ text: ask })
269  } else if (row.name) {
270    await $.prompt.submit({
271      text: `Use SendMessage to wake the agent "${row.name}" (${row.label}) and tell it to load the skill "${skill}" with the Skill tool and use it to continue its task.`,
272    })
273  } else {
274    $.ui.toast(`🐱 ${t.skillNotGiven(row.nickname || row.label)}`)
275
276    return
277  }
278  $.ui.toast(`🐱 ${t.skillGiven(row.nickname || row.label || t.mainAgent, skill)}`)
279}
280
281async function sync($: EngineInterface): Promise<void> {
282  // The person is in a picker: nothing redraws under them; the next tick
283  // after the hold catches up (end times are then a second or so late).
284  if (await isHolding($)) {
285    return
286  }
287  const list = await $.agent.list()
288  const now = await $.clock.now()
289
290  await changeAgents($, current => {
291    let changed = false
292    const next = current.map(row => {
293      const info = list.find(a => a.id === row.id)
294      if (!info) {
295        return row
296      }
297      const status = catStatusOf(info)
298      if (status === row.status && info.status === row.rawStatus && info.name === row.name) {
299        return row
300      }
301      changed = true
302      const isWoken = status === 'working' && row.status !== 'working'
303
304      return {
305        ...row,
306        status,
307        rawStatus: info.status,
308        name: info.name,
309        label: info.description || row.label,
310        startedAt: isWoken ? now : row.startedAt,
311        endedAt: status === 'working' ? undefined : (row.endedAt ?? now),
312        tool: status === 'working' ? row.tool : undefined,
313        isHidden: isWoken ? false : row.isHidden,
314      }
315    })
316    const known = new Set(current.map(r => r.id))
317    const added = list
318      .filter(a => !known.has(a.id))
319      .map((a, i): CatAgent => {
320        const status = catStatusOf(a)
321
322        return {
323          id: a.id,
324          label: a.description || a.type,
325          type: a.type,
326          name: a.name,
327          status,
328          rawStatus: a.status,
329          color: PALETTE[(current.length - 1 + i + PALETTE.length) % PALETTE.length] ?? MAIN_COLOR,
330          startedAt: now,
331          endedAt: status === 'working' ? undefined : now,
332          skills: [],
333        }
334      })
335
336    return changed || added.length > 0 ? [...next, ...added] : current
337  })
338
339  const rows = await read($, agents)
340  const waits = await read($, pending)
341  if (rows.some(r => r.status === 'working') || waits.length > 0) {
342    wrote('now')
343    await $.state.set({ plugin: 'agent-cats', key: 'now' }, now)
344  }
345}
346
347export const register: Register = (on, options) => {
348  t = STRINGS[languageOf(options['language'])]
349
350  on('session.start', async ($, e, next) => {
351    await setMain($, row => row)
352    $.ui.log(`agent-cats: ${await loadSkills($)}`, { to: 'debug' })
353    await $.command.register({ name: 'cats', description: t.commandDescription })
354    void $.ui.open({ id: PANE, title: t.title })
355    $.clock.every(1000, () => {
356      void sync($)
357    })
358
359    return next(e)
360  })
361
362  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
363    // The person's move names the element; a plugin's `$.ui.focus` call, its `key`.
364    const element = e.element ?? (e as { key?: string }).key
365    holdUntil = element && HOLD_ELEMENT.test(element) ? (await $.clock.now()) + HOLD_MS : 0
366    focusSeen.push(element ?? '(none)')
367    focusSeen.splice(0, Math.max(0, focusSeen.length - 3))
368
369    return next(e)
370  })
371
372  on('command.run', { command: 'cats' }, async $ => {
373    const found = await loadSkills($)
374    await $.ui.open({ id: PANE, title: t.title, focus: true })
375    const focus = focusSeen.length > 0 ? focusSeen.join(', ') : 'none'
376
377    return { text: `${t.opened} ${found}; draws ${renders}; writes ${JSON.stringify(writes)}; last focus ${focus}` }
378  })
379
380  // The skills chosen in the dashboard ride along with every prompt and spawn.
381  on('prompt.submit', async ($, e, next) => {
382    const active = await read($, activeSkills)
383    if (active.length === 0) {
384      return next(e)
385    }
386    const note = skillNote(active, await read($, skillMode))
387
388    return next({ ...e, context: [...(e.context ?? []), note] })
389  })
390
391  on('agent.spawn', async ($, e, next) => {
392    const active = await read($, activeSkills)
393    if (active.length === 0) {
394      return next(e)
395    }
396
397    return next({ ...e, prompt: `${skillNote(active, await read($, skillMode))}\n\n${e.prompt}` })
398  })
399
400  on('turn.start', async ($, e, next) => {
401    const now = await $.clock.now()
402    const text = e.text.trim().replace(/\s+/g, ' ')
403    await setMain($, row => ({
404      ...row,
405      status: 'working',
406      rawStatus: 'running',
407      label: text ? text.slice(0, 60) : row.label,
408      startedAt: now,
409      endedAt: undefined,
410      skills: [],
411    }))
412
413    return next(e)
414  })
415
416  on('turn.complete', async ($, e, next) => {
417    if (e.agentId === undefined) {
418      const now = await $.clock.now()
419      await setMain($, row => ({ ...row, status: 'sleeping', rawStatus: 'idle', endedAt: now, tool: undefined }))
420    }
421
422    return next(e)
423  })
424
425  // Every tool call: what each cat is doing now, the skills it used, and the
426  // permission questions it raised that the call has now settled.
427  on('tool.call', async ($, e, next) => {
428    const who = e.agentId ?? MAIN
429    // Read the arguments as untyped: how the engine types a built-in tool's
430    // input depends on the build that wrote the declarations.
431    const args = e as { skill?: unknown }
432    const skill = e.tool === 'Skill' && typeof args.skill === 'string' ? args.skill : undefined
433    if (skill !== undefined && (await read($, skillMode)) === 'only') {
434      const active = await read($, activeSkills)
435      if (active.length > 0 && !active.includes(skill)) {
436        return {
437          deny: `The user allowed only these skills in the Agent Cats dashboard: ${active.join(', ')}. "${skill}" is switched off.`,
438        }
439      }
440    }
441    // The "using X" line waits while the person is in a picker; a skill is kept.
442    const showsTool = !(await isHolding($))
443    await patchAgent($, who, row => ({
444      ...row,
445      tool: showsTool ? e.tool : row.tool,
446      skills: skill && !row.skills.includes(skill) ? [...row.skills, skill] : row.skills,
447    }))
448    try {
449      return await next(e)
450    } finally {
451      if (!(await isHolding($))) {
452        await patchAgent($, who, row => (row.tool === e.tool ? { ...row, tool: undefined } : row))
453      }
454      await update($, pending, list =>
455        (list ?? []).filter(p => !(p.kind === 'permission' && p.agentId === who && p.tool === e.tool)),
456      )
457    }
458  })
459
460  // A question to the person: answered in the dashboard. The engine's dialog
461  // waits beneath (its site drawn as a pointer here) and wins if used instead.
462  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
463    const id = e.tool_use_id
464    const raw = (e as { questions?: unknown }).questions
465    const questions = toQuestions(Array.isArray(raw) ? raw : [])
466    const answered = new Promise<Answers>(resolve => {
467      resolvers.set(id, resolve)
468    })
469    const item: CatPending = {
470      id,
471      kind: 'question',
472      agentId: e.agentId ?? MAIN,
473      askedAt: await $.clock.now(),
474      questions,
475      picks: questions.map(() => []),
476      others: questions.map(() => ''),
477    }
478    await update($, pending, list => [...(list ?? []), item])
479    void $.ui.open({ id: PANE, title: t.title, focus: true })
480    void meow($, t.meowQuestion)
481
482    try {
483      const won = await Promise.race([
484        next(e).then(result => ({ result })),
485        answered.then(answers => ({ answers })),
486      ])
487      if ('result' in won) {
488        return won.result
489      }
490
491      return { result: { questions: raw, answers: won.answers } as never }
492    } finally {
493      resolvers.delete(id)
494      await update($, pending, list => (list ?? []).filter(p => p.id !== id))
495      await update($, native, list => (list ?? []).filter(n => n !== id))
496    }
497  })
498
499  // A permission prompt: shown with its meow; the engine's own dialog answers it.
500  on('classic.PermissionRequest', async ($, e, next) => {
501    const item: CatPending = {
502      id: `perm-${await $.clock.now()}-${e.tool_name}`,
503      kind: 'permission',
504      agentId: e.agent_id ?? MAIN,
505      askedAt: await $.clock.now(),
506      questions: [],
507      picks: [],
508      others: [],
509      tool: e.tool_name,
510      detail: describeInput(e.tool_input),
511    }
512    await update($, pending, list => [...(list ?? []), item])
513    void $.ui.open({ id: PANE, title: t.title })
514    void meow($, t.meowPermission(e.tool_name))
515
516    return next(e)
517  })
518
519  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
520    const useNative = (await read($, native)).includes(e.requestId)
521    if (useNative || !resolvers.has(e.requestId)) {
522      return next(e)
523    }
524    const { Box, Text, Button } = $.ui.resolve(e)
525
526    return (
527      <Box flexDirection="row" gap={1}>
528        <Text color={MAIN_COLOR}>ᓚᘏᗢ</Text>
529        <Text>{t.pointer(t.title)}</Text>
530        <Button
531          key={`native-${e.requestId}`}
532          label={t.answerHere}
533          plain
534          onPress={() => update($, native, list => [...(list ?? []), e.requestId])}
535        />
536      </Box>
537    )
538  })
539
540  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
541    renders += 1
542    const table = $.ui.resolve(e)
543    const { Box, Text, Button } = table
544    const Svg = 'Svg' in table ? table.Svg : undefined
545    const Input = 'Input' in table ? table.Input : undefined
546    const Select = 'Select' in table ? table.Select : undefined
547    const installed = await read($, skills)
548    const active = await read($, activeSkills)
549    const mode = await read($, skillMode)
550    const filter = await read($, skillFilter)
551    const editing = await read($, renaming)
552    const rows = await read($, agents)
553    const waits = await read($, pending)
554    const now = Math.max(await read($, clockNow), ...rows.map(r => r.endedAt ?? r.startedAt))
555    const nativeIds = await read($, native)
556    const byId = (id: string) => rows.find(r => r.id === id)
557
558    const cat = (color: string, isAsleep: boolean, key: string) =>
559      Svg ? (
560        <Svg key={key} source={catSvg(color, isAsleep)} alt={isAsleep ? 'sleeping cat' : 'cat'} width={28} height={28} />
561      ) : (
562        <Text color={color}>{isAsleep ? 'ᓚᘏᗢᶻ' : 'ᓚᘏᗢ'}</Text>
563      )
564
565    const setPending = (id: string, change: (p: CatPending) => CatPending) =>
566      update($, pending, list => (list ?? []).map(p => (p.id === id ? change(p) : p)))
567
568    const pick = (p: CatPending, index: number, label: string) =>
569      setPending(p.id, cur => {
570        const q = cur.questions[index]
571        const had = cur.picks[index] ?? []
572        const chosen = q?.isMulti
573          ? had.includes(label)
574            ? had.filter(l => l !== label)
575            : [...had, label]
576          : [label]
577        const others = q?.isMulti ? cur.others : cur.others.map((o, i) => (i === index ? '' : o))
578
579        return { ...cur, picks: cur.picks.map((l, i) => (i === index ? chosen : l)), others }
580      })
581
582    const typeOther = (p: CatPending, index: number, text: string) =>
583      setPending(p.id, cur => {
584        const isMulti = cur.questions[index]?.isMulti
585        const picks = isMulti ? cur.picks : cur.picks.map((l, i) => (i === index ? [] : l))
586
587        return { ...cur, picks, others: cur.others.map((o, i) => (i === index ? text : o)) }
588      })
589
590    const send = (p: CatPending) => {
591      const answers: Answers = {}
592      p.questions.forEach((q, i) => {
593        answers[q.question] = answerOf(p, i)
594      })
595      resolvers.get(p.id)?.(answers)
596    }
597
598    const working = rows.filter(r => r.status === 'working').length
599    const sleeping = rows.filter(r => r.status === 'sleeping').length
600    const visible = rows.filter(r => !r.isHidden)
601    const hiddenClosed = rows.some(r => r.status === 'closed' && !r.isHidden)
602
603    return (
604      <Box flexDirection="column" gap={1}>
605        <Box flexDirection="row" gap={2}>
606          <Text bold>🐾 {t.title}</Text>
607          <Text color={STATUS_COLOR.working}>
608            {t.counts.working} {working}
609          </Text>
610          <Text color={STATUS_COLOR.sleeping}>
611            {t.counts.sleeping} {sleeping}
612          </Text>
613          <Text color={waits.length > 0 ? '#E76F51' : '#888888'}>
614            {t.counts.waiting} {waits.length}
615          </Text>
616        </Box>
617
618        <Box flexDirection="column" gap={1} borderStyle="round" borderColor="#4D96FF" paddingX={1}>
619          <Box flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
620            <Text bold color="#4D96FF">{t.skillsHeading}</Text>
621            <Button
622              key="skill-mode"
623              label={mode === 'only' ? t.modeOnly : t.modePrefer}
624              variant={mode === 'only' ? 'primary' : 'secondary'}
625              onPress={() => update($, skillMode, m => (m === 'only' ? 'prefer' : 'only'))}
626            />
627            <Button
628              key="skills-reload"
629              label={t.reloadList}
630              plain
631              onPress={async () => {
632                $.ui.toast(`🐱 ${await loadSkills($)}`)
633              }}
634            />
635          </Box>
636          {active.length === 0 ? (
637            <Text dimColor>{t.noneChosen}</Text>
638          ) : (
639            <Box flexDirection="row" flexWrap="wrap" gap={1}>
640              {active.map(name => (
641                <Button
642                  key={`active-${name}`}
643                  label={`${name} ✕`}
644                  onPress={() => update($, activeSkills, list => (list ?? []).filter(n => n !== name))}
645                />
646              ))}
647              <Button key="active-clear" label={t.clearAll} plain onPress={() => update($, activeSkills, () => [])} />
648            </Box>
649          )}
650          {Input && installed.length > MAX_SKILL_OPTIONS && (
651            <Input
652              key="skill-filter"
653              placeholder={t.filterPlaceholder(installed.length)}
654              value={filter}
655              submitLabel={t.filterSubmit}
656              onSubmit={value => update($, skillFilter, () => value)}
657            />
658          )}
659          {Select && installed.length > 0 && (
660            <Select
661              key="active-add"
662              label={t.addSkill}
663              value={PICK_HINT}
664              options={skillOptions(
665                installed.filter(n => !active.includes(n)),
666                filter,
667                t.pickInstalled,
668              )}
669              onSelect={value => {
670                if (value !== PICK_HINT) {
671                  void update($, activeSkills, list => [...new Set([...(list ?? []), value])])
672                }
673              }}
674            />
675          )}
676          {installed.length === 0 && <Text dimColor>{t.noSkillsFound}</Text>}
677          {Input && (
678            <Input
679              key="skill-search"
680              placeholder={t.searchPlaceholder}
681              submitLabel={t.searchSubmit}
682              onSubmit={value => {
683                const need = value.trim()
684                if (need) {
685                  void $.prompt.submit({
686                    text:
687                      `From the Agent Cats dashboard, the user wants a new skill for: "${need}". ` +
688                      'Search the skill and plugin catalogs (SearchSkills, SearchPlugins), then show the fitting results ' +
689                      'as install cards (SuggestSkills, SuggestPluginInstall) so the user approves the install. Do not install anything yourself.',
690                  })
691                  $.ui.toast(`🐱 ${t.searchToast}`)
692                }
693              }}
694            />
695          )}
696        </Box>
697
698        {waits.length > 0 && (
699          <Box flexDirection="column" gap={1} borderStyle="round" borderColor="#E76F51" paddingX={1}>
700            <Text bold color="#E76F51">{t.needsYou}</Text>
701            {waits.map(p => {
702              const who = byId(p.agentId)
703              const color = who?.color ?? MAIN_COLOR
704              const name = who?.nickname || who?.label || (p.agentId === MAIN ? t.mainAgent : p.agentId)
705              const head = (
706                <Box flexDirection="row" gap={1} alignItems="center">
707                  {cat(color, false, `ask-cat-${p.id}`)}
708                  <Text bold>{name}</Text>
709                  <Text dimColor>{t.waited(elapsed(p.askedAt, now))}</Text>
710                </Box>
711              )
712
713              if (p.kind === 'permission') {
714                return (
715                  <Box key={`wait-${p.id}`} flexDirection="column">
716                    {head}
717                    <Text>{t.wantsTool(p.tool ?? '', p.detail ?? '')}</Text>
718                    <Text dimColor>{t.permissionNote}</Text>
719                  </Box>
720                )
721              }
722
723              const isOrphan = !resolvers.has(p.id)
724              const isNative = nativeIds.includes(p.id)
725              const isReady = p.questions.every((_, i) => answerOf(p, i) !== '')
726
727              return (
728                <Box key={`wait-${p.id}`} flexDirection="column" gap={1}>
729                  {head}
730                  {p.questions.map((q, i) => (
731                    <Box key={`q-${p.id}-${i}`} flexDirection="column">
732                      <Text>
733                        {q.header ? `[${q.header}] ` : ''}
734                        {q.question}
735                      </Text>
736                      {q.kind === 'choice' && (
737                        <Box flexDirection="row" flexWrap="wrap" gap={1}>
738                          {q.options.map((label, j) => (
739                            <Button
740                              key={`opt-${p.id}-${i}-${j}`}
741                              label={`${(p.picks[i] ?? []).includes(label) ? '✓ ' : ''}${label}`}
742                              variant={(p.picks[i] ?? []).includes(label) ? 'primary' : 'secondary'}
743                              onPress={() => pick(p, i, label)}
744                            />
745                          ))}
746                        </Box>
747                      )}
748                      {Input && (
749                        <Input
750                          key={`other-${p.id}-${i}`}
751                          placeholder={q.kind === 'choice' ? t.otherPlaceholder : t.answerPlaceholder}
752                          value={p.others[i] ?? ''}
753                          submitLabel={t.confirm}
754                          onSubmit={value => typeOther(p, i, value)}
755                        />
756                      )}
757                    </Box>
758                  ))}
759                  {isOrphan || isNative ? (
760                    <Text dimColor>{t.answerNatively}</Text>
761                  ) : (
762                    <Box flexDirection="row" gap={1}>
763                      <Button
764                        key={`send-${p.id}`}
765                        label={isReady ? t.send : t.sendIncomplete}
766                        variant="primary"
767                        dimColor={!isReady}
768                        onPress={() => {
769                          if (isReady) {
770                            send(p)
771                          }
772                        }}
773                      />
774                      <Button
775                        key={`use-native-${p.id}`}
776                        label={t.useNative}
777                        onPress={() => update($, native, list => [...(list ?? []), p.id])}
778                      />
779                    </Box>
780                  )}
781                </Box>
782              )
783            })}
784          </Box>
785        )}
786
787        {visible.length === 0 && <Text dimColor>{t.noAgents}</Text>}
788        {visible.map(r => (
789          <Box key={`agent-${r.id}`} flexDirection="column" borderStyle="round" borderColor={r.color} paddingX={1}>
790            <Box flexDirection="row" gap={1} alignItems="center">
791              {cat(r.color, r.status !== 'working', `cat-${r.id}`)}
792              <Text bold wrap="truncate-end">
793                {r.nickname || r.label || t.mainAgent}
794              </Text>
795              {editing !== r.id && (
796                <Button key={`rename-btn-${r.id}`} label={t.rename} plain onPress={() => update($, renaming, () => r.id)} />
797              )}
798            </Box>
799            {r.nickname && r.label && (
800              <Text dimColor wrap="truncate-end">
801                {t.taskLabel(r.label)}
802              </Text>
803            )}
804            {Input && editing === r.id && (
805              <Box flexDirection="row" gap={1}>
806                <Input
807                  key={`rename-${r.id}`}
808                  placeholder={t.renamePlaceholder}
809                  value={r.nickname ?? ''}
810                  submitLabel={t.renameSubmit}
811                  autoFocus
812                  onSubmit={value => {
813                    const nickname = value.trim().slice(0, 40)
814                    void patchAgent($, r.id, row => ({ ...row, nickname: nickname || undefined }))
815                    void update($, renaming, () => '')
816                  }}
817                />
818                <Button key={`rename-cancel-${r.id}`} label={t.cancel} plain onPress={() => update($, renaming, () => '')} />
819              </Box>
820            )}
821            <Box flexDirection="row" gap={2} flexWrap="wrap">
822              <Text color={STATUS_COLOR[r.status]}>{t.status[r.status]}</Text>
823              <Text dimColor>⏱ {r.startedAt ? elapsed(r.startedAt, r.endedAt ?? now) : '--:--'}</Text>
824              <Text dimColor>{r.type}</Text>
825              {r.tool && <Text color="#4D96FF">{t.usingTool(r.tool)}</Text>}
826            </Box>
827            <Text dimColor={r.skills.length === 0}>
828              {t.skillsUsed(r.skills.length > 0 ? r.skills.join(t.skillsSeparator) : t.skillsNone)}
829            </Text>
830            {Select && r.status !== 'closed' && installed.length > 0 && (
831              <Select
832                key={`give-${r.id}`}
833                label={t.giveSkill}
834                value={PICK_HINT}
835                options={skillOptions(installed, filter, t.giveSkillHint)}
836                onSelect={value => {
837                  if (value !== PICK_HINT) {
838                    void giveSkill($, r, value)
839                  }
840                }}
841              />
842            )}
843            {r.status === 'sleeping' && r.id !== MAIN && (
844              <Box flexDirection="column">
845                {Input && r.name && (
846                  <Input
847                    key={`wake-${r.id}`}
848                    placeholder={t.wakePlaceholder}
849                    submitLabel={t.wakeSubmit}
850                    onSubmit={value => {
851                      const task = value.trim()
852                      if (task && r.name) {
853                        void $.prompt.submit({
854                          text: `Use SendMessage to wake the agent "${r.name}" (${r.label}) and give it this follow-up task: ${task}`,
855                        })
856                      }
857                    }}
858                  />
859                )}
860                <Button
861                  key={`close-${r.id}`}
862                  label={t.closeCat}
863                  plain
864                  onPress={() =>
865                    patchAgent($, r.id, row => ({ ...row, status: 'closed', isHidden: true }))
866                  }
867                />
868              </Box>
869            )}
870          </Box>
871        ))}
872        {hiddenClosed && (
873          <Button
874            key="clear-closed"
875            label={t.hideClosed}
876            plain
877            onPress={() =>
878              update($, agents, list => (list ?? []).map(r => (r.status === 'closed' ? { ...r, isHidden: true } : r)))
879            }
880          />
881        )}
882      </Box>
883    )
884  })
885}
886
hooks/strings.ts 184 lines
1// The dashboard's words, one table per language the `language` option offers.
2// Text the model reads (prompts, denials) stays English in both: it is for the model.
3
4export type Language = 'en' | 'zh-Hant'
5
6export type Strings = {
7  title: string
8  commandDescription: string
9  mainAgent: string
10  status: { working: string; sleeping: string; closed: string }
11  counts: { working: string; sleeping: string; waiting: string }
12  meowQuestion: string
13  meowPermission: (tool: string) => string
14  pointer: (title: string) => string
15  answerHere: string
16  skillsHeading: string
17  modeOnly: string
18  modePrefer: string
19  reloadList: string
20  noneChosen: string
21  clearAll: string
22  filterPlaceholder: (count: number) => string
23  filterSubmit: string
24  addSkill: string
25  pickInstalled: string
26  noMatch: (filter: string) => string
27  moreHidden: (hint: string, hidden: number) => string
28  noSkillsFound: string
29  searchPlaceholder: string
30  searchSubmit: string
31  searchToast: string
32  needsYou: string
33  waited: (time: string) => string
34  wantsTool: (tool: string, detail: string) => string
35  permissionNote: string
36  otherPlaceholder: string
37  answerPlaceholder: string
38  confirm: string
39  answerNatively: string
40  send: string
41  sendIncomplete: string
42  useNative: string
43  noAgents: string
44  rename: string
45  renamePlaceholder: string
46  renameSubmit: string
47  cancel: string
48  taskLabel: (task: string) => string
49  usingTool: (tool: string) => string
50  skillsUsed: (list: string) => string
51  skillsNone: string
52  skillsSeparator: string
53  giveSkill: string
54  giveSkillHint: string
55  wakePlaceholder: string
56  wakeSubmit: string
57  closeCat: string
58  hideClosed: string
59  skillGiven: (name: string, skill: string) => string
60  skillNotGiven: (name: string) => string
61  opened: string
62}
63
64export const STRINGS: Record<Language, Strings> = {
65  en: {
66    title: 'Agent Cats',
67    commandDescription: 'Open the Agent Cats dashboard',
68    mainAgent: 'Main agent',
69    status: { working: 'Working', sleeping: 'Asleep (can be woken)', closed: 'Done' },
70    counts: { working: 'Working', sleeping: 'Asleep', waiting: 'Waiting for you' },
71    meowQuestion: 'An agent has a question for you: answer it in the Agent Cats dashboard',
72    meowPermission: tool => `An agent wants to use ${tool} and is waiting for your approval`,
73    pointer: title => `Answer this question in the ${title} dashboard.`,
74    answerHere: 'Answer here',
75    skillsHeading: 'Skills for this task',
76    modeOnly: 'Mode: only the chosen ones',
77    modePrefer: 'Mode: prefer the chosen ones',
78    reloadList: 'Refresh list',
79    noneChosen: 'None chosen (every skill works as usual)',
80    clearAll: 'Clear all',
81    filterPlaceholder: count => `Filter skills (${count} in all): type a keyword, then Enter`,
82    filterSubmit: 'Filter',
83    addSkill: 'Add a skill: ',
84    pickInstalled: '— Pick an installed skill —',
85    noMatch: filter => `— No skill matches "${filter}" —`,
86    moreHidden: (hint, hidden) => `${hint} (${hidden} more: type above to filter)`,
87    noSkillsFound: 'No installed skills found. Try "Refresh list".',
88    searchPlaceholder: 'Find a new skill: say what you need, Enter asks the main agent to search',
89    searchSubmit: 'Search',
90    searchToast: 'Asked the main agent to look for a skill',
91    needsYou: 'Needs your reply',
92    waited: time => `waiting ${time}`,
93    wantsTool: (tool, detail) => `wants to use ${tool}${detail ? `: ${detail}` : ''}`,
94    permissionNote: 'Choose Allow / Deny in the permission dialog (only the native dialog can grant a permission).',
95    otherPlaceholder: 'Other (type your own, Enter to confirm)',
96    answerPlaceholder: 'Your answer (Enter to confirm)',
97    confirm: 'Confirm',
98    answerNatively: 'Answer in the native dialog.',
99    send: 'Send answers',
100    sendIncomplete: 'Send answers (not all answered)',
101    useNative: 'Use the native dialog',
102    noAgents: 'No agents yet.',
103    rename: '✎ Rename',
104    renamePlaceholder: 'New name (empty: back to the task name)',
105    renameSubmit: 'Rename',
106    cancel: 'Cancel',
107    taskLabel: task => `Task: ${task}`,
108    usingTool: tool => `using ${tool}`,
109    skillsUsed: list => `Skills: ${list}`,
110    skillsNone: 'none yet',
111    skillsSeparator: ', ',
112    giveSkill: '+Skill: ',
113    giveSkillHint: '— Hand it a skill —',
114    wakePlaceholder: 'Give it a new task, Enter wakes it',
115    wakeSubmit: 'Wake',
116    closeCat: 'No need to wake it again: close',
117    hideClosed: 'Hide the finished cats',
118    skillGiven: (name, skill) => `Asked "${name}" to use ${skill}`,
119    skillNotGiven: name => `${name} has finished: cannot hand it a skill`,
120    opened: 'Agent Cats dashboard opened.',
121  },
122  'zh-Hant': {
123    title: 'Agent 貓貓',
124    commandDescription: '打開 Agent 貓貓 dashboard',
125    mainAgent: '主 agent',
126    status: { working: '工作中', sleeping: '休眠(可喚醒)', closed: '已結束' },
127    counts: { working: '工作中', sleeping: '休眠', waiting: '等你回覆' },
128    meowQuestion: '有 agent 問你問題,喺 Agent 貓貓 dashboard 答',
129    meowPermission: tool => `有 agent 想用 ${tool},等你批准`,
130    pointer: title => `呢條問題請喺「${title}」dashboard 回答。`,
131    answerHere: '喺呢度答',
132    skillsHeading: '今次任務用嘅 skills',
133    modeOnly: '模式:只准用揀咗嘅',
134    modePrefer: '模式:優先用揀咗嘅',
135    reloadList: '重新整理清單',
136    noneChosen: '未揀(全部 skills 照常可用)',
137    clearAll: '全部清除',
138    filterPlaceholder: count => `篩選 skill(共 ${count} 個),打關鍵字再 Enter`,
139    filterSubmit: '篩選',
140    addSkill: '加入 skill:',
141    pickInstalled: '— 揀一個已安裝嘅 skill —',
142    noMatch: filter => `— 冇 skill 符合「${filter}」—`,
143    moreHidden: (hint, hidden) => `${hint}(仲有 ${hidden} 個,喺上面打字篩選)`,
144    noSkillsFound: '搵唔到已安裝嘅 skills,試吓撳「重新整理清單」。',
145    searchPlaceholder: '搵新 skill:講你想做乜,Enter 叫主 agent 去搵',
146    searchSubmit: '搵',
147    searchToast: '已經叫主 agent 去搵 skill',
148    needsYou: '需要你回覆',
149    waited: time => `等咗 ${time}`,
150    wantsTool: (tool, detail) => `想用 ${tool}${detail ? `:${detail}` : ''}`,
151    permissionNote: '請喺權限對話框揀 Allow / Deny(權限只可以由原生對話框批准)。',
152    otherPlaceholder: '其他(自己打,Enter 確認)',
153    answerPlaceholder: '你嘅答案(Enter 確認)',
154    confirm: '確認',
155    answerNatively: '請喺原生對話框回答。',
156    send: '送出答案',
157    sendIncomplete: '送出答案(未答晒)',
158    useNative: '改用原生對話框',
159    noAgents: '未有 agent。',
160    rename: '✎ 改名',
161    renamePlaceholder: '新名(留空就用返原本嘅任務名)',
162    renameSubmit: '改名',
163    cancel: '取消',
164    taskLabel: task => `任務:${task}`,
165    usingTool: tool => `用緊 ${tool}`,
166    skillsUsed: list => `Skills:${list}`,
167    skillsNone: '未用',
168    skillsSeparator: '、',
169    giveSkill: '+Skill:',
170    giveSkillHint: '— 交一個 skill 俾佢 —',
171    wakePlaceholder: '交代新工作,Enter 喚醒佢',
172    wakeSubmit: '喚醒',
173    closeCat: '唔使再喚醒,關閉佢',
174    hideClosed: '收埋已結束嘅貓',
175    skillGiven: (name, skill) => `已經叫「${name}」用 ${skill}`,
176    skillNotGiven: name => `${name} 已經結束,交唔到 skill`,
177    opened: 'Agent 貓貓 dashboard 已打開。',
178  },
179}
180
181export function languageOf(value: unknown): Language {
182  return value === 'zh-Hant' ? 'zh-Hant' : 'en'
183}
184
types/index.d.ts 58 lines
1export type CatStatus = 'working' | 'sleeping' | 'closed'
2
3/** `prefer`: the chosen skills are suggested; `only`: any other skill is refused. */
4export type SkillMode = 'prefer' | 'only'
5
6export type CatAgent = {
7  id: string
8  label: string
9  /** The name the person gave the cat in the dashboard; shown before `label`. */
10  nickname?: string
11  type: string
12  name?: string
13  status: CatStatus
14  rawStatus: string
15  color: string
16  startedAt: number
17  endedAt?: number
18  skills: string[]
19  tool?: string
20  isHidden?: boolean
21}
22
23export type CatQuestion = {
24  question: string
25  header: string
26  kind: 'choice' | 'text' | 'number'
27  isMulti: boolean
28  options: string[]
29}
30
31export type CatPending = {
32  id: string
33  kind: 'question' | 'permission'
34  agentId: string
35  askedAt: number
36  questions: CatQuestion[]
37  picks: string[][]
38  others: string[]
39  tool?: string
40  detail?: string
41}
42
43declare module 'claude-code' {
44  interface PluginState {
45    'agent-cats': {
46      agents: CatAgent[]
47      pending: CatPending[]
48      now: number
49      native: string[]
50      skills: string[]
51      activeSkills: string[]
52      skillMode: SkillMode
53      skillFilter: string
54      renaming: string
55    }
56  }
57}
58