Optional visuals for the dojo crew: themed spinners, turn-end flair, a live crew band, deploy/return toasts, and a model guard that keeps dojo agents on their…

Anime-themed Claude Code subagents: one orchestrator and a crew, each with its own model, tools and voice.
| Agent | Character | Role | Model |
|---|---|---|---|
dojo:strategist | Shikamaru or Lelouch (per session) | Orchestrator / integrator (main thread) | claude-opus-5-5 |
dojo:senku | Senku | Backend builder | claude-sonnet-5-5 |
dojo:sanji | Sanji | Frontend builder | claude-sonnet-5-5 |
dojo:l | L | Deep web researcher | claude-haiku-4-5 |
dojo:light | Light | Quick fact-finder | claude-haiku-4-5 |
dojo:levi | Levi | QA inspector (read-only) | claude-haiku-4-5 |
dojo:robin | Robin | Documentation writer | claude-haiku-4-5 |
/plugin marketplace add shreym95/dojo
/plugin install dojo@dojo
claude --agent dojo:strategist
crew # bin/crew: random persona
crew lelouch # pin the persona (shikamaru | lelouch)
Or make it the default in settings.json: "agent": "dojo:strategist".
The strategist must run as the main-thread agent because subagents cannot spawn subagents. Run as a subagent it could not dispatch the crew.
A SessionStart hook tells the strategist which persona is on duty. Override with DOJO_STRATEGIST=shikamaru|lelouch; set DOJO_DEBUG=1 to log hook input to ${TMPDIR:-/tmp}/dojo-hook-debug.log.
Edit roster.mjs (characters, models, tools, descriptions), personas/*.md (voice sheets), roles/*.md (duties) or shared/house-rules.md, then:
npm run build # regenerate agents/*.md
npm run check # fail if agents/ has drifted (used in CI)
npm test
agents/*.md is generated; do not edit by hand.
How loud the characters are is set in roster.mjs: defaults: { voice: 'medium' }, or voice: '...' on one agent to override it.
| Level | What you get |
|---|---|
subtle | One in-character opening line, optional sign-off, plain body. |
medium (default) | 1-2 opening lines, character-coloured wording, up to ~3 short asides and 2 catchphrases, a closing line. About +20% length. |
full | The character speaks freely, section by section. About +40% length. |
At every level facts, paths, numbers and commands stay exact, and persona never reaches code, commits or files. Each crew report ends with a Says: line; the strategist relays them to you under Crew:. Level rules are in shared/voice/; run npm run build after changing a level.
A second plugin in this marketplace, built on Claude Code's function hooks (early-access API): /plugin install dojo-fx@dojo. The main dojo plugin works without it.
dojo:* subagent runs, one row each above the prompt: emoji, name, an animated frame, its task and its latest tool call (Bash: npm test). Gone when nobody is running.🧹 Levi deployed — "Chi. Show me the mess.", then 🧹 Levi returned or 💀 Levi fell.agent.spawn of any dojo:* type has model stripped, so a caller's "always pass model: sonnet" can't override an agent's own frontmatter model.The strategist is read from the dojo: on duty: <Name> context line; if it can't be found, DOJO_STRATEGIST is used, else a neutral strategist theme. Toggle features in /config (or pluginConfigs in settings): spinners, turnFlair, band, toasts, neutralMainTheme, all on by default. Emoji, verbs, frames and toast lines live in fx/hooks/characters.ts. Try it without installing: claude --plugin-dir ./fx. Tests: claude plugin test fx.
Don't pass model when spawning dojo agents: it overrides the model set in their frontmatter.
MIT
hooks/register.tsx 192 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import { CREW, FELL_EMOJI } from './characters'
5import {
6 bandLine,
7 crewKey,
8 isDojoType,
9 parseOnDuty,
10 pick,
11 strategistKey,
12 strategistTheme,
13 summarizeTool,
14} from './lib'
15import type { FxBand, FxCrewRow } from '../types'
16
17const strategist = atom({ plugin: 'dojo-fx', key: 'strategist' } as const, null)
18const turn = atom({ plugin: 'dojo-fx', key: 'turn' } as const, 0)
19const band = atom({ plugin: 'dojo-fx', key: 'band' } as const, { frame: 0, rows: [] } as FxBand)
20const tools = atom({ plugin: 'dojo-fx', key: 'tools' } as const, {} as Record<string, string>)
21
22const TICK_MS = 250
23const TALK_DOORS = new Set(['prompt', 'response', 'tool-result'])
24const TERMINAL = new Set(['completed', 'failed', 'killed'])
25
26type Options = Readonly<Record<string, unknown>>
27
28// Each feature is a toggle (userConfig). The model guard below has no toggle.
29const isOn = (options: Options, name: string) => options[name] !== false
30
31// Module variables die with a hot reload; everything a drawing reads lives in $.state.
32const seen = new Map<string, string>()
33let timer: { cancel: () => void } | undefined
34
35async function mainTheme($: Engine, options: Options) {
36 let key = (await read($, strategist)) ?? null
37 if (key === null) {
38 key = strategistKey(await $.env.get('DOJO_STRATEGIST'))
39 }
40 return strategistTheme(key, isOn(options, 'neutralMainTheme'))
41}
42
43async function scanForPersona($: Engine) {
44 if ((await read($, strategist)) !== null) return
45 try {
46 const messages = await $.session.messages()
47 if ('deny' in messages) return
48 for (const m of messages.slice(0, 8)) {
49 const found = parseOnDuty(m.text)
50 if (found !== null) {
51 await update($, strategist, () => found)
52 return
53 }
54 }
55 } catch {
56 // no transcript to read: the env var or the neutral theme stands
57 }
58}
59
60async function tick($: Engine, options: Options) {
61 const crew = (await $.agent.list()).filter(a => crewKey(a.type) !== null)
62 const rows: FxCrewRow[] = []
63 for (const a of crew) {
64 const key = crewKey(a.type)
65 if (key === null) continue
66 const c = CREW[key]
67 if (c === undefined) continue
68 const before = seen.get(a.id)
69 seen.set(a.id, a.status)
70 if (TERMINAL.has(a.status)) {
71 if (before !== undefined && !TERMINAL.has(before) && isOn(options, 'toasts')) {
72 $.ui.toast(a.status === 'completed' ? `${c.emoji} ${c.name} returned` : `${FELL_EMOJI} ${c.name} fell`)
73 }
74 } else {
75 rows.push({ id: a.id, key, description: a.description })
76 }
77 }
78 if (rows.length === 0) {
79 timer?.cancel()
80 timer = undefined
81 }
82 if (!isOn(options, 'band')) return
83 await update($, band, b =>
84 rows.length === 0 && b.rows.length === 0 ? b : { frame: rows.length === 0 ? 0 : b.frame + 1, rows },
85 )
86}
87
88function ensureTick($: Engine, options: Options) {
89 if (timer !== undefined || !(isOn(options, 'band') || isOn(options, 'toasts'))) return
90 timer = $.clock.every(TICK_MS, () => {
91 tick($, options).catch(() => {})
92 })
93}
94
95export const register: Register = (on, options) => {
96 // Model guard: ships regardless of the visual toggles. A caller's "always pass model: sonnet"
97 // must not override a dojo agent's own frontmatter model; undefined lets the agent decide.
98 on('agent.spawn', async ($, e, next) => {
99 if (!isDojoType(e.subagentType)) return next(e)
100 const ran = await next(e.model === undefined ? e : { ...e, model: undefined })
101
102 const key = crewKey(e.subagentType)
103 if (ran.deny === undefined && ran.agentId !== undefined && key !== null) {
104 seen.set(ran.agentId, 'running')
105 const c = CREW[key]
106 if (c !== undefined && isOn(options, 'toasts')) {
107 $.ui.toast(`${c.emoji} ${c.name} deployed — "${c.deploy}"`)
108 }
109 ensureTick($, options)
110 if (isOn(options, 'band')) {
111 const id = ran.agentId
112 await update($, band, b => ({
113 ...b,
114 rows: [...b.rows.filter(r => r.id !== id), { id, key, description: e.description }],
115 }))
116 }
117 }
118 return ran
119 })
120
121 // Persona detection: the dojo SessionStart hook injects `dojo: on duty: <Name>` as context.
122 // Read it off the appended row (any door but the conversation's own talk), and once per
123 // prompt fall back to scanning the transcript until it is found.
124 on('session.append', async ($, e, next) => {
125 if (e.agentId === undefined && !TALK_DOORS.has(e.door)) {
126 for (const block of e.message.content) {
127 const found = block.type === 'text' && typeof block.text === 'string' ? parseOnDuty(block.text) : null
128 if (found !== null) await update($, strategist, () => found)
129 }
130 }
131 return next(e)
132 })
133
134 on('prompt.submit', async ($, e, next) => {
135 await update($, turn, n => n + 1)
136 await scanForPersona($)
137 return next(e)
138 })
139
140 on('tool.call', async ($, e, next) => {
141 if (e.agentId !== undefined && isOn(options, 'band')) {
142 const id = e.agentId
143 const line = summarizeTool(String(e.tool), e as unknown as Record<string, unknown>)
144 await update($, tools, t => ({ ...t, [id]: line }))
145 ensureTick($, options)
146 }
147 return next(e)
148 })
149
150 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
151 if (!isOn(options, 'spinners')) return next(e)
152 const n = (await read($, turn)) ?? 0
153 const agent = (await $.agent.list()).find(a => a.id === e.requestId)
154 const key = agent === undefined ? null : crewKey(agent.type)
155 const crew = key === null ? undefined : CREW[key]
156 if (crew !== undefined) {
157 return next({ ...e, props: { ...e.props, word: `${crew.emoji} ${pick(crew.verbs, n, e.requestId)}` } })
158 }
159 if (agent !== undefined) return next(e) // some other subagent: leave it alone
160 const theme = await mainTheme($, options)
161 if (theme === null) return next(e)
162 return next({ ...e, props: { ...e.props, word: `${theme.emoji} ${pick(theme.verbs, n)}` } })
163 })
164
165 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
166 if (!isOn(options, 'turnFlair')) return next(e)
167 const theme = await mainTheme($, options)
168 if (theme?.done === undefined) return next(e)
169 const n = (await read($, turn)) ?? 0
170 return next({ ...e, props: { ...e.props, word: pick(theme.done, n, String(e.props.durationMs)) } })
171 })
172
173 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
174 if (!isOn(options, 'band') || e.props.hasSurvey) return next(e)
175 const { frame, rows } = await read($, band)
176 if (rows.length === 0) return next(e)
177
178 const latest = await read($, tools)
179 const { Box, Text } = $.ui.resolve(e)
180 const shown = rows.slice(0, Math.max(1, e.props.maxRows))
181 return (
182 <Box flexDirection="column">
183 {shown.map(r => (
184 <Text key={r.id} wrap="truncate-end">
185 {bandLine({ key: r.key, description: r.description, tool: latest[r.id], frame }, e.props.bodyColumns)}
186 </Text>
187 ))}
188 </Box>
189 )
190 })
191}
192hooks/characters.ts 92 lines1// dojo-fx character data: the one place to edit emoji, verbs, frames and toast lines.
2// Keys match the dojo roster (`dojo:<key>` agent types; strategists are personas).
3
4export type Character = {
5 name: string
6 emoji: string
7 /** Spinner verbs (present participle), rotated per turn. */
8 verbs: readonly string[]
9 /** Band animation frames, cycled by the clock. Single-width glyphs. */
10 frames: readonly string[]
11 /** Toast line on deploy (crew only). */
12 deploy: string
13 /** TurnDuration past-tense words (strategists only). */
14 done?: readonly string[]
15}
16
17export const STRATEGISTS: Record<string, Character> = {
18 shikamaru: {
19 name: 'Shikamaru',
20 emoji: '🦌',
21 verbs: ['Shadow-possessing', 'Reading the board', 'Sighing strategically', 'Mendokusē-ing', 'Locking scope', 'Binding the last risk'],
22 frames: ['◐', '◓', '◑', '◒'],
23 deploy: 'Mendokusē.',
24 done: ['Outmaneuvered', 'Shadow-bound', 'Sighed through'],
25 },
26 lelouch: {
27 name: 'Lelouch',
28 emoji: '♟️',
29 verbs: ['Commanding', 'Geass-ing', 'Plotting checkmate', 'Moving the pieces', 'Dispatching the Black Knights', 'Staging the Requiem'],
30 frames: ['♙', '♘', '♗', '♖'],
31 deploy: 'Kneel, bugs.',
32 done: ['Checkmated', 'Commanded', 'Geass-ed'],
33 },
34}
35
36/** Used when no persona is detected (and DOJO_STRATEGIST is unset). */
37export const NEUTRAL: Character = {
38 name: 'Strategist',
39 emoji: '🎴',
40 verbs: ['Strategizing', 'Planning', 'Moving the pieces', 'Reading the board'],
41 frames: ['◐', '◓', '◑', '◒'],
42 deploy: '',
43 done: ['Strategized', 'Outplanned'],
44}
45
46export const CREW: Record<string, Character> = {
47 senku: {
48 name: 'Senku',
49 emoji: '🧪',
50 verbs: ['Science-ing', 'Ten-billion-percenting', 'Brewing Revival Fluid', 'Building from scratch', 'Getting excited'],
51 frames: ['⚗', '⚛', '✧', '⚛'],
52 deploy: 'Ten billion percent doable.',
53 },
54 sanji: {
55 name: 'Sanji',
56 emoji: '🍳',
57 verbs: ['Plating', 'Diable-Jambe-ing', 'Concassé-ing', 'Seasoning', 'Mouton-Shotting'],
58 frames: ['♨', '≈', '∿', '≈'],
59 deploy: 'Allow me.',
60 },
61 l: {
62 name: 'L',
63 emoji: '🍰',
64 verbs: ['Deducing', 'Crouching thoughtfully', 'Eliminating suspects', 'Weighing sweets', 'Closing the case'],
65 frames: ['◔', '◑', '◕', '●'],
66 deploy: 'Hm. A suspect with two alibis.',
67 },
68 light: {
69 name: 'Light',
70 emoji: '📓',
71 verbs: ['Writing in the notebook', 'Calculating', 'Staying a step ahead', 'Reading the source', 'Smirking'],
72 frames: ['✎', '✐', '✑', '✐'],
73 deploy: 'Just as planned.',
74 },
75 levi: {
76 name: 'Levi',
77 emoji: '🧹',
78 verbs: ['Inspecting', 'Scrubbing', 'Wiping it down', 'Checking the corners', 'Tch-ing'],
79 frames: ['⚔', '⟋', '⟍', '✕'],
80 deploy: 'Chi. Show me the mess.',
81 },
82 robin: {
83 name: 'Robin',
84 emoji: '🌸',
85 verbs: ['Deciphering', 'Blooming arms', 'Reading the Poneglyph', 'Cien-Fleur-ing', 'Pinning a stale claim'],
86 frames: ['❀', '✿', '❁', '✾'],
87 deploy: "Fufu. I'll decipher it.",
88 },
89}
90
91export const FELL_EMOJI = '💀'
92hooks/lib.ts 74 lines1import { CREW, NEUTRAL, STRATEGISTS } from './characters'
2import type { Character } from './characters'
3
4export const DOJO_PREFIX = 'dojo:'
5const ON_DUTY = /dojo: on duty: ([A-Za-z]+)/
6
7/** `dojo:levi` -> `levi`; anything that is not a dojo crew type -> null (the strategist is no crew). */
8export function crewKey(type: string): string | null {
9 if (!type.startsWith(DOJO_PREFIX)) return null
10 const key = type.slice(DOJO_PREFIX.length)
11 return Object.prototype.hasOwnProperty.call(CREW, key) ? key : null
12}
13
14export function isDojoType(type: string): boolean {
15 return type.startsWith(DOJO_PREFIX)
16}
17
18/** Persona key from the SessionStart context line `dojo: on duty: <Name>`, or null. */
19export function parseOnDuty(text: string): string | null {
20 const name = ON_DUTY.exec(text)?.[1]?.toLowerCase()
21 return name !== undefined && Object.prototype.hasOwnProperty.call(STRATEGISTS, name) ? name : null
22}
23
24export function strategistKey(raw: string | undefined | null): string | null {
25 const key = (raw ?? '').trim().toLowerCase()
26 return Object.prototype.hasOwnProperty.call(STRATEGISTS, key) ? key : null
27}
28
29export function strategistTheme(key: string | null, neutral: boolean): Character | null {
30 if (key !== null) return STRATEGISTS[key] ?? null
31 return neutral ? NEUTRAL : null
32}
33
34export function hash(text: string): number {
35 let h = 0
36 for (const c of text) h = (h * 31 + c.charCodeAt(0)) >>> 0
37 return h
38}
39
40/** Rotates per turn; `salt` (an agent id) keeps concurrent agents from marching in step. */
41export function pick<T>(pool: readonly T[], turn: number, salt = ''): T {
42 return pool[(turn + hash(salt)) % pool.length] as T
43}
44
45export function truncate(text: string, max: number): string {
46 const chars = [...text]
47 if (max <= 0) return ''
48 return chars.length <= max ? text : chars.slice(0, Math.max(0, max - 1)).join('') + '…'
49}
50
51/** `Bash: npm test` from a tool.call event's tool name and arguments. */
52export function summarizeTool(tool: string, args: Record<string, unknown>): string {
53 const preferred = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description']
54 const field = preferred.find(k => typeof args[k] === 'string')
55 const value =
56 field !== undefined
57 ? (args[field] as string)
58 : Object.values(args).find((v): v is string => typeof v === 'string')
59 const short = tool.replace(/^mcp__/, '')
60 return value === undefined ? short : `${short}: ${value.replace(/\s+/g, ' ').trim()}`
61}
62
63export type RowInput = { key: string; description: string; tool?: string; frame: number }
64
65/** One band line, cut to `columns` cells. */
66export function bandLine(row: RowInput, columns: number): string {
67 const c = CREW[row.key]
68 if (c === undefined) return ''
69 const glyph = c.frames[row.frame % c.frames.length] ?? ''
70 const head = `${c.emoji} ${c.name} ${glyph} ${row.description}`
71 const line = row.tool === undefined ? head : `${head} · ${row.tool}`
72 return truncate(line, columns)
73}
74types/index.d.ts 18 lines1export type FxCrewRow = { id: string; key: string; description: string }
2export type FxBand = { frame: number; rows: FxCrewRow[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'dojo-fx': {
7 /** Detected strategist persona key (`shikamaru`, `lelouch`) or null. */
8 strategist: string | null
9 /** Prompts submitted so far; rotates the verb pools. */
10 turn: number
11 /** Running dojo subagents plus the animation frame. */
12 band: FxBand
13 /** Latest tool call per subagent id, e.g. `Bash: npm test`. */
14 tools: Record<string, string>
15 }
16 }
17}
18