SLOPSHOPPER

dojo-fx

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…

newbandspinnerguardtoastprompt
v0.1.0MITupdated 2026-10-02shreym95/dojo/fx
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dojo-fx
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Outplanned for 42s · done 4:20 PM ✻ 🎴 Planning… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dojo

Anime-themed Claude Code subagents: one orchestrator and a crew, each with its own model, tools and voice.

Crew

AgentCharacterRoleModel
dojo:strategistShikamaru or Lelouch (per session)Orchestrator / integrator (main thread)claude-opus-5-5
dojo:senkuSenkuBackend builderclaude-sonnet-5-5
dojo:sanjiSanjiFrontend builderclaude-sonnet-5-5
dojo:lLDeep web researcherclaude-haiku-4-5
dojo:lightLightQuick fact-finderclaude-haiku-4-5
dojo:leviLeviQA inspector (read-only)claude-haiku-4-5
dojo:robinRobinDocumentation writerclaude-haiku-4-5

Install

/plugin marketplace add shreym95/dojo
/plugin install dojo@dojo

Launch the orchestrator

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.

Customise

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.

Voice levels

How loud the characters are is set in roster.mjs: defaults: { voice: 'medium' }, or voice: '...' on one agent to override it.

LevelWhat you get
subtleOne 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.
fullThe 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.

dojo-fx (optional visuals)

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.

  • Themed spinners: the main thread's spinner word comes from the on-duty strategist (Shikamaru: "Shadow-possessing", "Reading the board"; Lelouch: "Commanding", "Geass-ing"). A crew member's own spinner gets its emoji and verbs (🧪 Senku "Science-ing", 🧹 Levi "Scrubbing", 🌸 Robin "Deciphering"...). Verbs rotate per turn.
  • Turn-end flair: the line that closes a turn says "Outmaneuvered" (Shikamaru) or "Checkmated" (Lelouch).
  • Crew band: while any 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.
  • Toasts: 🧹 Levi deployed — "Chi. Show me the mess.", then 🧹 Levi returned or 💀 Levi fell.
  • Model guard (always on): an 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.

Note

Don't pass model when spawning dojo agents: it overrides the model set in their frontmatter.

License

MIT

Source 4 files
hooks/register.tsx 192 lines
1import { 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}
192
hooks/characters.ts 92 lines
1// 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 = '💀'
92
hooks/lib.ts 74 lines
1import { 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}
74
types/index.d.ts 18 lines
1export 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