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…

English | 繁體中文
A Claude Code mod that turns the agents of a session into a dashboard of cats.
ᓚᘏᗢ in the terminal.SendMessage can still reach it. Give it a new task in its field and the main agent wakes it.AskUserQuestion), the question appears at the top of the dashboard with a meow, played once per question.Claude Code 2.1.286 or later: the first release with mods (function hooks).
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
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.
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.
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./cats prints a diagnostic line: how many skills it found, how often the pane drew and the last focus moves.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.
hooks/register.tsx 886 lines1import { 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}
886hooks/strings.ts 184 lines1// 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}
184types/index.d.ts 58 lines1export 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