Anime-themed subagents: a strategist orchestrator plus backend, frontend, research, QA and docs crew, each with its own model, tools and voice. Includes…

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.
Crew agents: 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.
Visuals and art: edit mods/theme.ts to change character emoji and colors; mods/visuals/transcript-art.ts for banners and speech bubbles; mods/visuals/chrome-art.ts for spinner verbs, turn-end words, toast lines, fall lines, prompt hints and band headers. Validate, test and try locally:
claude plugin validate . # check the hook structure
claude plugin test . # run the mod suite
claude --plugin-dir . # try the plugin locally without installing
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.
Built on Claude Code's function hooks (early-access API), the dojo plugin includes:
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.🦌 Chakra ▰▰▰▰▰▰▱▱▱▱ 58% · Stamina 23% (5h) · 両 $1.42. Chakra is the context window's fill, Stamina the busiest rate-limit window (subscriptions only), and the last figure the session cost. The bar takes the strategist's color, turns to the warning color from 60% and to the error color from 85%, where a short line from the strategist is added. A segment with no data is left out, and under 50 columns the row shrinks to 🦌 ▰▰▰▱ 58% · 23%. The crew band draws below it./dojo shows the crew, who is on duty and who is running.🧹 Levi deployed — "Chi. Show me the mess.", then 🧹 Levi returned or 💀 Levi fell. Sound cues play on macOS only (via afplay).Toggle features in /config (or pluginConfigs in settings): spinners, turnFlair, band, usageLine, toasts, neutralMainTheme, transcriptTheme, banners, promptChrome, rosterPane, sounds, all on by default.
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.git commit command that breaks the rules, and injects the configured Co-Authored-By trailer (default Co-Authored-By: Claude). Empty trailer disables the check./scratchpad/) is exempt./dojo-duty [shikamaru|lelouch]: Show or switch the strategist persona on duty. The choice survives compaction and resume in the same process; a resume in a new process starts from the persona picked at session start./dojo-voice [subtle|medium|full|default|reset|off]: Show or set the dojo voice level override for this session; overrides pass to agents spawned until cleared./dojo: Open the roster pane (requires rosterPane toggle on).Every setting below lives in the userConfig section of the plugin and can be toggled in /config or set in pluginConfigs in Claude Code's settings.json:
| Key | Default | What it does |
|---|---|---|
spinners | true | Replace the spinner word with the on-duty strategist's (or crew member's) themed verbs. |
turnFlair | true | Past-tense themed word on the line that closes a turn (Outmaneuvered, Checkmated). |
band | true | Show one animated row per running dojo subagent above the prompt. |
usageLine | true | Show one row above the prompt with the session's context fill (Chakra), the busiest rate-limit window (Stamina) and the cost. |
toasts | true | Toast when a dojo subagent is deployed, returns or falls. |
neutralMainTheme | true | When no strategist persona is detected, still theme the main spinner with a neutral strategist pool. |
commitGate | true | Force the configured commit trailer into git texts and deny git commit commands whose inline message breaks the commit rules (Conventional Commits subject, no body, exact trailer). Applies in every session. |
commitTrailer | Co-Authored-By: Claude | The exact Co-Authored-By line commits must carry. Empty keeps Claude Code's own text and skips the trailer check. |
roleGuard | true | In a dojo session, cap the main thread's inline edits and web lookups per turn and point it at dojo:senku, dojo:sanji, dojo:l or dojo:light. |
inlineEditFiles | 2 | Distinct files the main thread may edit itself in one turn before it must delegate. |
inlineEditLines | 6 | Largest Edit (lines in new_string) the main thread may make itself. |
inlineLookups | 3 | WebSearch and WebFetch calls the main thread may make itself in one turn before it must delegate. |
archivist | true | In a dojo session, tell the compaction summary to keep the on-duty line, the voice level, running dojo agents with their tasks, and the user's decisions. |
transcriptTheme | true | Frame messages, tool calls and subagent results in the on-duty character's colors: persona frames on messages, tool badges, crew cards and speech bubbles. |
banners | true | Show an ASCII banner when a dojo subagent is deployed and when it returns. |
promptChrome | true | Theme the prompt area: persona hints under the prompt and the session mode line. |
rosterPane | true | Show the /dojo roster panel: the crew, who is on duty and who is running. |
sounds | true | Play a sound cue when a dojo subagent is deployed, returns or falls (macOS only). |
Don't pass model when spawning dojo agents: the model guard enforces each agent's own frontmatter model.
MIT
mods/register.tsx 28 lines1import type { Register } from 'claude-code'
2
3import { installArchivist } from './features/archivist'
4import { installCommands } from './features/commands'
5import { installCommitGate } from './features/commit-gate'
6import { installModelGuard } from './features/model-guard'
7import { installRoleGuard } from './features/role-guard'
8import { installPersona } from './lib/persona'
9import { installChrome } from './visuals/chrome'
10import { installTranscript } from './visuals/transcript'
11
12// What `claude plugin validate` enforces on this module graph, and so on every installer:
13// - an event has at most one hook without a matcher (today: session.append, prompt.submit, agent.spawn in the
14// model guard, tool.call in chrome); any further hook on it needs a distinct matcher: on(event, matcher, hook)
15// - `$` is passed only to functions declared in the same file, never across an import
16// - an atom is read and updated only through a const of the file that uses it (the {plugin, key} pair is the shared name)
17// - events are spelled literally: on('<event>', ...)
18export const register: Register = (on, options) => {
19 installPersona(on, options)
20 installModelGuard(on, options)
21 installCommitGate(on, options)
22 installRoleGuard(on, options)
23 installArchivist(on, options)
24 installCommands(on, options)
25 installChrome(on, options)
26 installTranscript(on, options)
27}
28mods/features/archivist.ts 71 lines1import { atom, read } from 'claude-code'
2
3import { isOn } from '../lib/options'
4import type { Installer } from '../lib/options'
5import { isDojoType, strategistKey, strategistName } from '../lib/persona'
6import { toastLine } from '../lib/toast'
7import { voiceKey } from '../lib/voice'
8import type { VoiceLevel } from '../voice.generated'
9
10// The module scan reads an atom only from a const of the file that uses it; the {plugin, key} pair is the shared name.
11const strategist = atom({ plugin: 'dojo', key: 'strategist' } as const, null)
12const voice = atom({ plugin: 'dojo', key: 'voice' } as const, null)
13
14const TERMINAL = new Set(['completed', 'failed', 'killed'])
15
16export type RunningAgent = { id: string; type: string; description: string }
17
18/** The directive appended to the compaction instructions. */
19export function compactDirective(input: { strategist: string | null; voice: VoiceLevel | null; agents: readonly RunningAgent[] }): string {
20 const name = strategistName(input.strategist)
21 const lines = ['When summarizing, preserve verbatim:']
22 lines.push(
23 name === null
24 ? '- the context line "dojo: on duty: <Name>" naming the strategist persona on duty, exactly as it appeared'
25 : `- the context line "dojo: on duty: ${name}" (the strategist persona on duty)`,
26 )
27 lines.push(
28 input.voice === null
29 ? '- the active voice level: the roster default (no override is set)'
30 : `- the active voice level override: "dojo: voice level: ${input.voice}"`,
31 )
32 lines.push(
33 input.agents.length === 0
34 ? '- every running dojo agent with its task (none are running now)'
35 : `- every running dojo agent with its task:\n${input.agents.map(a => ` - ${a.type} (${a.id}): ${a.description}`).join('\n')}`,
36 )
37 lines.push('- every decision the user made, with the question it answered')
38 return lines.join('\n')
39}
40
41export const installArchivist: Installer = (on, options) => {
42 // The compaction summary keeps what a dojo session cannot afford to lose.
43 on('session.compact', async ($, e, next) => {
44 let out = e
45 try {
46 if (isOn(options, 'archivist') && e.agentId === undefined) {
47 const key = (await read($, strategist)) ?? strategistKey(await $.env.get('DOJO_STRATEGIST'))
48 if (key !== null) {
49 const agents = (await $.agent.list())
50 .filter(a => isDojoType(a.type) && !TERMINAL.has(a.status))
51 .map(a => ({ id: a.id, type: a.type, description: a.description }))
52 const level = voiceKey(await read($, voice))
53 const directive = compactDirective({ strategist: key, voice: level, agents })
54 const instructions = e.instructions === undefined || e.instructions.trim() === '' ? directive : `${e.instructions}\n\n${directive}`
55 out = { ...e, instructions }
56 if (e.trigger !== 'precompute') {
57 try {
58 $.ui.toast(toastLine('compact'))
59 } catch {
60 // a toast is never worth failing a hook
61 }
62 }
63 }
64 }
65 } catch {
66 out = e
67 }
68 return next(out)
69 })
70}
71mods/features/commands.ts 89 lines1import { atom, read, update } from 'claude-code'
2
3import type { Installer } from '../lib/options'
4import { isDojoType, strategistKey, strategistName } from '../lib/persona'
5import { VOICE_CLEAR_WORDS, VOICE_LEVELS, voiceContext, voiceKey, voiceOverrideBlock } from '../lib/voice'
6import { STRATEGISTS } from '../theme'
7
8// The module scan reads an atom only from a const of the file that uses it; the {plugin, key} pair is the shared name.
9const strategist = atom({ plugin: 'dojo', key: 'strategist' } as const, null)
10const chosen = atom({ plugin: 'dojo', key: 'chosen' } as const, null)
11const voice = atom({ plugin: 'dojo', key: 'voice' } as const, null)
12
13const COMMANDS = [
14 { name: 'dojo-duty', description: 'Show or switch the strategist persona on duty', argumentHint: '[shikamaru|lelouch]' },
15 { name: 'dojo-voice', description: 'Show or set the dojo voice level for this session', argumentHint: '[subtle|medium|full|default]' },
16] as const
17
18const argsOf = (args: unknown) => (typeof args === 'string' ? args : '')
19
20export const installCommands: Installer = on => {
21 // Voice override: dojo crew spawned while /dojo-voice is set carry that level's rules.
22 on('agent.spawn', { subagentType: /^dojo:/ }, async ($, e, next) => {
23 let out = e
24 try {
25 const level = voiceKey(await read($, voice))
26 if (level !== null && isDojoType(e.subagentType) && e.subagentType !== 'dojo:strategist') {
27 out = { ...e, prompt: `${e.prompt}\n\n${voiceOverrideBlock(level)}` }
28 }
29 } catch {
30 out = e
31 }
32 return next(out)
33 })
34
35 // Slash commands can only be registered once the session binds.
36 on('session.start', async ($, e, next) => {
37 for (const spec of COMMANDS) {
38 try {
39 await $.command.register(spec)
40 } catch {
41 // a name taken or refused: the others still register
42 }
43 }
44 return next(e)
45 })
46
47 on('command.run', { command: 'dojo-duty' }, async ($, e, next) => {
48 try {
49 const arg = argsOf(e.args).trim()
50 const current = (await read($, strategist)) ?? strategistKey(await $.env.get('DOJO_STRATEGIST'))
51 if (arg === '') {
52 const name = strategistName(current)
53 return { text: name === null ? `No strategist on duty. Valid names: ${Object.keys(STRATEGISTS).join(', ')}.` : `On duty: ${name}` }
54 }
55 const key = strategistKey(arg)
56 if (key === null) return { text: `Unknown strategist "${arg}". Valid names: ${Object.keys(STRATEGISTS).join(', ')}.` }
57 // the choice is the persona on duty now, and the one every later automatic on-duty line must yield to
58 await update($, strategist, () => key)
59 await update($, chosen, () => key)
60 const name = strategistName(key) as string
61 return { text: `On duty: ${name}`, context: [`dojo: on duty: ${name}`] }
62 } catch {
63 return next(e)
64 }
65 })
66
67 on('command.run', { command: 'dojo-voice' }, async ($, e, next) => {
68 try {
69 const arg = argsOf(e.args).trim().toLowerCase()
70 if (arg === '') {
71 const level = voiceKey(await read($, voice))
72 return { text: `Voice level override: ${level ?? 'none (roster default)'}` }
73 }
74 if (VOICE_CLEAR_WORDS.includes(arg)) {
75 await update($, voice, () => null)
76 return { text: 'Voice level override cleared (roster default).', context: ['dojo: voice level: default'] }
77 }
78 const level = voiceKey(arg)
79 if (level === null) {
80 return { text: `Unknown voice level "${arg}". Valid levels: ${VOICE_LEVELS.join(', ')}, or default to clear.` }
81 }
82 await update($, voice, () => level)
83 return { text: `Voice level override: ${level} (the strategist and dojo agents spawned from now on).`, context: [voiceContext(level)] }
84 } catch {
85 return next(e)
86 }
87 })
88}
89mods/features/commit-gate.ts 199 lines1import { isOn } from '../lib/options'
2import type { Installer, Options } from '../lib/options'
3import { base, head, parseShell } from '../lib/shell'
4import type { Word } from '../lib/shell'
5import { toastLine } from '../lib/toast'
6
7export const DEFAULT_TRAILER = 'Co-Authored-By: Claude'
8
9export type CommitCall = { messages: string[]; trailers: string[] }
10
11const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--super-prefix'])
12const UNSUPPORTED_LONG = new Set(['--file', '--reuse-message', '--reedit-message', '--fixup', '--squash', '--template'])
13const SKIP_VALUE_LONG = new Set(['--author', '--date', '--cleanup', '--pathspec-from-file'])
14
15/**
16 * Every `git commit` in `command` that carries its message inline (-m / --message, repeated or not).
17 * Commits whose message cannot be read statically (-F, -C, --fixup, editor, expansions) are left out.
18 */
19export function extractCommits(command: string): CommitCall[] {
20 const calls: CommitCall[] = []
21 for (const cmd of parseShell(command)) {
22 const h = head(cmd)
23 if (base(h[0]?.text) !== 'git') continue
24 let k = 1
25 while (h[k] !== undefined && h[k]?.text.startsWith('-')) k += GIT_VALUE_OPTIONS.has(h[k]?.text ?? '') ? 2 : 1
26 if (h[k]?.text !== 'commit') continue
27
28 const args = h.slice(k + 1)
29 const messages: string[] = []
30 const trailers: string[] = []
31 let unreadable = false
32 let a = 0
33 const value = (): string | undefined => {
34 const w = args[++a]
35 if (w === undefined) {
36 unreadable = true
37 return undefined
38 }
39 if (w.dynamic) unreadable = true
40 return w.text
41 }
42 for (; a < args.length; a++) {
43 const w = args[a] as Word
44 if (w.text === '--') break
45 if (w.dynamic) unreadable = true
46 if (w.text.startsWith('--')) {
47 const eq = w.text.indexOf('=')
48 const name = eq < 0 ? w.text : w.text.slice(0, eq)
49 const inline = eq < 0 ? undefined : w.text.slice(eq + 1)
50 if (name === '--message') {
51 const v = inline ?? value()
52 if (v !== undefined) messages.push(v)
53 } else if (name === '--trailer') {
54 const v = inline ?? value()
55 if (v !== undefined) trailers.push(v)
56 } else if (UNSUPPORTED_LONG.has(name)) {
57 unreadable = true
58 } else if (SKIP_VALUE_LONG.has(name) && inline === undefined) {
59 a++
60 }
61 } else if (w.text.startsWith('-') && w.text.length > 1) {
62 for (let p = 1; p < w.text.length; p++) {
63 const ch = w.text[p] as string
64 if (ch === 'm') {
65 const rest = w.text.slice(p + 1)
66 const v = rest === '' ? value() : rest
67 if (v !== undefined) messages.push(v)
68 break
69 }
70 if ('FCct'.includes(ch)) {
71 unreadable = true
72 break
73 }
74 if (ch === 'S' || ch === 'u') break
75 }
76 }
77 }
78 if (!unreadable && messages.length > 0) calls.push({ messages, trailers })
79 }
80 return calls
81}
82
83export const COMMIT_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'] as const
84const SUBJECT = new RegExp(`^(${COMMIT_TYPES.join('|')})(\\([^()\\r\\n]+\\))?!?: \\S`)
85const SUBJECT_MAX = 72
86const TRAILER_LINE = /^[A-Za-z][A-Za-z0-9-]*\s*:\s*\S/
87const GENERATED_WITH = /generated with \[?claude code/i
88
89function clip(text: string, max = 60): string {
90 const chars = [...text]
91 return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…'
92}
93
94/** Rule violations of one commit message, empty when it is clean. `trailer` '' skips the trailer check. */
95export function checkCommitMessage(call: CommitCall, trailer: string): string[] {
96 const full = [...call.messages, ...(call.trailers.length > 0 ? [call.trailers.join('\n')] : [])]
97 .join('\n\n')
98 .replace(/\r\n?/g, '\n')
99 const lines = full.split('\n')
100 while (lines.length > 0 && (lines[0] ?? '').trim() === '') lines.shift()
101 const subject = (lines.shift() ?? '').trim()
102 const out: string[] = []
103
104 if (!SUBJECT.test(subject)) {
105 out.push(
106 `the subject is not Conventional Commits (type(scope)!: subject; types: ${COMMIT_TYPES.join(', ')}): "${clip(subject)}"`,
107 )
108 }
109 const length = [...subject].length
110 if (length > SUBJECT_MAX) out.push(`the subject is ${length} characters; the limit is ${SUBJECT_MAX}`)
111
112 let body: string | undefined
113 for (const raw of lines) {
114 const line = raw.trim()
115 if (line === '' || GENERATED_WITH.test(line) || TRAILER_LINE.test(line)) continue
116 body ??= line
117 }
118 if (body !== undefined) {
119 out.push(`the message has a body ("${clip(body)}"); keep only the subject line and trailer lines`)
120 }
121
122 if (trailer !== '') {
123 for (const raw of lines) {
124 const line = raw.trim()
125 if (/^co-authored-by\s*:/i.test(line) && /claude/i.test(line) && line !== trailer) {
126 out.push(`the Co-Authored-By line "${clip(line, 80)}" must be exactly "${trailer}"`)
127 }
128 }
129 }
130 if (lines.some(l => /^claude-session\s*:/i.test(l.trim()))) out.push('a "Claude-Session:" line is not allowed')
131 if (GENERATED_WITH.test(full)) out.push('"Generated with Claude Code" text is not allowed')
132 return out
133}
134
135/** All violations over every inline `git commit` in `command` (empty: nothing to object to). */
136export function checkCommitCommand(command: string, trailer: string): string[] {
137 const out: string[] = []
138 for (const call of extractCommits(command)) {
139 for (const v of checkCommitMessage(call, trailer)) if (!out.includes(v)) out.push(v)
140 }
141 return out
142}
143
144/** The deny text the model reads: each violation, then the corrected form. */
145export function commitDenyReason(violations: readonly string[], trailer: string): string {
146 const fix =
147 trailer === ''
148 ? 'Retry git commit with a single Conventional Commits subject line (at most 72 characters), no body, and no Claude-Session or "Generated with Claude Code" lines.'
149 : `Retry git commit with a single Conventional Commits subject line (at most 72 characters), no body, and, if you add a trailer, exactly this line:\n${trailer}`
150 return ['Commit message rejected by the dojo commit gate:', ...violations.map(v => `- ${v}`), fix].join('\n')
151}
152
153function trailerOf(options: Options): string {
154 const v = options.commitTrailer
155 return typeof v === 'string' ? v.trim() : DEFAULT_TRAILER
156}
157
158function commandOf(input: unknown): string | null {
159 const c = typeof input === 'object' && input !== null ? (input as { command?: unknown }).command : undefined
160 return typeof c === 'string' ? c : null
161}
162
163export const installCommitGate: Installer = (on, options) => {
164 // The model reads the configured trailer wherever core would have written its own.
165 on('attribution.text', { kind: 'commit' }, async ($, e, next) => {
166 try {
167 const trailer = trailerOf(options)
168 if (isOn(options, 'commitGate') && trailer !== '') return { text: trailer }
169 } catch {
170 // fall through to core's text
171 }
172 return next(e)
173 })
174
175 // Core answers first; a deny is never weakened.
176 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
177 const core = await next(e)
178 try {
179 if (core.decision === 'deny') return core
180 const command = commandOf(e.input)
181 if (command === null || !isOn(options, 'commitGate')) return core
182
183 const trailer = trailerOf(options)
184 const violations = checkCommitCommand(command, trailer)
185 if (violations.length > 0) {
186 try {
187 $.ui.toast(toastLine('commit'))
188 } catch {
189 // a toast is never worth failing a hook
190 }
191 return { decision: 'deny' as const, reason: commitDenyReason(violations, trailer) }
192 }
193 } catch {
194 // fail open: core's verdict stands
195 }
196 return core
197 })
198}
199mods/features/model-guard.ts 12 lines1import { isDojoType } from '../lib/persona'
2import type { Installer } from '../lib/options'
3
4// A caller's "always pass model: sonnet" must not override a dojo agent's own frontmatter model;
5// undefined lets the agent decide. Ships regardless of every toggle.
6export const installModelGuard: Installer = on => {
7 on('agent.spawn', async ($, e, next) => {
8 if (!isDojoType(e.subagentType)) return next(e)
9 return next(e.model === undefined ? e : { ...e, model: undefined })
10 })
11}
12mods/features/role-guard.ts 189 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine } from 'claude-code'
3
4import { isOn } from '../lib/options'
5import type { Installer, Options } from '../lib/options'
6import { strategistKey } from '../lib/persona'
7import { toastLine } from '../lib/toast'
8import type { DojoBudget } from '../state'
9
10// The module scan reads an atom only from a const of the file that uses it; the {plugin, key} pair is the shared name.
11const strategist = atom({ plugin: 'dojo', key: 'strategist' } as const, null)
12const turn = atom({ plugin: 'dojo', key: 'turn' } as const, 0)
13const budget = atom({ plugin: 'dojo', key: 'budget' } as const, { files: [], lookups: 0 } as DojoBudget)
14// The prompt number the budget belongs to: a new prompt is a new turn, and its first metered call starts a fresh budget.
15const budgetTurn = atom({ plugin: 'dojo', key: 'budgetTurn' } as const, 0)
16
17export type Budget = DojoBudget
18export const EMPTY_BUDGET: Budget = { files: [], lookups: 0 }
19
20const METERED_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'WebSearch', 'WebFetch'] as const
21
22export type RoleLimits = { files: number; lines: number; lookups: number }
23
24const FRONTEND = /\.(tsx|jsx|vue|svelte|css|scss|sass|less|html)$/i
25const TEMP_ROOTS = ['/tmp/', '/var/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/']
26
27/** `dojo:sanji` for UI files, `dojo:senku` for everything else. */
28export function delegateFor(path: string): 'dojo:sanji' | 'dojo:senku' {
29 return FRONTEND.test(path) ? 'dojo:sanji' : 'dojo:senku'
30}
31
32/** Scratch space is not the project: system temp dirs (and `tmpDirs`) and any `/scratchpad/`. */
33export function isExemptPath(path: string, tmpDirs: readonly string[] = []): boolean {
34 if (path.includes('/scratchpad/')) return true
35 const roots = [...TEMP_ROOTS, ...tmpDirs.filter(d => d !== '').map(d => (d.endsWith('/') ? d : `${d}/`))]
36 return roots.some(r => path.startsWith(r))
37}
38
39export function countLines(text: string): number {
40 if (text === '') return 0
41 const n = text.split('\n').length
42 return text.endsWith('\n') ? n - 1 : n
43}
44
45export type RoleCall =
46 | { kind: 'edit'; file: string; lines: number | null }
47 | { kind: 'lookup' }
48
49/** What the main thread's tool call spends, or null for a call the role guard does not meter. */
50export function roleCall(tool: string, args: Record<string, unknown>): RoleCall | null {
51 if (tool === 'WebSearch' || tool === 'WebFetch') return { kind: 'lookup' }
52 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
53 const file = tool === 'NotebookEdit' ? args.notebook_path : args.file_path
54 if (typeof file !== 'string' || file === '') return null
55 const lines = tool === 'Edit' && typeof args.new_string === 'string' ? countLines(args.new_string) : null
56 return { kind: 'edit', file, lines }
57 }
58 return null
59}
60
61export type Charge = {
62 budget: Budget
63 /** True when this call newly spent budget (a file not yet edited this turn, or a lookup). */
64 added: boolean
65 denied: null | { reason: string; toast: 'backend' | 'frontend' | 'lookups' }
66}
67
68/** Pure: spend one call from the turn's budget, or say why it is over. */
69export function charge(b: Budget, call: RoleCall, limits: RoleLimits, tmpDirs: readonly string[] = []): Charge {
70 if (call.kind === 'lookup') {
71 if (b.lookups >= limits.lookups) {
72 return {
73 budget: b,
74 added: false,
75 denied: {
76 reason: `Inline lookup budget reached: ${b.lookups} WebSearch/WebFetch calls already made this turn (limit ${limits.lookups}). Delegate multi-source research to dojo:l, or a single-fact lookup to dojo:light, with a delegation brief.`,
77 toast: 'lookups',
78 },
79 }
80 }
81 return { budget: { ...b, lookups: b.lookups + 1 }, added: true, denied: null }
82 }
83 if (isExemptPath(call.file, tmpDirs)) return { budget: b, added: false, denied: null }
84 const agent = delegateFor(call.file)
85 const toast = agent === 'dojo:sanji' ? 'frontend' : 'backend'
86 if (call.lines !== null && call.lines > limits.lines) {
87 return {
88 budget: b,
89 added: false,
90 denied: {
91 reason: `This Edit writes ${call.lines} lines (inline limit ${limits.lines}). Delegate it to ${agent} with a delegation brief instead of editing ${call.file} yourself.`,
92 toast,
93 },
94 }
95 }
96 if (b.files.includes(call.file)) return { budget: b, added: false, denied: null }
97 if (b.files.length >= limits.files) {
98 return {
99 budget: b,
100 added: false,
101 denied: {
102 reason: `Inline edit budget reached: ${b.files.length} distinct files already edited this turn (limit ${limits.files}). Delegate the change to ${agent} with a delegation brief instead of editing ${call.file} yourself.`,
103 toast,
104 },
105 }
106 }
107 return { budget: { ...b, files: [...b.files, call.file] }, added: true, denied: null }
108}
109
110/** Pure: give back what a charge spent, for a call that core then refused. */
111export function refund(b: Budget, call: RoleCall): Budget {
112 if (call.kind === 'lookup') return { ...b, lookups: Math.max(0, b.lookups - 1) }
113 return { ...b, files: b.files.filter(f => f !== call.file) }
114}
115
116function count(options: Options, name: string, fallback: number): number {
117 const v = options[name]
118 return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : fallback
119}
120
121function limitsOf(options: Options): RoleLimits {
122 return {
123 files: count(options, 'inlineEditFiles', 2),
124 lines: count(options, 'inlineEditLines', 6),
125 lookups: count(options, 'inlineLookups', 3),
126 }
127}
128
129async function tmpDirs($: Engine): Promise<string[]> {
130 try {
131 const dir = await $.env.get('TMPDIR')
132 return typeof dir === 'string' && dir !== '' ? [dir] : []
133 } catch {
134 return []
135 }
136}
137
138async function newTurnBudget($: Engine) {
139 const n = (await read($, turn)) ?? 0
140 if ((await read($, budgetTurn)) === n) return
141 await update($, budget, () => EMPTY_BUDGET)
142 await update($, budgetTurn, () => n)
143}
144
145export const installRoleGuard: Installer = (on, options) => {
146 // Role guard on the main thread: caps inline edits and web lookups per turn in a dojo session.
147 on('tool.call', { tool: METERED_TOOLS }, async ($, e, next) => {
148 let spent: RoleCall | null = null
149 try {
150 if (e.agentId === undefined && isOn(options, 'roleGuard') && ((await read($, strategist)) ?? strategistKey(await $.env.get('DOJO_STRATEGIST'))) !== null) {
151 const call = roleCall(String(e.tool), e as unknown as Record<string, unknown>)
152 if (call !== null) {
153 await newTurnBudget($)
154 const limits = limitsOf(options)
155 const tmp = call.kind === 'edit' ? await tmpDirs($) : []
156 let result = charge(EMPTY_BUDGET, call, limits, tmp)
157 await update($, budget, b => {
158 result = charge(b, call, limits, tmp)
159 return result.budget
160 })
161 if (result.denied !== null) {
162 try {
163 $.ui.toast(toastLine(result.denied.toast))
164 } catch {
165 // a toast is never worth failing a hook
166 }
167 return { deny: result.denied.reason }
168 }
169 if (result.added) spent = call
170 }
171 }
172 } catch {
173 // fail open: the call goes through unmetered
174 }
175
176 const ran = await next(e)
177
178 try {
179 if (ran.deny !== undefined && spent !== null) {
180 const call = spent
181 await update($, budget, b => refund(b, call))
182 }
183 } catch {
184 // bookkeeping only
185 }
186 return ran
187 })
188}
189mods/lib/persona.ts 117 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine } from 'claude-code'
3
4import type { Installer } from './options'
5import { CREW, STRATEGISTS } from '../theme'
6
7// The one copy of strategist detection. Every feature that needs to know who is on duty reads it here.
8
9export const DOJO_PREFIX = 'dojo:'
10const ON_DUTY = /dojo: on duty: ([A-Za-z]+)/g
11
12/** The strategist persona key (`shikamaru`, `lelouch`) read off the session, or null. */
13export const strategist = atom({ plugin: 'dojo', key: 'strategist' } as const, null)
14/** The persona a /dojo-duty command picked; it outranks every automatic on-duty line after it. */
15export const chosen = atom({ plugin: 'dojo', key: 'chosen' } as const, null)
16/** Prompts submitted so far; rotates the verb pools and bounds the transcript scan. */
17export const turn = atom({ plugin: 'dojo', key: 'turn' } as const, 0)
18
19const TALK_DOORS = new Set(['prompt', 'response', 'tool-result'])
20// The transcript fallback only looks during the first prompts: later it would cost a read per prompt.
21const SCAN_PROMPTS = 3
22
23const has = (table: Record<string, unknown>, key: string) => Object.prototype.hasOwnProperty.call(table, key)
24
25/** `dojo:levi` -> `levi`; anything that is not a dojo crew type -> null (the strategist is no crew). */
26export function crewKey(type: string): string | null {
27 if (!type.startsWith(DOJO_PREFIX)) return null
28 const key = type.slice(DOJO_PREFIX.length)
29 return has(CREW, key) ? key : null
30}
31
32export function isDojoType(type: string): boolean {
33 return type.startsWith(DOJO_PREFIX)
34}
35
36/** Persona key from the context line `dojo: on duty: <Name>` (the last one in the text), or null. */
37export function parseOnDuty(text: string): string | null {
38 let found: string | null = null
39 for (const m of text.matchAll(ON_DUTY)) {
40 const name = m[1]?.toLowerCase()
41 if (name !== undefined && has(STRATEGISTS, name)) found = name
42 }
43 return found
44}
45
46export function strategistKey(raw: string | undefined | null): string | null {
47 const key = (raw ?? '').trim().toLowerCase()
48 return has(STRATEGISTS, key) ? key : null
49}
50
51export function strategistName(key: string | null): string | null {
52 return key === null ? null : (STRATEGISTS[key]?.name ?? null)
53}
54
55/** Every known `dojo: on duty: <Name>` line in `text` renamed to the persona `key`. */
56export function reassertOnDuty(text: string, key: string): string {
57 const name = STRATEGISTS[key]?.name
58 if (name === undefined) return text
59 return text.replace(ON_DUTY, (line, found: string) => (has(STRATEGISTS, found.toLowerCase()) ? `dojo: on duty: ${name}` : line))
60}
61
62async function scanForPersona($: Engine) {
63 if ((await read($, strategist)) !== null || (await read($, turn)) > SCAN_PROMPTS) return
64 try {
65 const messages = await $.session.messages()
66 if ('deny' in messages) return
67 for (const m of [...messages].reverse()) {
68 const found = m.role === 'user' ? parseOnDuty(m.text) : null
69 if (found !== null) {
70 await update($, strategist, () => found)
71 return
72 }
73 }
74 } catch {
75 // no transcript to read: the env var or no dojo session stands
76 }
77}
78
79export const installPersona: Installer = on => {
80 // The dojo SessionStart hook (and /dojo-duty) inject `dojo: on duty: <Name>` as context. The most recent
81 // line wins, so every appended row updates the persona, except that a /dojo-duty choice outranks the hook:
82 // SessionStart fires again on resume and compact and would announce the original persona over the switch,
83 // so such a line is rewritten to the chosen persona before the model reads it.
84 on('session.append', async ($, e, next) => {
85 let out = e
86 try {
87 if (e.agentId === undefined && !TALK_DOORS.has(e.door)) {
88 const picked = (await read($, chosen)) ?? null
89 const content = e.message.content.map(block => {
90 if (block.type !== 'text' || typeof block.text !== 'string') return block
91 const text = picked === null ? block.text : reassertOnDuty(block.text, picked)
92 return text === block.text ? block : { ...block, text }
93 })
94 for (const block of content) {
95 const found = block.type === 'text' && typeof block.text === 'string' ? parseOnDuty(block.text) : null
96 if (found !== null) await update($, strategist, () => found)
97 }
98 if (content.some((block, i) => block !== e.message.content[i])) out = { ...e, message: { ...e.message, content } }
99 }
100 } catch {
101 out = e
102 }
103 return next(out)
104 })
105
106 // A new prompt is a new turn.
107 on('prompt.submit', async ($, e, next) => {
108 try {
109 await update($, turn, n => n + 1)
110 await scanForPersona($)
111 } catch {
112 // bookkeeping only
113 }
114 return next(e)
115 })
116}
117mods/visuals/chrome.tsx 504 lines1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface as Engine, RenderElement, TextProps } from 'claude-code'
3
4import { isOn } from '../lib/options'
5import type { Installer, Options } from '../lib/options'
6import { crewKey, strategistKey } from '../lib/persona'
7import { summarizeTool } from '../lib/text'
8import { CREW, FELL_EMOJI, NEUTRAL, STRATEGISTS, WORDS } from '../theme'
9import type { Theme, Words } from '../theme'
10import type { DojoBand, DojoCrewRow, DojoRosterEntry, DojoUsage } from '../state'
11import { CUE_GAP_MS, DEPLOY_MORE, SOUNDS } from './chrome-art'
12import type { Cue } from './chrome-art'
13import {
14 deployLine,
15 framesOf,
16 hintTail,
17 parseSays,
18 planBand,
19 planUsage,
20 rosterLines,
21 rosterText,
22 snapshotUsage,
23 themedModes,
24 turnWord,
25 verbAt,
26} from './chrome-lib'
27import type { Line, Seg } from './chrome-lib'
28
29export { bandLine } from './chrome-lib'
30export type { RowInput } from './chrome-lib'
31
32// The module scan reads an atom only from a const of the file that uses it; the {plugin, key} pair is the shared name.
33const strategist = atom({ plugin: 'dojo', key: 'strategist' } as const, null)
34const turn = atom({ plugin: 'dojo', key: 'turn' } as const, 0)
35const voice = atom({ plugin: 'dojo', key: 'voice' } as const, null)
36const band = atom({ plugin: 'dojo', key: 'band' } as const, { frame: 0, rows: [] } as DojoBand)
37const tools = atom({ plugin: 'dojo', key: 'tools' } as const, {} as Record<string, string>)
38const roster = atom({ plugin: 'dojo', key: 'roster' } as const, {} as Record<string, DojoRosterEntry>)
39const pulse = atom({ plugin: 'dojo', key: 'pulse' } as const, 0)
40const cueAt = atom({ plugin: 'dojo', key: 'cueAt' } as const, null as number | null)
41const usage = atom({ plugin: 'dojo', key: 'usage' } as const, null as DojoUsage | null)
42
43const TICK_MS = 250
44const PULSE_MS = 400
45// the spinner walks on to its next verb every this many pulses
46const VERB_EVERY = 12
47// pulses with no spinner drawn before the pulse timer stops itself
48const PULSE_IDLE = 3
49const PANE_ID = 'dojo-roster'
50const TERMINAL = new Set(['completed', 'failed', 'killed'])
51
52// Module variables die with a hot reload; everything a drawing reads lives in $.state.
53const seen = new Map<string, string>()
54let timer: { cancel: () => void } | undefined
55let pulseTimer: { cancel: () => void } | undefined
56let spinnerDraws = 0
57
58const wordsOf = (key: string): Words => WORDS[key] as Words
59
60/** The strategist's theme and words for `key`; the neutral ones when `neutral` is on and no persona is known. */
61export function strategistTheme(key: string | null, neutral: boolean): (Theme & Words) | null {
62 const theme = key !== null ? STRATEGISTS[key] : neutral ? NEUTRAL : undefined
63 return theme === undefined ? null : { ...theme, ...wordsOf(theme.key) }
64}
65
66/** The persona on duty (`shikamaru`, `lelouch`): what a command or the transcript said, else DOJO_STRATEGIST. */
67async function personaKey($: Engine): Promise<string | null> {
68 const known = await read($, strategist)
69 if (known !== null) return known
70 try {
71 return strategistKey(await $.env.get('DOJO_STRATEGIST'))
72 } catch {
73 return null
74 }
75}
76
77async function mainTheme($: Engine, options: Options) {
78 return strategistTheme(await personaKey($), isOn(options, 'neutralMainTheme'))
79}
80
81const textProps = (s: Seg) => ({
82 ...(s.color === undefined ? {} : { color: s.color }),
83 ...(s.bold === true ? { bold: true } : {}),
84 ...(s.dim === true ? { dimColor: true } : {}),
85 ...(s.italic === true ? { italic: true } : {}),
86})
87
88/** One planned line as a Text: flat when it has one segment, nested colored runs otherwise. Always cut at the row's end. */
89function drawLine(Text: ElementConstructor<TextProps>, line: Line, key: string): RenderElement {
90 const only = line.length === 1 ? line[0] : undefined
91 if (only !== undefined) {
92 return (
93 <Text key={key} wrap="truncate-end" {...textProps(only)}>
94 {only.text}
95 </Text>
96 )
97 }
98 return (
99 <Text key={key} wrap="truncate-end">
100 {line.map((s, i) => (
101 <Text key={`${key}-${i}`} {...textProps(s)}>
102 {s.text}
103 </Text>
104 ))}
105 </Text>
106 )
107}
108
109/**
110 * Plays a cue unless one played within the gap. The stamp is claimed inside `update`, which retries on a version
111 * miss, so parallel callers (each in an environment of its own) cannot both pass the gate.
112 */
113async function cue($: Engine, options: Options, kind: Cue): Promise<void> {
114 if (!isOn(options, 'sounds')) return
115 try {
116 const now = await $.clock.now()
117 let isClaimed = false
118 await update($, cueAt, last => {
119 isClaimed = !(last !== null && now >= last && now - last < CUE_GAP_MS)
120 return isClaimed ? now : last
121 })
122 if (isClaimed) $.audio.play({ asset: SOUNDS[kind] }).catch(() => {})
123 } catch {
124 // no clock or no player: the cue is a garnish
125 }
126}
127
128/** Keeps the spinner animating while one is on screen, and stops once none has been drawn for a few pulses. */
129function startPulse($: Engine) {
130 if (pulseTimer !== undefined) return
131 let lastDraws = -1
132 let idle = 0
133 pulseTimer = $.clock.every(PULSE_MS, () => {
134 if (spinnerDraws === lastDraws) {
135 idle += 1
136 if (idle >= PULSE_IDLE) {
137 pulseTimer?.cancel()
138 pulseTimer = undefined
139 return
140 }
141 } else {
142 idle = 0
143 lastDraws = spinnerDraws
144 }
145 update($, pulse, p => p + 1).catch(() => {})
146 })
147}
148
149type Group = { running: number; last?: 'returned' | 'fell'; task: string }
150
151/** The roster after folding in what the agent list says now; the same object when nothing changed. */
152function foldRoster(prev: Record<string, DojoRosterEntry>, groups: ReadonlyMap<string, Group>): Record<string, DojoRosterEntry> {
153 let out = prev
154 for (const [key, g] of groups) {
155 const status = g.running > 0 ? 'running' : g.last
156 if (status === undefined) continue
157 const before = prev[key]
158 if (before !== undefined && before.status === status && (status !== 'running' || before.task === g.task)) continue
159 out = {
160 ...out,
161 [key]: {
162 status,
163 count: before?.count ?? 1,
164 task: g.running > 0 ? g.task : (before?.task ?? ''),
165 ...(before?.says === undefined ? {} : { says: before.says }),
166 },
167 }
168 }
169 return out
170}
171
172async function tick($: Engine, options: Options) {
173 const crew = (await $.agent.list()).filter(a => crewKey(a.type) !== null)
174 const rows: DojoCrewRow[] = []
175 const groups = new Map<string, Group>()
176 const finished: { key: string; ok: boolean }[] = []
177 for (const a of crew) {
178 const key = crewKey(a.type)
179 if (key === null || CREW[key] === undefined) continue
180 const before = seen.get(a.id)
181 seen.set(a.id, a.status)
182 const g = groups.get(key) ?? { running: 0, task: '' }
183 groups.set(key, g)
184 if (TERMINAL.has(a.status)) {
185 g.last = a.status === 'completed' ? 'returned' : 'fell'
186 if (before !== undefined && !TERMINAL.has(before)) finished.push({ key, ok: a.status === 'completed' })
187 } else {
188 g.running += 1
189 g.task = a.description
190 rows.push({ id: a.id, key, description: a.description })
191 }
192 }
193
194 const keepRoster = isOn(options, 'rosterPane') || isOn(options, 'toasts')
195 let table: Record<string, DojoRosterEntry> = {}
196 if (keepRoster) {
197 const now = await read($, roster)
198 table = foldRoster(now, groups) === now ? now : await update($, roster, r => foldRoster(r, groups))
199 }
200
201 if (isOn(options, 'toasts')) {
202 for (const f of finished) {
203 const c = CREW[f.key]
204 if (c === undefined) continue
205 const says = table[f.key]?.says
206 $.ui.toast(
207 f.ok ? `${c.emoji} ${c.name} returned${says === undefined ? '' : ` — "${says}"`}` : `${FELL_EMOJI} ${c.name} fell`,
208 )
209 }
210 }
211 if (finished.length > 0) await cue($, options, finished.some(f => !f.ok) ? 'fall' : 'return')
212
213 if (rows.length === 0) {
214 timer?.cancel()
215 timer = undefined
216 }
217 if (!isOn(options, 'band')) return
218 await update($, band, b =>
219 rows.length === 0 && b.rows.length === 0 ? b : { frame: rows.length === 0 ? 0 : b.frame + 1, rows },
220 )
221}
222
223function ensureTick($: Engine, options: Options) {
224 const wanted = ['band', 'toasts', 'sounds', 'rosterPane'].some(name => isOn(options, name))
225 if (timer !== undefined || !wanted) return
226 timer = $.clock.every(TICK_MS, () => {
227 tick($, options).catch(() => {})
228 })
229}
230
231/** What the roster pane (and its text fallback) is drawn from. */
232async function rosterInput($: Engine, columns: number) {
233 return {
234 persona: await personaKey($),
235 voice: await read($, voice),
236 entries: await read($, roster),
237 frame: (await read($, band)).frame,
238 columns,
239 }
240}
241
242export const installChrome: Installer = (on, options) => {
243 // Deploy toast, sound and band row for a dojo subagent that started.
244 on('agent.spawn', { subagentType: /^dojo:(?!strategist$)/ }, async ($, e, next) => {
245 const ran = await next(e)
246 const key = crewKey(e.subagentType)
247 if (ran.deny === undefined && ran.agentId !== undefined && key !== null) {
248 try {
249 seen.set(ran.agentId, 'running')
250 const c = CREW[key]
251 let nth = 1
252 if (isOn(options, 'rosterPane') || isOn(options, 'toasts')) {
253 const table = await update($, roster, r => ({
254 ...r,
255 [key]: { status: 'running' as const, count: (r[key]?.count ?? 0) + 1, task: e.description },
256 }))
257 nth = table[key]?.count ?? 1
258 }
259 if (c !== undefined && isOn(options, 'toasts')) {
260 $.ui.toast(`${c.emoji} ${c.name} deployed — "${deployLine(key, nth, DEPLOY_MORE[key] ?? [])}"`)
261 }
262 await cue($, options, 'deploy')
263 ensureTick($, options)
264 if (isOn(options, 'band')) {
265 const id = ran.agentId
266 await update($, band, b => ({
267 ...b,
268 rows: [...b.rows.filter(r => r.id !== id), { id, key, description: e.description }],
269 }))
270 }
271 } catch {
272 // chrome only: the subagent has started either way
273 }
274 }
275 return ran
276 })
277
278 // What a crew member said last: the `Says:` line of its report.
279 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
280 const ran = await next(e)
281 if (e.agentId !== undefined && (isOn(options, 'rosterPane') || isOn(options, 'toasts'))) {
282 try {
283 const says = parseSays(e.answer)
284 const id = e.agentId
285 const agent = (await $.agent.list()).find(a => a.id === id)
286 const key = agent === undefined ? null : crewKey(agent.type)
287 if (says !== undefined && key !== null) {
288 await update($, roster, r => ({
289 ...r,
290 [key]: { status: 'running' as const, count: 1, task: '', ...r[key], says },
291 }))
292 }
293 } catch {
294 // the line is a garnish
295 }
296 }
297 return ran
298 })
299
300 on('tool.call', async ($, e, next) => {
301 if (e.agentId !== undefined && isOn(options, 'band')) {
302 const id = e.agentId
303 const line = summarizeTool(String(e.tool), e as unknown as Record<string, unknown>)
304 await update($, tools, t => ({ ...t, [id]: line }))
305 ensureTick($, options)
306 }
307 return next(e)
308 })
309
310 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
311 // a message (compacting, rewinding) is the engine's own state line: leave it alone
312 if (!isOn(options, 'spinners') || e.props.message !== null) return next(e)
313 let props = e.props
314 try {
315 const n = (await read($, turn)) ?? 0
316 const beat = (await read($, pulse)) ?? 0
317 spinnerDraws += 1
318 try {
319 startPulse($)
320 } catch {
321 // no clock to animate with: the first verb stands still
322 }
323 const step = Math.floor(beat / VERB_EVERY)
324 const agent = (await $.agent.list()).find(a => a.id === e.requestId)
325 const key = agent === undefined ? null : crewKey(agent.type)
326 const crew = key === null ? undefined : CREW[key]
327 let word: string | undefined
328 let owner: string | undefined
329 if (key !== null && crew !== undefined) {
330 owner = key
331 word = `${crew.emoji} ${verbAt(key, n, e.requestId, step)}`
332 } else if (agent === undefined) {
333 const theme = await mainTheme($, options)
334 if (theme !== null) {
335 owner = theme.key
336 word = `${theme.emoji} ${verbAt(theme.key, n, '', step)}`
337 }
338 }
339 if (word !== undefined && owner !== undefined) {
340 const frames = framesOf(owner)
341 // the animated glyph rides after the ellipsis; the terminal is the surface that documents a suffix rewrite
342 props =
343 e.surface === 'terminal'
344 ? { ...e.props, word, suffix: `${e.props.suffix} ${frames[beat % frames.length] ?? ''}`.trimEnd() }
345 : { ...e.props, word }
346 }
347 } catch {
348 props = e.props
349 }
350 return next({ ...e, props } as typeof e)
351 })
352
353 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
354 if (!isOn(options, 'turnFlair')) return next(e)
355 let word: string | undefined
356 try {
357 const theme = await mainTheme($, options)
358 if (theme?.done !== undefined) {
359 const n = (await read($, turn)) ?? 0
360 word = turnWord(theme.key, theme.done, e.props.durationMs, n)
361 }
362 } catch {
363 word = undefined
364 }
365 return word === undefined ? next(e) : next({ ...e, props: { ...e.props, word } })
366 })
367
368 // The latest session figures for the usage line; the engine pushes them, the band hook below draws them.
369 on('session.measure', async ($, e, next) => {
370 const ran = await next(e)
371 if (isOn(options, 'usageLine')) {
372 try {
373 const figures = snapshotUsage(e)
374 await update($, usage, () => figures)
375 } catch {
376 // the usage line is a garnish
377 }
378 }
379 return ran
380 })
381
382 // One usage row first (when on and known), then the crew band below it.
383 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
384 const wantBand = isOn(options, 'band')
385 const wantUsage = isOn(options, 'usageLine')
386 if ((!wantBand && !wantUsage) || e.props.hasSurvey) return next(e)
387 let tree: RenderElement | undefined
388 try {
389 const persona = await personaKey($)
390 const columns = e.props.bodyColumns
391 const bandState = wantBand ? await read($, band) : undefined
392 const crewOut = bandState !== undefined && bandState.rows.length > 0
393 // with a single row to spare the crew band keeps it
394 let usageRow: Line | undefined
395 if (wantUsage && !(crewOut && e.props.maxRows < 2)) {
396 try {
397 usageRow = planUsage({ persona, usage: (await read($, usage)) ?? snapshotUsage(await $.session.usage()), columns })
398 } catch {
399 usageRow = undefined
400 }
401 }
402 let lines: Line[] = usageRow === undefined ? [] : [usageRow]
403 if (bandState !== undefined && crewOut) {
404 const latest = await read($, tools)
405 lines = [
406 ...lines,
407 ...planBand({
408 persona,
409 rows: bandState.rows.map(r => ({ key: r.key, description: r.description, tool: latest[r.id] })),
410 frame: bandState.frame,
411 columns,
412 maxRows: e.props.maxRows - lines.length,
413 }),
414 ]
415 }
416 if (lines.length > 0) {
417 const { Box, Text } = $.ui.resolve(e)
418 tree = <Box flexDirection="column">{lines.map((l, i) => drawLine(Text, l, `band-${i}`))}</Box>
419 }
420 } catch {
421 tree = undefined
422 }
423 return tree ?? next(e)
424 })
425
426 // The prompt hint keeps the engine's line (its pills stay live); the strategist adds a dim line of its own after it.
427 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
428 if (!isOn(options, 'promptChrome') || e.surface !== 'terminal') return next(e)
429 let tail: string | undefined
430 try {
431 const mine = hintTail(await personaKey($), (await read($, turn)) ?? 0, e.props.isWorking, e.props.isDraft)
432 tail = mine === undefined ? undefined : e.props.tail === undefined ? mine : `${e.props.tail} ${mine}`
433 } catch {
434 tail = undefined
435 }
436 return tail === undefined ? next(e) : next({ ...e, props: { ...e.props, tail } })
437 })
438
439 // Each footer mode keeps its own name; the strategist's label sits beside it and its badge follows.
440 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
441 if (!isOn(options, 'promptChrome')) return next(e)
442 let modes: string[] | undefined
443 try {
444 modes = themedModes(await personaKey($), e.props.modes)
445 } catch {
446 modes = undefined
447 }
448 return modes === undefined ? next(e) : next({ ...e, props: { ...e.props, modes } })
449 })
450
451 // /dojo opens the roster pane. Commands can only be registered once an interactive session binds.
452 on('session.start', { isInteractive: true }, async ($, e, next) => {
453 if (isOn(options, 'rosterPane')) {
454 try {
455 await $.command.register({ name: 'dojo', description: 'Open the dojo roster: strategist on duty, crew, models and status' })
456 } catch {
457 // a name taken or refused: the other commands still stand
458 }
459 }
460 return next(e)
461 })
462
463 on('command.run', { command: 'dojo' }, async ($, e, next) => {
464 if (!isOn(options, 'rosterPane')) return next(e)
465 try {
466 const opened = await $.ui.open({ id: PANE_ID, title: 'Dojo roster', focus: true, closeOnEscape: true, rows: 22 })
467 if (opened.isPlaced) return {}
468 return { text: rosterText(await rosterInput($, e.presentation.columns)) }
469 } catch {
470 return next(e)
471 }
472 })
473
474 on('ui.render', { component: 'Pane', requestId: 'dojo-roster' }, async ($, e, next) => {
475 if (!isOn(options, 'rosterPane')) return next(e)
476 let tree: RenderElement | undefined
477 try {
478 const lines = rosterLines(await rosterInput($, e.props.bodyColumns))
479 const { Box, Text, Button } = $.ui.resolve(e)
480 tree = (
481 <Box flexDirection="column">
482 {lines.map((l, i) => drawLine(Text, l, `roster-${i}`))}
483 <Box marginTop={1} gap={1}>
484 <Button
485 key="close"
486 label="close"
487 hotkey="q"
488 onPress={() => {
489 $.ui.close({ id: PANE_ID }).catch(() => {})
490 }}
491 />
492 <Text dimColor wrap="truncate-end">
493 esc closes
494 </Text>
495 </Box>
496 </Box>
497 )
498 } catch {
499 tree = undefined
500 }
501 return tree ?? next(e)
502 })
503}
504mods/visuals/transcript.tsx 504 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, EngineInterface as Engine, ElementConstructor, RenderNode, TextProps } from 'claude-code'
3
4import { isOn } from '../lib/options'
5import type { Installer, Options } from '../lib/options'
6import { crewKey, strategistKey } from '../lib/persona'
7import { CREW, FELL_EMOJI, NEUTRAL, STRATEGISTS } from '../theme'
8import type { Theme } from '../theme'
9import { ASKS, BUBBLES, COMMAND_TITLES, DEPLOY_LINES, ERROR_MARKS, FALLEN_LINES, MOTIFS, RETURN_LINES } from './transcript-art'
10import {
11 FALLEN_STATUSES,
12 WIDE,
13 agentFacts,
14 bannerRows,
15 cells,
16 columnsOf,
17 crewOfInput,
18 descriptionOf,
19 dojoCommand,
20 dutyNameOf,
21 extractSays,
22 line,
23 remember,
24 rule,
25 statsLine,
26} from './transcript-lib'
27
28// Transcript makeover (userConfig `transcriptTheme` for frames, badges, cards and bubbles; `banners` for ASCII art).
29// Decoration only: every hook asks the engine for its own drawing first and wraps it. Whatever happens here,
30// the engine's drawing is what comes back.
31
32// The module scan reads an atom only from a const of the file that uses it; the {plugin, key} pair is the shared name.
33const strategist = atom({ plugin: 'dojo', key: 'strategist' } as const, null)
34/** tool_use_id of a call a subagent made -> its crew key, or `-` for any other subagent. The main thread has no entry. */
35const callers = atom({ plugin: 'dojo', key: 'callers' } as const, {} as Record<string, string>)
36/** tool_use_id of an Agent call -> the crew key it spawns, or `-` for any other agent type. */
37const spawns = atom({ plugin: 'dojo', key: 'spawns' } as const, {} as Record<string, string>)
38
39type UI = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
40type CardState = 'deployed' | 'returned' | 'fell' | 'recalled' | 'background'
41
42const motifOf = (key: string) => MOTIFS[key] ?? (MOTIFS.strategist as (typeof MOTIFS)[string])
43
44async function mainTheme($: Engine, options: Options): Promise<Theme | null> {
45 const key = (await read($, strategist)) ?? strategistKey(await $.env.get('DOJO_STRATEGIST'))
46 if (key !== null) return STRATEGISTS[key] ?? null
47 return isOn(options, 'neutralMainTheme') ? NEUTRAL : null
48}
49
50/** Who made tool call `id`: a crew member's theme, the strategist's, or null for a subagent that is no dojo crew. */
51async function callerTheme($: Engine, options: Options, id: string): Promise<Theme | null> {
52 const lane = (await read($, callers))[id]
53 if (lane === undefined) return mainTheme($, options)
54 return lane === '-' ? null : (CREW[lane] ?? null)
55}
56
57/** A one-cell-wide stripe in `color`, as tall as what stands beside it. */
58function stripe({ Box }: UI, color: string) {
59 return <Box width={1} flexShrink={0} marginRight={1} backgroundColor={color} />
60}
61
62/** `inner` beside a stripe. */
63function striped(ui: UI, color: string, inner: RenderNode) {
64 const { Box } = ui
65 return (
66 <Box flexDirection="row">
67 {stripe(ui, color)}
68 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
69 {inner}
70 </Box>
71 </Box>
72 )
73}
74
75/** `inner` after a small badge on the same row. */
76function badged(ui: UI, who: readonly Theme[], cols: number, errored: boolean, inner: RenderNode) {
77 const { Box, Text } = ui
78 const first = who[0]
79 if (first === undefined) return <Box>{inner}</Box>
80 const label = who.map(t => (cols >= WIDE ? `${t.emoji} ${t.name}` : t.emoji)).join(' ') + (errored ? ' ✗' : '')
81 return (
82 <Box flexDirection="row">
83 <Box flexShrink={0} marginRight={1}>
84 <Text color={who.length === 1 ? first.color : undefined} bold wrap="truncate-end">
85 {label}
86 </Text>
87 </Box>
88 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
89 {inner}
90 </Box>
91 </Box>
92 )
93}
94
95/** A decorative mark above an errored tool result; the error text itself is the engine's. */
96function errorMark({ Text }: UI, who: Theme) {
97 const mark = ERROR_MARKS[who.key] ?? ERROR_MARKS.strategist
98 return (
99 <Text color="error" wrap="truncate-end">
100 {`${who.emoji} ✗ ${mark}`}
101 </Text>
102 )
103}
104
105type Card = {
106 theme: Theme
107 state: CardState
108 description: string
109 cols: number
110 banners: boolean
111 salt: string
112 stats?: string
113}
114
115const STATE_LABEL: Record<CardState, string> = {
116 deployed: '▸ deployed',
117 returned: '✓ returned',
118 fell: '✗ fell',
119 recalled: '↩ recalled',
120 background: '… running in background',
121}
122
123function cardLine(c: Card): string {
124 const pool = c.state === 'deployed' ? DEPLOY_LINES : c.state === 'returned' ? RETURN_LINES : c.state === 'fell' ? FALLEN_LINES : undefined
125 return line(pool?.[c.theme.key], c.salt)
126}
127
128/** A crew member's card: banner, header, task, signature. One compact line when the width is short. */
129function crewCard(ui: UI, c: Card) {
130 const { Box, Text } = ui
131 const t = c.theme
132 const fell = c.state === 'fell'
133 const emoji = fell ? FELL_EMOJI : t.emoji
134 const label = STATE_LABEL[c.state]
135 if (c.cols < WIDE) {
136 const task = c.description === '' ? '' : ` · ${c.description}`
137 return (
138 <Text color={t.color} bold={!fell} dimColor={fell} wrap="truncate-end">
139 {`${emoji} ${t.name} · ${label}${task}`}
140 </Text>
141 )
142 }
143 const width = Math.min(c.cols - 2, 46)
144 const art = c.banners ? bannerRows(t.key, width - 4) : []
145 const said = cardLine(c)
146 return (
147 <Box flexDirection="column" borderStyle="round" borderColor={fell ? 'error' : t.color} borderDimColor={fell} paddingX={1} width={width}>
148 {art.map((row, i) => (
149 <Text key={`art-${i}`} color={t.color} dimColor={fell} wrap="truncate-end">
150 {row}
151 </Text>
152 ))}
153 <Text wrap="truncate-end">
154 <Text color={t.color} bold>
155 {`${emoji} ${t.name}`}
156 </Text>
157 <Text dimColor>{` · ${t.role} · `}</Text>
158 <Text color={fell ? 'error' : t.color}>{label}</Text>
159 </Text>
160 {c.description === '' ? null : <Text wrap="wrap">{`task ${c.description}`}</Text>}
161 {c.stats === undefined || c.stats === '' ? null : (
162 <Text dimColor wrap="truncate-end">
163 {c.stats}
164 </Text>
165 )}
166 {said === '' ? null : (
167 <Text color={t.color} italic dimColor={fell} wrap="wrap">
168 {`“${said}”`}
169 </Text>
170 )}
171 </Box>
172 )
173}
174
175/** The `Says:` line as a speech bubble in the character's colour. */
176function bubble(ui: UI, t: Theme, says: string, cols: number) {
177 const { Box, Text } = ui
178 if (cols < WIDE) {
179 return (
180 <Text color={t.color} italic wrap="wrap">
181 {`${t.emoji} “${says}”`}
182 </Text>
183 )
184 }
185 const shape = BUBBLES[t.key] ?? { borderStyle: 'round', tail: '<' }
186 const prefix = `${t.emoji} ${shape.tail}`
187 const avail = cols - cells(prefix) - 2
188 const width = Math.max(10, Math.min(avail, cells(says) + 4))
189 return (
190 <Box flexDirection="row" alignItems="center">
191 <Box flexShrink={0}>
192 <Text color={t.color}>{prefix}</Text>
193 </Box>
194 <Box borderStyle={shape.borderStyle} borderColor={t.color} paddingX={1} width={width}>
195 <Text color={t.color} italic wrap="wrap">
196 {says}
197 </Text>
198 </Box>
199 </Box>
200 )
201}
202
203export const installTranscript: Installer = (on, options) => {
204 const themed = () => isOn(options, 'transcriptTheme')
205 const bannersOn = () => isOn(options, 'banners')
206
207 // Remember which subagent made each tool call, so a row can be badged by the crew member behind it.
208 on('tool.call', { agentId: /./ }, async ($, e, next) => {
209 if (themed()) {
210 try {
211 const id = String(e.tool_use_id)
212 const loop = String(e.agentId)
213 const found = (await $.agent.list()).find(a => a.id === loop)
214 const key = found === undefined ? null : crewKey(found.type)
215 await update($, callers, table => remember(table, id, key ?? '-'))
216 } catch {
217 // bookkeeping only
218 }
219 }
220 return next(e)
221 })
222
223 // Remember which agent type each Agent call spawns, so its result can be drawn as that character's return card.
224 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
225 if (themed()) {
226 try {
227 const type = (e as unknown as { subagent_type?: unknown }).subagent_type
228 const key = typeof type === 'string' ? crewKey(type) : null
229 await update($, spawns, table => remember(table, String(e.tool_use_id), key ?? '-'))
230 } catch {
231 // bookkeeping only
232 }
233 }
234 return next(e)
235 })
236
237 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
238 const base = await next(e)
239 if (!themed()) return base
240 try {
241 const t = await mainTheme($, options)
242 if (t === null) return base
243 const ui = $.ui.resolve(e)
244 const { Box, Text } = ui
245 const cols = columnsOf(e.viewport)
246 const m = motifOf(t.key)
247 const body = striped(ui, t.color, base)
248 if (e.props.isFirstOfReply !== true) return body
249 const head = cols >= WIDE ? `${t.emoji} ${m.glyph} ${t.name} ${m.glyph} ` : `${t.emoji} ${t.name}`
250 const tail = cols >= WIDE ? rule(m.rule, cols, cells(head)) : ''
251 return (
252 <Box flexDirection="column">
253 <Text wrap="truncate-end">
254 <Text color={t.color} bold>
255 {head}
256 </Text>
257 <Text color={t.accent}>{tail}</Text>
258 </Text>
259 {body}
260 </Box>
261 )
262 } catch {
263 return base
264 }
265 })
266
267 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
268 const base = await next(e)
269 if (!themed()) return base
270 try {
271 const kind = e.props.origin.kind
272 if (kind !== 'composer' && kind !== 'bridge') return base
273 const t = await mainTheme($, options)
274 if (t === null) return base
275 const { Box, Text } = $.ui.resolve(e)
276 const m = motifOf(t.key)
277 const seal = columnsOf(e.viewport) >= WIDE ? `${m.glyph} you ${m.rule.repeat(6)}` : `${m.glyph} you`
278 return (
279 <Box flexDirection="column">
280 <Text color={t.accent} dimColor wrap="truncate-end">
281 {seal}
282 </Text>
283 {base}
284 </Box>
285 )
286 } catch {
287 return base
288 }
289 })
290
291 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
292 const base = await next(e)
293 if (!themed()) return base
294 try {
295 const ui = $.ui.resolve(e)
296 const { Box } = ui
297 const cols = columnsOf(e.viewport)
298 const p = e.props
299 const who = await callerTheme($, options, p.tool_use_id)
300 const row = who === null ? base : badged(ui, [who], cols, p.isErrored, base)
301 const spawned = p.tool === 'Agent' ? crewOfInput(p.input) : null
302 const crew = spawned === null ? undefined : CREW[spawned]
303 if (crew === undefined) return row
304 const state: CardState = p.isErrored ? 'fell' : p.isInterrupted ? 'recalled' : 'deployed'
305 const card = crewCard(ui, {
306 theme: crew,
307 state,
308 description: descriptionOf(p.input),
309 cols,
310 banners: bannersOn(),
311 salt: p.tool_use_id,
312 })
313 return (
314 <Box flexDirection="column">
315 {card}
316 {row}
317 </Box>
318 )
319 } catch {
320 return base
321 }
322 })
323
324 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
325 const base = await next(e)
326 if (!themed()) return base
327 try {
328 const ui = $.ui.resolve(e)
329 const { Box } = ui
330 const cols = columnsOf(e.viewport)
331 const p = e.props
332 if (p.tool === 'Agent') {
333 const facts = agentFacts(p.output)
334 const lane = (await read($, spawns))[p.tool_use_id]
335 let key = lane !== undefined && lane !== '-' ? lane : null
336 if (lane === undefined && facts.agentType !== null) key = crewKey(facts.agentType)
337 const crew = key === null ? undefined : CREW[key]
338 if (crew !== undefined) {
339 let fell = p.isErrored || (facts.status !== null && FALLEN_STATUSES.has(facts.status))
340 if (!fell && facts.agentId !== null) {
341 try {
342 const found = (await $.agent.list()).find(a => a.id === facts.agentId)
343 fell = found !== undefined && FALLEN_STATUSES.has(found.status)
344 } catch {
345 // the roster is a second opinion only: the result itself decides
346 }
347 }
348 const background = !fell && facts.status === 'async_launched'
349 const state: CardState = fell ? 'fell' : background ? 'background' : 'returned'
350 const says = fell || background ? null : extractSays(facts.text)
351 const card = crewCard(ui, {
352 theme: crew,
353 state,
354 description: '',
355 cols,
356 banners: bannersOn(),
357 salt: p.tool_use_id,
358 stats: statsLine(facts),
359 })
360 return (
361 <Box flexDirection="column">
362 {card}
363 {says === null ? null : bubble(ui, crew, says, cols)}
364 {base}
365 </Box>
366 )
367 }
368 }
369 if (!p.isErrored) return base
370 const who = await callerTheme($, options, p.tool_use_id)
371 if (who === null) return base
372 return (
373 <Box flexDirection="column">
374 {errorMark(ui, who)}
375 {base}
376 </Box>
377 )
378 } catch {
379 return base
380 }
381 })
382
383 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
384 const base = await next(e)
385 if (!themed()) return base
386 try {
387 const ui = $.ui.resolve(e)
388 const cols = columnsOf(e.viewport)
389 const lanes = await read($, callers)
390 const main = await mainTheme($, options)
391 const who: Theme[] = []
392 for (const call of e.props.calls) {
393 const lane = call.tool_use_id === undefined ? undefined : lanes[call.tool_use_id]
394 const t = lane === undefined ? main : lane === '-' ? null : (CREW[lane] ?? null)
395 if (t !== null && !who.includes(t)) who.push(t)
396 }
397 if (who.length === 0) return base
398 return badged(ui, who, cols, e.props.calls.some(c => c.isErrored), base)
399 } catch {
400 return base
401 }
402 })
403
404 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
405 const base = await next(e)
406 if (!themed()) return base
407 try {
408 const ui = $.ui.resolve(e)
409 const who = await callerTheme($, options, e.props.tool_use_id)
410 if (who === null) return base
411 return badged(ui, [who], 0, false, base)
412 } catch {
413 return base
414 }
415 })
416
417 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
418 const base = await next(e)
419 if (!themed()) return base
420 try {
421 const command = dojoCommand(e.props.command)
422 if (command === null || e.props.isErrored) return base
423 let t = await mainTheme($, options)
424 let art: string | null = null
425 if (command === 'dojo-duty') {
426 const named = dutyNameOf(e.props.text, Object.keys(STRATEGISTS))
427 if (named !== null) {
428 t = STRATEGISTS[named] ?? t
429 art = named
430 }
431 }
432 if (t === null) return base
433 const { Box, Text } = $.ui.resolve(e)
434 const cols = columnsOf(e.viewport)
435 const m = motifOf(t.key)
436 const rows = bannersOn() && art !== null && cols >= WIDE ? bannerRows(art, cols - 4) : []
437 return (
438 <Box flexDirection="column" borderStyle="round" borderColor={t.color} paddingX={1}>
439 {rows.map((row, i) => (
440 <Text key={`art-${i}`} color={t.color} wrap="truncate-end">
441 {row}
442 </Text>
443 ))}
444 <Text wrap="truncate-end">
445 <Text color={t.color} bold>
446 {`${t.emoji} ${m.glyph} ${t.name}`}
447 </Text>
448 <Text color={t.accent}>{` · ${COMMAND_TITLES[command]}`}</Text>
449 </Text>
450 {base}
451 </Box>
452 )
453 } catch {
454 return base
455 }
456 })
457
458 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
459 const base = await next(e)
460 if (!themed()) return base
461 try {
462 const t = await mainTheme($, options)
463 if (t === null) return base
464 const { Box, Text } = $.ui.resolve(e)
465 const m = motifOf(t.key)
466 return (
467 <Box flexDirection="column">
468 <Text wrap="truncate-end">
469 <Text color={t.color} bold>
470 {`${t.emoji} ${m.glyph} ${t.name} `}
471 </Text>
472 <Text color={t.accent}>{ASKS[t.key] ?? ASKS.strategist}</Text>
473 </Text>
474 {base}
475 </Box>
476 )
477 } catch {
478 return base
479 }
480 })
481
482 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
483 const base = await next(e)
484 if (!themed()) return base
485 try {
486 const t = await mainTheme($, options)
487 if (t === null) return base
488 const { Box, Text } = $.ui.resolve(e)
489 return (
490 <Box flexDirection="row">
491 <Box flexShrink={0} marginRight={1}>
492 <Text color={t.color}>{motifOf(t.key).glyph}</Text>
493 </Box>
494 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
495 {base}
496 </Box>
497 </Box>
498 )
499 } catch {
500 return base
501 }
502 })
503}
504mods/lib/options.ts 10 lines1import type { On, PluginOptions } from 'claude-code'
2
3export type Options = PluginOptions
4
5/** A feature installer: registers its hooks on `on`, reading its toggles from `options`. */
6export type Installer = (on: On, options: Options) => void
7
8/** Every feature is a `userConfig` toggle: on unless explicitly false. */
9export const isOn = (options: Options, name: string): boolean => options[name] !== false
10mods/lib/toast.ts 19 lines1import { CREW } from '../theme'
2
3export type ToastKind = 'commit' | 'backend' | 'frontend' | 'lookups' | 'compact'
4
5/** Who speaks, and what they say, for each guard's user-facing toast. */
6export const TOASTS: Record<ToastKind, { who: string; line: string }> = {
7 commit: { who: 'levi', line: 'Filthy commit message. Rejected.' },
8 backend: { who: 'senku', line: 'That is a lab job. Hand it over, and get excited.' },
9 frontend: { who: 'sanji', line: 'The kitchen is mine. Allow me to plate that.' },
10 lookups: { who: 'l', line: 'Hm. Too many searches for one head. Delegate the investigation.' },
11 compact: { who: 'robin', line: 'Fufu. I will keep the thread safe.' },
12}
13
14export function toastLine(kind: ToastKind): string {
15 const { who, line } = TOASTS[kind]
16 const c = CREW[who]
17 return `${c?.emoji ?? ''} ${c?.name ?? who}: "${line}"`.trim()
18}
19mods/lib/voice.ts 22 lines1import { VOICE_RULES } from '../voice.generated'
2import type { VoiceLevel } from '../voice.generated'
3
4export const VOICE_LEVELS = Object.keys(VOICE_RULES) as VoiceLevel[]
5
6export function voiceKey(raw: string | undefined | null): VoiceLevel | null {
7 const key = (raw ?? '').trim().toLowerCase()
8 return (VOICE_LEVELS as string[]).includes(key) ? (key as VoiceLevel) : null
9}
10
11/** Appended to a dojo agent's spawn prompt while an override is set. */
12export function voiceOverrideBlock(level: VoiceLevel): string {
13 return `## Voice level override\nThe user set the voice level to "${level}" for this session. It replaces the voice level in your instructions; follow these rules instead.\n\n${VOICE_RULES[level]}`
14}
15
16/** The context entry /dojo-voice leaves the main model. */
17export function voiceContext(level: VoiceLevel): string {
18 return `dojo: voice level: ${level}\n${VOICE_RULES[level]}`
19}
20
21export const VOICE_CLEAR_WORDS = ['default', 'reset', 'off']
22