SLOPSHOPPER

dojo

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

newpanebandspinnerrowsguard
v0.5.1MITupdated 2026-10-11shreym95/dojo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dojo
│ ┃ Dojo roster ✕ ◆ you ────── │ ┃ No strategist on duty ⟨Claude Code's own drawing⟩ │ ┃ voice: medium (roster default) │ ┃ crew: 0 running · 0 returned · 0 fell · 6 i… ⏺ Read(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Read 6 lines │ ┃ 🧪 Senku · backend · sonnet 5.5 🎴 Strategist ⟨Claude Code's own drawing⟩ │ ┃ ○ idle ⏺ Bash(bun test) │ ┃ 🍳 Sanji · frontend · sonnet 5.5 ⎿ 3 pass, 1 fail │ ┃ ○ idle │ ┃ 🍰 L · deep research · haiku 4.5 ⟨Claude Code's own drawing⟩ │ ┃ ○ idle │ ┃ 📓 Light · quick fact · haiku 4.5 ✻ Outplanned for 42s · done 4:20 PM │ ┃ ○ idle │ ┃ 🧹 Levi · QA · haiku 4.5 › /dojo-duty │ ┃ ○ idle ╭────────────────────────────────────────────────────────────────── │ ┃ 🌸 Robin · docs · haiku 4.5 │ 🎴 ◆ Strategist · on duty │ ┃ ○ idle │ ⟨Claude Code's own drawing⟩ │ ┃ ╰────────────────────────────────────────────────────────────────── │ ┃ [ close ] esc closes │ ✻ 🎴 Planning ◐… 🎴 Chakra ▰▰▰▰▰▱▱▱▱▱ 49% · Stamina 31% (5h) · 両 $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🎴 Chakra ▰▰▰▰▰▱▱▱▱▱ 49% · Stamina 31% (5h) · 両 $0.42
Pane · Dojo roster
No strategist on duty voice: medium (roster default) crew: 0 running · 0 returned · 0 fell · 6 idle ──────────────────────────────────────────────── 🧪 Senku · backend · sonnet 5.5 ○ idle 🍳 Sanji · frontend · sonnet 5.5 ○ idle 🍰 L · deep research · haiku 4.5 ○ idle 📓 Light · quick fact · haiku 4.5 ○ idle 🧹 Levi · QA · haiku 4.5 ○ idle 🌸 Robin · docs · haiku 4.5 ○ idle [ close ] esc closes
Your message
◆ you ────── ⟨Claude Code's own drawing⟩
Claude's reply
⟨Claude Code's own drawing⟩
Tool row
🎴 Strategist ⟨Claude Code's own drawing⟩
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🎴 ◆ Strategist · on duty │ │ ⟨Claude Code's own drawing⟩ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
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

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

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.

Visuals

Built on Claude Code's function hooks (early-access API), the dojo plugin includes:

  • 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.
  • Session usage line: one row above the prompt that shows where the session is heading: 🦌 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.
  • Transcript theme: persona frames on messages, tool badges, crew cards and speech bubbles in the on-duty character's colors.
  • ASCII banners: shown when a dojo subagent is deployed and when it returns.
  • Prompt chrome: themed prompt area with persona hints under the prompt and session mode labels.
  • Roster pane: /dojo shows the crew, who is on duty and who is running.
  • Deploy/return toasts and sounds: 🧹 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.

Guards and gates

  • 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.
  • Commit gate (on by default): forces Conventional Commits (subject ≤72 chars, no body), denies a 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.
  • Delegation budgets (on by default, in a dojo session): caps the main thread's inline edits and lookups per turn. Limits: 2 files, 6 lines, 3 lookups. Scratch (temp dirs, /scratchpad/) is exempt.
  • Compaction memory (on by default): in a dojo session, the compaction summary keeps the on-duty line, voice level, running dojo agents with their tasks, and user decisions.

Commands

  • /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).

Settings

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:

KeyDefaultWhat it does
spinnerstrueReplace the spinner word with the on-duty strategist's (or crew member's) themed verbs.
turnFlairtruePast-tense themed word on the line that closes a turn (Outmaneuvered, Checkmated).
bandtrueShow one animated row per running dojo subagent above the prompt.
usageLinetrueShow one row above the prompt with the session's context fill (Chakra), the busiest rate-limit window (Stamina) and the cost.
toaststrueToast when a dojo subagent is deployed, returns or falls.
neutralMainThemetrueWhen no strategist persona is detected, still theme the main spinner with a neutral strategist pool.
commitGatetrueForce 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.
commitTrailerCo-Authored-By: ClaudeThe exact Co-Authored-By line commits must carry. Empty keeps Claude Code's own text and skips the trailer check.
roleGuardtrueIn 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.
inlineEditFiles2Distinct files the main thread may edit itself in one turn before it must delegate.
inlineEditLines6Largest Edit (lines in new_string) the main thread may make itself.
inlineLookups3WebSearch and WebFetch calls the main thread may make itself in one turn before it must delegate.
archivisttrueIn 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.
transcriptThemetrueFrame messages, tool calls and subagent results in the on-duty character's colors: persona frames on messages, tool badges, crew cards and speech bubbles.
bannerstrueShow an ASCII banner when a dojo subagent is deployed and when it returns.
promptChrometrueTheme the prompt area: persona hints under the prompt and the session mode line.
rosterPanetrueShow the /dojo roster panel: the crew, who is on duty and who is running.
soundstruePlay a sound cue when a dojo subagent is deployed, returns or falls (macOS only).

Note

Don't pass model when spawning dojo agents: the model guard enforces each agent's own frontmatter model.

License

MIT

Source 21 files
mods/register.tsx 28 lines
1import 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}
28
mods/features/archivist.ts 71 lines
1import { 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}
71
mods/features/commands.ts 89 lines
1import { 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}
89
mods/features/commit-gate.ts 199 lines
1import { 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}
199
mods/features/model-guard.ts 12 lines
1import { 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}
12
mods/features/role-guard.ts 189 lines
1import { 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}
189
mods/lib/persona.ts 117 lines
1import { 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}
117
mods/visuals/chrome.tsx 504 lines
1import { 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}
504
mods/visuals/transcript.tsx 504 lines
1import { 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}
504
mods/lib/options.ts 10 lines
1import 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
10
mods/lib/toast.ts 19 lines
1import { 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}
19
mods/lib/voice.ts 22 lines
1import { 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