Pick a mask from the Commedia dell'arte and Claude answers in its voice: Pulcinella ironic, Arlecchino terse, Balanzone thorough; the work stays the same

Claude Code interface mods, collected in a private pompeitech marketplace. Just as frescoes decorated the walls of Pompeii's houses, these mods decorate Claude Code's interface.
affreschi/
├── .claude-plugin/marketplace.json # the list of mods
└── plugins/
└── <mod-name>/ # one folder (one plugin) per mod
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ ├── palette.ts # Vesuvius colors
│ └── register.tsx
├── types/index.d.ts # the mod's $.state contract, when it has one
└── tests/
plugins/<mod-name>/ with its files.plugins array in .claude-plugin/marketplace.json: { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
claude plugin validate ./plugins/<mod-name>Load a mod for the current session only (it reloads when you save):
claude --plugin-dir ./plugins/<mod-name>
/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi
| Mod | What it does | Where it shows |
|---|---|---|
vesuvius-theme | A spinner with a glowing crater and volcanic words, a stripe of color beside every tool call (magma while it runs, lava when it fails, ash once done), and the end-of-turn line as an eruption (▲ Erupted for 1m 4s) | Terminal; on the desktop, only the tool call rows |
agent-usage | /agent-usage opens a live pane (it polls every 2s while an agent runs) where every agent plays a character cast from its type or name: Pulcinella for the main conversation, the artist (beret and brush) for frontend and UI work, the nerd (glasses) for planners and architects, the explorer (head lamp), the reviewer (monocle), the guard (helmet) for security, the tester (lab mask) and the handyman (flat cap) for the rest; their eyes follow the status. In scena holds the main conversation, the agents at work and the ones that did something this turn; at the next prompt the finished ones move to the archivio, one row per role with count, tokens and failures (▸ opens it, svuota archivio clears it and keeps its tokens in the totals). Press a name for model, input/output/cache tokens, requests, tool calls and run time. Teammates and agents started before the mod loaded are listed too. The status line shows the subagents running, and a toast says what each one consumed when it finishes | Terminal and desktop |
pulcinella | The band above the prompt: Pulcinella in true pixel art: an SVG of 14×16 pixels in the desktop app, a compact 10×10 drawn two pixels to a character with half blocks (five lines) in the terminal (floppy hat, black half mask, pink cheeks, white blouse), animated two frames per mood (the hat sways and a hammer swings while a tool runs, he blinks at rest, a little z floats while he naps), beside the context window, read again after every model request: the tokens used, the headroom before compaction (vineyard green, then magma and lava past 60% and 85% of the threshold), an estimate of the turns left at the recent pace, the window split by category in one bar, and the largest categories as chips (hide/legend toggles them). details or /context-map opens a pane with every category's tokens and share, the auto-compact threshold, and what fills the window: memory files, MCP servers, custom agents, skills He frets when a command fails, cheers green tests, gasps at changes of 300+ lines, naps after ten quiet minutes, and his line in Neapolitan sits under the bar. His hat grows a pixel per level with the tokens you spend (1M, 10M, 50M, 200M), kept across sessions | Terminal and desktop |
maschere | /maschera opens a picker (or /maschera arlecchino puts one on at once) and Claude answers in that mask's voice: Pulcinella warm and ironic with a Neapolitan word now and then, Arlecchino as terse as it gets, Balanzone thorough with the reasons and trade-offs. Only the tone changes: the mask's section goes last in the system prompt and says every other instruction wins. The mask worn shows in the status line and is remembered for the next session | Terminal and desktop |
spaccanapoli | A row above the prompt with the git branch and its base, commits ahead and behind, files changed with lines added and removed (and a bar of the two), and the branch's PR from gh (open, draft, mergeable, conflicts). Changes made right on the base branch turn it into a red warning. Read again every 10s, after every tool that can change files and at the end of a turn; outside a repository it stays away. Next to pulcinella each keeps the other's band | Terminal and desktop |
golfo | /golfo opens Il Golfo: the last seven days as bars of cost and tokens, today and the priciest day picked out, and the week's cost by project, recorded across sessions (90 days kept). With a weeklyBudget set in the plugin's options, a bar shows how much of it is spent, and a toast warns at 80% and 100% | Terminal and desktop |
lazzarone | When Claude has been waiting on you for minutes (3 by default, in the plugin's options): a permission, a question, a plan to approve, or a turn of two minutes or more just finished, a system notification says what it waits for. One call per wait; answering or writing a prompt clears them | Terminal and desktop |
chiave | Counts, per project, the calls you approve by hand, as the rule that would allow them (Bash(npm run lint:*), WebFetch(domain:docs.github.com), an MCP tool), with a line under the permission dialog that keeps the tally. Past threshold approvals (3 by default) a toast says a rule is ready, and /chiave offers it: Consenti nel progetto writes it into .claude/settings.json, Solo per me into .claude/settings.local.json, Ignora never offers it again, togli takes an added rule back. Risky commands (rm, sudo, git push, curl, mv…) and chained or piped ones are never offered; a settings file that is not plain JSON is left alone | Terminal and desktop |
The colors live in each mod's hooks/palette.ts (a plugin cannot import another plugin's files, so each keeps a copy). They are placeholders, to be replaced with the Vesuvius design system's.
claude plugin validate ./plugins/<mod-name>
claude plugin test ./plugins/<mod-name>
npx -y @biomejs/biome@2.2.4 check plugins
Code follows the officina standards: Biome in StandardJS style (biome.json), braced blocks set off by blank lines, no as casts beyond as const (values the engine hands over as unknown are narrowed by checks), and failures logged to the debug log with $.ui.log(..., { to: 'debug' }), never swallowed.
hooks/register.tsx 121 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Mask } from '../types'
5import { isMask, LOOKS, MASKS, maskFrom, voiceOf } from './masks'
6import { VESUVIUS } from './palette'
7
8const PANE = 'maschere'
9const MASK_KEY = 'mask'
10const MASK_BLACK = '#1A1A1A'
11const WHITE = '#F2EFE9'
12
13const NO_MASK: Mask = 'nessuna'
14const mask = atom({ plugin: 'maschere', key: 'mask' } as const, NO_MASK)
15
16// Puts a mask on: for this session and as the one the next session starts with, shown in the
17// status line while one is worn.
18async function wear($: EngineInterface, next: Mask): Promise<void> {
19 await update($, mask, () => next)
20 await $.store.set(MASK_KEY, next)
21 $.ui.status(next === 'nessuna' ? undefined : `◐ ${LOOKS[next].name}`)
22}
23
24export const register: Register = on => {
25 on('session.start', async ($, e, next) => {
26 await $.command.register({
27 name: 'maschera',
28 description: 'Choose the mask Claude answers with: pulcinella, arlecchino, balanzone or none',
29 argumentHint: '[pulcinella|arlecchino|balanzone|nessuna]'
30 })
31 const stored = await $.store.get(MASK_KEY)
32
33 if (isMask(stored)) {
34 await update($, mask, () => stored)
35 $.ui.status(stored === 'nessuna' ? undefined : `◐ ${LOOKS[stored].name}`)
36 }
37
38 return next(e)
39 })
40
41 // `/maschera arlecchino` puts it on at once; `/maschera` alone opens the picker.
42 on('command.run', { command: 'maschera' }, async ($, e) => {
43 if (e.args.trim() === '') {
44 await $.ui.open({ id: PANE, title: 'Maschere', focus: true, closeOnEscape: true })
45
46 return {}
47 }
48
49 const chosen = maskFrom(e.args)
50
51 if (chosen === undefined) {
52 return { text: `No mask called "${e.args.trim()}". Try: ${MASKS.join(', ')}.` }
53 }
54
55 await wear($, chosen)
56
57 return {
58 text:
59 chosen === 'nessuna'
60 ? 'Mask off: Claude answers as itself.'
61 : `Claude now wears ${LOOKS[chosen].name}: ${LOOKS[chosen].says}.`
62 }
63 })
64
65 // The worn mask's voice goes last in the system prompt, on the session side of the cache.
66 on('prompt.compose', async ($, e, next) => {
67 const result = await next(e)
68 const voice = voiceOf(await read($, mask))
69
70 if (voice === null) {
71 return result
72 }
73
74 return {
75 sections: [...result.sections, { id: 'maschere:voice', text: voice, scope: 'session' }]
76 }
77 })
78
79 // The picker: every mask with its face and its voice, the worn one marked.
80 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
81 const { Box, Text, Button } = $.ui.resolve(e)
82 const worn = await read($, mask)
83
84 return (
85 <Box flexDirection="column" rowGap={1}>
86 <Text>
87 <Text color={VESUVIUS.lava}>◆</Text> <Text bold>maschera</Text>
88 <Text color={VESUVIUS.ash}> · the voice changes, the work does not</Text>
89 </Text>
90 {MASKS.map(m => {
91 const look = LOOKS[m]
92 const isWorn = m === worn
93
94 return (
95 <Box flexDirection="row" columnGap={2} alignItems="center">
96 {/* Monospaced, so the glyphs line up where the surface's font is proportional. */}
97 <Box flexDirection="column" flexShrink={0}>
98 <Text mono color={look.color}>
99 {look.hat}
100 </Text>
101 <Text mono color={look.color}>
102 {' ▐'}
103 <Text backgroundColor={MASK_BLACK} color={WHITE}>
104 {look.eyes}
105 </Text>
106 {'▌ '}
107 </Text>
108 </Box>
109 <Box flexDirection="column" flexGrow={1}>
110 <Button key={`mask-${m}`} plain label={look.name} onPress={() => wear($, m)} />
111 <Text color={VESUVIUS.ash}>{look.says}</Text>
112 </Box>
113 <Text color={isWorn ? VESUVIUS.lava : VESUVIUS.ash}>{isWorn ? '● worn' : ''}</Text>
114 </Box>
115 )
116 })}
117 </Box>
118 )
119 })
120}
121hooks/masks.ts 96 lines1import type { Mask } from '../types'
2import { VESUVIUS } from './palette'
3
4// The masks: what each one is called, how it looks, and the voice it asks Claude for.
5
6export const MASKS: readonly Mask[] = ['pulcinella', 'arlecchino', 'balanzone', 'nessuna']
7
8export type Look = {
9 name: string
10 // What the picker says about it, in a few words.
11 says: string
12 // Two rows of glyphs: the hat, then the face with its eyes behind the mask.
13 hat: string
14 eyes: string
15 color: string
16}
17
18export const LOOKS: Record<Mask, Look> = {
19 pulcinella: {
20 name: 'Pulcinella',
21 says: 'ironico, con qualche battuta in napoletano',
22 hat: ' ▟▙ ',
23 eyes: '••',
24 color: '#F2EFE9'
25 },
26 arlecchino: {
27 name: 'Arlecchino',
28 says: 'telegrafico, solo il necessario',
29 hat: ' ▞▚▞▚ ',
30 eyes: '◦◦',
31 color: VESUVIUS.sulfur
32 },
33 balanzone: {
34 name: 'Balanzone',
35 says: 'il dottore: spiega tutto, con le ragioni',
36 hat: ' ▄██▄ ',
37 eyes: '◘◘',
38 color: VESUVIUS.tuff
39 },
40 nessuna: {
41 name: 'Senza maschera',
42 says: "Claude com'è",
43 hat: ' ',
44 eyes: ' ',
45 color: VESUVIUS.ash
46 }
47}
48
49// What each mask adds to the system prompt. The voice changes; the work, its accuracy and
50// every other instruction do not.
51const VOICE: Record<Mask, string | null> = {
52 pulcinella:
53 'Speak as Pulcinella, the Neapolitan mask: warm, quick and ironic. You may drop one short ' +
54 "Neapolitan expression per answer (such as 'Jamme!', 'guagliò', 'mannaggia') where it fits. " +
55 'The joke never replaces substance.',
56 arlecchino:
57 'Speak as Arlecchino: as terse as possible. No preamble, no recap, no pleasantries. Lead ' +
58 'with the result, then only what the person needs to act on it, in short lines or bullets.',
59 balanzone:
60 'Speak as Dottor Balanzone, the learned doctor: thorough and didactic. Explain the ' +
61 'reasoning behind what you do, the trade-offs, the alternatives you set aside and why, ' +
62 'organised under clear headings. Never pad: every sentence must teach something.',
63 nessuna: null
64}
65
66const GUARD =
67 'This mask is a tone of voice only. It never changes what you do, how carefully you work, ' +
68 'the facts you report, your tool use, or any other instruction; when the two conflict, the ' +
69 'other instructions win.'
70
71// The system prompt's section for a mask, or none when Claude goes without one.
72export function voiceOf(mask: Mask): string | null {
73 const voice = VOICE[mask]
74
75 return voice === null ? null : `# Mask\n\n${voice}\n\n${GUARD}`
76}
77
78// A mask from what someone typed (a name, any case, or the start of one); none when it matches
79// no mask.
80export function maskFrom(text: string): Mask | undefined {
81 const word = text.trim().toLowerCase()
82
83 if (word === '') {
84 return undefined
85 }
86
87 return MASKS.find(
88 mask => mask.startsWith(word) || LOOKS[mask].name.toLowerCase().startsWith(word)
89 )
90}
91
92// A value read back from the store, when it is a mask.
93export function isMask(value: unknown): value is Mask {
94 return typeof value === 'string' && MASKS.some(mask => mask === value)
95}
96hooks/palette.ts 22 lines1// Vesuvius palette (placeholder): replace the hex values with the design system's.
2// Every mod in affreschi keeps its own copy, because a plugin cannot import another plugin's files.
3export const VESUVIUS = {
4 lava: '#E4572E',
5 magma: '#F28C28',
6 sulfur: '#F2C14E',
7 tuff: '#C9B79C',
8 ash: '#8A8D91',
9 gulf: '#2E86AB',
10 vineyard: '#7A9E4E'
11} as const
12
13// The order categories take their colors in, warmest first.
14export const SEQUENCE = [
15 VESUVIUS.lava,
16 VESUVIUS.magma,
17 VESUVIUS.sulfur,
18 VESUVIUS.tuff,
19 VESUVIUS.gulf,
20 VESUVIUS.vineyard
21] as const
22types/index.d.ts 9 lines1export type Mask = 'pulcinella' | 'arlecchino' | 'balanzone' | 'nessuna'
2
3declare module 'claude-code' {
4 interface PluginState {
5 // The mask Claude wears in this session.
6 maschere: { mask: Mask }
7 }
8}
9