An always-on assistant with a name and a face: give it goals, and it keeps working on them in background rounds, asks before risky actions, briefs you in the…

Dracula-themed mods for Claude Code: a live diff pane, Kubernetes, Docker, CodeBuild and worktree panes, a pull request dashboard, AWS SSO status, guardrails for risky shell commands and a few workflow helpers. Each mod is its own plugin, so install only the ones you want.
Mods use Claude Code's function hooks API, which is in early access and may change between releases. Mods run on your machine with your permissions: read the code before installing, like any plugin.
In Claude Code (terminal or the desktop app's Code tab):
/plugin marketplace add lucasleandro08/claude-mods
/plugin install mod-manager@dracula-mods
The marketplace is named dracula-mods. Open /mods (or click the ⚙ Mods chip above the prompt; the pane also opens by itself while no mod is on): install the mods you want from there (Install or Install all) and turn them on. Every mod ships turned off. Your choices are saved in ~/.claude/dracula-mods.json, so they hold in every session; each mod's other options are in /config.
| Mod | What it does | Commands | |||
|---|---|---|---|---|---|
| mod-manager | The only mod on by default: a pane that lists every mod with an on/off switch and an Install button for the ones not installed yet (it runs claude plugin install, or puts the /plugin install command in your prompt when the CLI is not found), plus a ⚙ Mods · n/12 chip above the prompt. After an install it runs /reload-plugins, so the new mod shows up in the session you are in; ↻ Reload does the same by hand. | /mods, /mods reload | |||
| live-diff | A pane with the diff of every Edit and Write Claude makes: file navigator with M/A status, +/- counts, line numbers and red/green rows. A Δ Diff chip above the prompt reopens it. | /diff, /diff-clear | |||
| kube-pane | A ⎈ context chip above the prompt (red on production) and a pods pane: context and namespace pickers, filter, pages, unhealthy pods first, and a ⌘ Shell button that opens kubectl exec in the app's Terminal, with a container picker for multi-container pods. Switching context only affects Claude's session: your kubeconfig is never written. | /pods | |||
| pr-pane | Your open pull requests grouped by repo, with CI status, the Codex review state and whether each PR already reached your integration branch. A ⑂ PRs chip flags failing CI and Codex findings. Needs the GitHub CLI. | /prs | |||
| guardrails | Blocks risky commands before they run: killing a port owned by a Docker container, pkill docker, printing secrets (env, printenv, echo $API_TOKEN), rewriting the kubeconfig. Asks before kubectl writes on production contexts. Each rule can be switched off. | — | |||
| promote-branch | /promote previews merging the current branch into your integration branch (commits, files, test files touched). /promote go does a full merge in a temporary worktree and pushes it, refusing dirty trees and protected branches. | /promote, /promote go | |||
| codex-loop | Hands Claude the review loop with the Codex GitHub bot: request a review, weigh each finding, fix or 👎 it, reply, resolve, repeat until 👍, for at most 5 rounds (and 15 minutes of waiting per round) before it stops and reports what is still open. | /codex-loop <PR> [--max <rounds>] | |||
| turn-done | A toast and a chime when a long turn finishes. | — | |||
| aws-profile | Each AWS profile above the prompt with whether its login still works (aws sts get-caller-identity, only the exit code is kept). Expired ones get a login button that runs aws sso login --profile … in the app's Terminal and turns green once you finish. The AWS_PROFILE Claude Code runs with is marked ●; production profiles are red. | — | |||
| docker-pane | A 🐳 Docker · n up chip (unhealthy and stale counts) and a pane grouped by compose project: ports, health, Start/Stop/Restart by container name (never by port), Logs and compose Recreate in the app's Terminal. Flags containers created before your last git checkout in the session's repo, the usual cause of a test failing for no reason. | /docker | |||
| deploy-watch | Watches AWS CodeBuild projects: a 🚀 chip with the last build, a warning (chip and toast) when a git push to a project's branch started no build after a few minutes, and a pane with recent builds, their logs in the Terminal and a Start build button that asks first. | /deploys | |||
| worktrees | A 🌳 n worktrees chip and a pane with every worktree of the session's repo and of the repos under your roots: branch, uncommitted changes, PR state, a Terminal button, and Remove (asks first, never --force) for clean worktrees whose branch is merged. | /worktrees | |||
| dot | An always-on assistant with a name and a face: Vlad, a chibi vampire drawn in SVG that lives in its own pane and animates by mood (working, waiting on you, idle, paused), with what it is doing right under its name; the mods bar keeps a one-word tile that opens it. Rename it with /dot name. Give it goals in its pane or with /dot <goal>; background rounds (the desktop app's scheduled tasks, every 30 min) pick one goal at a time and work on it, following ~/.claude/dot/rules.md: act alone, ask first, or hand off to you. When it needs a decision it notifies you and the goal shows under Waiting on you, where you answer it. A weekday morning briefing sums up what needs you. Its memory is a file you can read and edit. Rounds run while the desktop app is open. | /dot, /dot <goal>, `/dot on \ | pause \ | resume \ | run` |
| Mod | Option | Default |
|---|---|---|
| mod-manager | Show the Mods chip · open on start until a mod is on · claude CLI path | on · on · PATH |
| kube-pane | Production context pattern · default namespace · refresh interval · pods per page · kubectl path | prod · default · 15 s · 25 · PATH |
| pr-pane | Integration branch · Codex bot login · refresh interval · PRs to load · gh path | staging · chatgpt-codex-connector[bot] · 5 min · 50 · PATH |
| guardrails | Production pattern · confirm production writes · block kubeconfig changes · block printing secrets · protect Docker ports | prod · on · on · on · on |
| promote-branch | Target branch · protected branches | staging · main,master |
| live-diff | Context lines · edits kept | 2 · 100 |
| turn-done | Long turn threshold · play sound | 60 s · on |
| codex-loop | Codex bot login · maximum review rounds · wait for Codex | chatgpt-codex-connector[bot] · 5 · 15 min |
| aws-profile | Production pattern · profiles · check interval · aws path | prod · all · 5 min · PATH |
| docker-pane | Refresh interval · docker path | 60 s (10 s while open) · PATH |
| deploy-watch | Projects (name=branch, comma-separated) · AWS profile · region · poll interval · missing build warning · production pattern · aws path | none · CLI default · profile's · 60 s · 3 min · prod · PATH |
| worktrees | Roots (folders holding your repos) · refresh interval · gh path | session repo only · 5 min · PATH |
| dot | Work round interval · morning briefing (weekdays) · open its pane on start | 30 min · 09:00 · on |
With the mod-manager on, every mod's chip is drawn in one bar above the prompt: a tile per mod, its name dimmed over its value (like a usage band), wrapping between tiles with a blank row between lines; each mod publishes its chip as state and the bar sends presses back to it. Without the manager, or if it stops, each mod draws its own row again. Either way the band keeps whatever other mods draw beneath it.
Admins can register the marketplace and install mods for everyone through managed settings (extraKnownMarketplaces and enabledPlugins); people then turn on what they use in /mods. See Plugins for organizations and Mods for admins.
git clone https://github.com/lucasleandro08/claude-mods
cd claude-mods
scripts/check.sh # validate + test every plugin (CLAUDE_BIN overrides the CLI path)
scripts/check.sh kube-pane # only one
Load your checkout instead of the published version by adding it as a local marketplace and installing from it:
claude plugin marketplace add ~/claude-mods
claude plugin install mod-manager@dracula-mods --scope user
Plugins of a marketplace added from a local folder load in place, so edits and newly installed mods reach open sessions with /reload-plugins (or ↻ Reload in /mods); see Plugin loading. Avoid CLAUDE_CODE_PLUGIN_DIRS for this: it is read only when a session starts, so a mod added to it shows up only in new sessions.
.claude-plugin/marketplace.json
plugins/<mod>/
.claude-plugin/plugin.json manifest and /config options
hooks/register.tsx event wiring, state atoms, everything that touches $
src/ pure logic (diffing, parsing kubectl and gh output, rules)
src/shared/ vendored copies of scripts/shared (theme, binary lookup)
ui/ components the panes and chips draw
types/index.d.ts state contract
tests/ claude plugin test suites
scripts/shared/ source of the shared files; scripts/sync-shared.sh copies them
Two rules of the hooks runtime shape this layout: $ can only be passed to functions declared in register.tsx itself, and state atoms must be declared there too. Plugins cannot import files from each other, which is why small shared helpers are vendored.
🇧🇷 Em português: mods para o Claude Code com tema Dracula. Instale com /plugin marketplace add lucasleandro08/claude-mods e /plugin install mod-manager@dracula-mods, instale os mods que quiser e ligue cada um em /mods (todos vêm desligados); as demais opções ficam em /config.
hooks/register.tsx 276 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Goal, Profile } from '../types'
5import {
6 addGoal, answerGoal, BRIEFING_TASK, briefingCron, briefingPrompt, DEFAULT_PROFILE, DEFAULT_RULES, dotPaths, everyMinutesCron,
7 newlyWaiting, parseGoals, parseProfile, removeGoal, serialize, setStatus, taskPointer, WORK_TASK, workPrompt,
8} from '../src/dot'
9import { isOn, OFF_TEXT, parseState, statePath } from '../src/shared/toggle'
10import { barIsLive, sameChip, type Chip, type ChipPress } from '../src/shared/chip'
11import { DRACULA } from '../src/shared/theme'
12import { dotChip } from '../ui/chip'
13import { DotPane, PaneError } from '../ui/pane'
14
15const MOD = 'dot'
16const CHECK_MS = 5000
17const active = atom({ plugin: 'dot', key: 'active' } as const, false)
18let checkedAt = 0
19
20async function isActive($: EngineInterface) {
21 if (Date.now() - checkedAt > CHECK_MS) {
22 checkedAt = Date.now()
23 const home = await $.env.get('HOME').catch(() => undefined)
24 const text = await $.fs.read(statePath(home)).catch(() => '')
25 const on = isOn(parseState(text), MOD)
26 await update($, active, prev => (prev === on ? prev : on))
27 }
28 return read($, active)
29}
30
31
32const chip = atom({ plugin: 'dot', key: 'chip' } as const, null)
33
34async function barOwnsChips($: EngineInterface) {
35 const { value } = await $.state.get({ plugin: 'mod-manager', key: 'bar' })
36 return barIsLive(value, Date.now())
37}
38
39async function setChip($: EngineInterface, next: Chip | null) {
40 await update($, chip, prev => (sameChip(prev, next) ? prev : next))
41}
42
43const PANE_ID = 'dot'
44const USAGE = 'Usage: /dot · /dot <goal> · /dot pause | resume | run | on · /dot name <name> [emoji]'
45
46const profile = atom({ plugin: 'dot', key: 'profile' } as const, DEFAULT_PROFILE)
47const goals = atom({ plugin: 'dot', key: 'goals' } as const, [])
48const isOpen = atom({ plugin: 'dot', key: 'isOpen' } as const, false)
49const notice = atom({ plugin: 'dot', key: 'notice' } as const, '')
50
51type Settings = { workCron: string; briefingCron: string | undefined; openOnStart: boolean }
52
53function readSettings(options: PluginOptions): Settings {
54 return {
55 workCron: everyMinutesCron(Number(options.workEveryMinutes ?? 30) || 30),
56 briefingCron: briefingCron(String(options.briefingTime ?? '09:00')),
57 openOnStart: options.openOnStart !== false,
58 }
59}
60
61async function paths($: EngineInterface) {
62 return dotPaths((await $.env.get('HOME')) ?? '')
63}
64
65async function readText($: EngineInterface, path: string) {
66 return $.fs.read(path).catch(() => undefined)
67}
68
69async function ensureFiles($: EngineInterface) {
70 const p = await paths($)
71 const current = parseProfile((await readText($, p.profile)) ?? '')
72 if ((await readText($, p.profile)) === undefined) await $.fs.write(p.profile, serialize(current))
73 if ((await readText($, p.goals)) === undefined) await $.fs.write(p.goals, serialize([]))
74 if ((await readText($, p.memory)) === undefined) await $.fs.write(p.memory, '# Memory\n')
75 if ((await readText($, p.rules)) === undefined) await $.fs.write(p.rules, DEFAULT_RULES)
76 await $.fs.write(p.work, `${workPrompt(current.name, p)}\n`)
77 await $.fs.write(p.briefing, `${briefingPrompt(current.name, p)}\n`)
78 return p
79}
80
81async function publishChip($: EngineInterface) {
82 await setChip($, (await read($, active)) ? dotChip(await read($, profile), await read($, goals)) : null)
83}
84
85async function sync($: EngineInterface) {
86 if (!(await isActive($))) return publishChip($)
87 const p = await paths($)
88 const nextProfile = parseProfile((await readText($, p.profile)) ?? '')
89 const nextGoals = parseGoals((await readText($, p.goals)) ?? '[]')
90 const before = await read($, goals)
91 await update($, profile, prev => (JSON.stringify(prev) === JSON.stringify(nextProfile) ? prev : nextProfile))
92 await update($, goals, prev => (JSON.stringify(prev) === JSON.stringify(nextGoals) ? prev : nextGoals))
93 await publishChip($)
94 for (const goal of newlyWaiting(before, nextGoals)) if (before.length > 0) $.ui.toast(`${nextProfile.emoji} ${nextProfile.name}: ${goal.question || goal.title}`, { timeoutMs: 10_000 })
95}
96
97async function changeGoals($: EngineInterface, change: (list: Goal[], now: number) => Goal[]) {
98 const p = await ensureFiles($)
99 const next = change(parseGoals((await readText($, p.goals)) ?? '[]'), Date.now())
100 await $.fs.write(p.goals, serialize(next))
101 await update($, goals, () => next)
102}
103
104async function changeProfile($: EngineInterface, change: (current: Profile) => Profile) {
105 const p = await ensureFiles($)
106 const next = change(parseProfile((await readText($, p.profile)) ?? ''))
107 await $.fs.write(p.profile, serialize(next))
108 await update($, profile, () => next)
109 await ensureFiles($)
110}
111
112async function say($: EngineInterface, text: string) {
113 await update($, notice, () => text)
114}
115
116// The scheduled-tasks tools belong to the desktop app; a refused or failed call resolves with isError instead of throwing
117async function callTool($: EngineInterface, tool: string, input: Record<string, unknown>) {
118 try {
119 const ran = (await $.tool.call({ tool, ...input } as never)) as { isError?: boolean; deny?: string } | undefined
120 return ran?.isError !== true && ran?.deny === undefined
121 } catch {
122 return false
123 }
124}
125
126async function upsertTask($: EngineInterface, taskId: string, title: string, cron: string, promptPath: string) {
127 const fields = { taskId, title, cronExpression: cron, prompt: taskPointer(promptPath), notifyOnCompletion: false }
128 if (await callTool($, 'mcp__scheduled-tasks__update_scheduled_task', { ...fields, enabled: true })) return true
129 return callTool($, 'mcp__scheduled-tasks__create_scheduled_task', { ...fields, description: `${title} (dot mod)` })
130}
131
132async function schedule($: EngineInterface, settings: Settings) {
133 const p = await ensureFiles($)
134 const me = await read($, profile)
135 const worked = await upsertTask($, WORK_TASK, `${me.name} · work`, settings.workCron, p.work)
136 const briefed = settings.briefingCron === undefined || (await upsertTask($, BRIEFING_TASK, `${me.name} · briefing`, settings.briefingCron, p.briefing))
137 if (worked && briefed) {
138 await changeProfile($, current => ({ ...current, scheduled: true, paused: false }))
139 return say($, `${me.name} now works in the background (${settings.workCron}).`)
140 }
141 const ask = [
142 `Set up the background work of my dot ${me.name} with the scheduled-tasks tools (create, or update if it exists):`,
143 `- taskId "${WORK_TASK}", cron "${settings.workCron}", prompt "${taskPointer(p.work)}"`,
144 settings.briefingCron ? `- taskId "${BRIEFING_TASK}", cron "${settings.briefingCron}", prompt "${taskPointer(p.briefing)}"` : '',
145 `Then set "scheduled": true in ${p.profile}.`,
146 ].filter(Boolean).join('\n')
147 const filled = await $.prompt.fill({ text: ask, mode: 'replace' }).catch(() => ({ isFilled: false }))
148 await say($, filled.isFilled ? 'The setup request is in your prompt: press Enter.' : 'Could not reach the scheduled-tasks tools.')
149}
150
151async function togglePause($: EngineInterface) {
152 const paused = !(await read($, profile)).paused
153 await changeProfile($, current => ({ ...current, paused }))
154 for (const taskId of [WORK_TASK, BRIEFING_TASK]) await callTool($, 'mcp__scheduled-tasks__update_scheduled_task', { taskId, enabled: !paused })
155 await say($, paused ? 'Paused: no background rounds until you resume.' : 'Resumed.')
156}
157
158async function runNow($: EngineInterface) {
159 const ran = await callTool($, 'mcp__scheduled-tasks__run_scheduled_task', { taskId: WORK_TASK })
160 await say($, ran ? 'A work round started.' : 'Could not start a round: turn on background work first.')
161}
162
163async function openPane($: EngineInterface) {
164 await ensureFiles($)
165 const opened = await $.ui.open({ id: PANE_ID, title: (await read($, profile)).name })
166 if (opened.isPlaced) await update($, isOpen, () => true)
167 await sync($)
168}
169
170async function press($: EngineInterface, pressed: ChipPress | null) {
171 if (pressed?.plugin === MOD && (await isActive($))) await openPane($)
172}
173
174export const register: Register = (on, options) => {
175 const settings = readSettings(options)
176
177 on('state.set', async ($, e, next) => {
178 const ran = await next(e)
179 if (e.plugin === 'mod-manager' && e.key === 'press') await press($, e.value as ChipPress | null)
180 return ran
181 })
182
183 on('session.start', async ($, e, next) => {
184 await $.command.register({ name: 'dot', description: 'Your always-on assistant: /dot, /dot <goal>, /dot pause|resume|run|on' })
185 $.clock.every(CHECK_MS, () => void sync($).catch(() => undefined))
186 if (settings.openOnStart) $.clock.after(1000, () => void isActive($).then(enabled => (enabled ? openPane($) : undefined)).catch(() => undefined))
187 return next(e)
188 })
189
190 on('command.run', { command: 'dot' }, async ($, e) => {
191 if (!(await isActive($))) return { text: OFF_TEXT }
192 const args = e.args.trim()
193 const [verb = '', ...rest] = args.split(/\s+/)
194 const me = await read($, profile)
195
196 if (args === '') {
197 await openPane($)
198 return { text: `${me.emoji} ${me.name} opened.` }
199 }
200 if (verb === 'pause' || verb === 'resume') {
201 if (me.paused === (verb === 'pause')) return { text: `${me.name} is already ${me.paused ? 'paused' : 'running'}.` }
202 await togglePause($)
203 return { text: `${me.name} ${verb === 'pause' ? 'paused' : 'resumed'}.` }
204 }
205 if (verb === 'run') {
206 await runNow($)
207 return { text: await read($, notice) }
208 }
209 if (verb === 'on') {
210 await schedule($, settings)
211 return { text: await read($, notice) }
212 }
213 if (verb === 'name') {
214 const [name, emoji] = rest
215 if (!name) return { text: USAGE }
216 await changeProfile($, current => ({ ...current, name, emoji: emoji ?? current.emoji }))
217 return { text: `Renamed to ${emoji ?? me.emoji} ${name}.` }
218 }
219 await changeGoals($, (list, now) => addGoal(list, args, now))
220 return { text: `${me.emoji} ${me.name} got a new goal: ${args}` }
221 })
222
223 on('ui.close', async ($, e, next) => {
224 if (e.id === PANE_ID) await update($, isOpen, () => false)
225 return next(e)
226 })
227
228 // Only a tile in the mods bar (or one line without it); the dot itself lives in its own pane
229 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230 if (!(await read($, active)) || (await barOwnsChips($))) return next(e)
231 const rest = await next(e)
232 const own = await read($, chip)
233 if (e.props.hasSurvey || !own) return rest
234
235 const ui = $.ui.resolve(e)
236 return (
237 <ui.Box flexDirection="column">
238 <ui.Box flexDirection="row" gap={1} alignItems="center" marginBottom={1}>
239 <ui.Text color={DRACULA.purple}>{own.icon}</ui.Text>
240 <ui.Button key="open-dot" label={`${(await read($, profile)).name} · ${own.parts[0]?.text ?? ''}`} dimColor onPress={() => openPane($)} />
241 </ui.Box>
242 {rest}
243 </ui.Box>
244 )
245 })
246
247 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
248 const ui = $.ui.resolve(e)
249 if (!(await read($, active))) {
250 return <ui.Box padding={1}><ui.Text color="#6272a4">{OFF_TEXT}</ui.Text></ui.Box>
251 }
252 try {
253 return (
254 <DotPane
255 ui={ui}
256 profile={await read($, profile)}
257 goals={await read($, goals)}
258 notice={await read($, notice)}
259 memoryPath={(await paths($)).memory}
260 onAdd={title => changeGoals($, (list, now) => addGoal(list, title, now))}
261 onAnswer={(id, answer) => changeGoals($, (list, now) => answerGoal(list, id, answer, now))}
262 onDone={id => changeGoals($, (list, now) => setStatus(list, id, 'done', now))}
263 onRemove={id => changeGoals($, list => removeGoal(list, id))}
264 onPause={() => togglePause($)}
265 onRunNow={() => runNow($)}
266 onSchedule={() => schedule($, settings)}
267 />
268 )
269 } catch (err) {
270 const message = err instanceof Error ? err.message : String(err)
271 $.ui.log(`dot render failed: ${message}`)
272 return <PaneError ui={ui} message={message} />
273 }
274 })
275}
276src/dot.ts 191 lines1import type { Goal, GoalStatus, Profile } from '../types'
2
3export const DOT_DIR = '.claude/dot'
4export const WORK_TASK = 'dot-work'
5export const BRIEFING_TASK = 'dot-briefing'
6export const DEFAULT_PROFILE: Profile = { name: 'Vlad', emoji: '🧛', paused: false, scheduled: false }
7
8export type DotPaths = { dir: string; profile: string; goals: string; memory: string; rules: string; work: string; briefing: string }
9
10export function dotPaths(home: string): DotPaths {
11 const dir = `${home}/${DOT_DIR}`
12 return {
13 dir,
14 profile: `${dir}/profile.json`,
15 goals: `${dir}/goals.json`,
16 memory: `${dir}/memory.md`,
17 rules: `${dir}/rules.md`,
18 work: `${dir}/work.md`,
19 briefing: `${dir}/briefing.md`,
20 }
21}
22
23export function parseProfile(text: string): Profile {
24 try {
25 const parsed = JSON.parse(text) as Partial<Profile>
26 return {
27 name: typeof parsed.name === 'string' && parsed.name.trim() !== '' ? parsed.name.trim() : DEFAULT_PROFILE.name,
28 emoji: typeof parsed.emoji === 'string' && parsed.emoji.trim() !== '' ? parsed.emoji.trim() : DEFAULT_PROFILE.emoji,
29 paused: parsed.paused === true,
30 scheduled: parsed.scheduled === true,
31 }
32 } catch {
33 return { ...DEFAULT_PROFILE }
34 }
35}
36
37const STATUSES: readonly GoalStatus[] = ['queued', 'working', 'waiting', 'done']
38
39export function parseGoals(text: string): Goal[] {
40 let parsed: unknown
41 try {
42 parsed = JSON.parse(text)
43 } catch {
44 return []
45 }
46 if (!Array.isArray(parsed)) return []
47 return parsed.flatMap((raw): Goal[] => {
48 const g = raw as Partial<Goal>
49 if (typeof g.id !== 'string' || typeof g.title !== 'string') return []
50 return [{
51 id: g.id,
52 title: g.title,
53 status: STATUSES.includes(g.status as GoalStatus) ? (g.status as GoalStatus) : 'queued',
54 notes: Array.isArray(g.notes) ? g.notes.filter((n): n is string => typeof n === 'string') : [],
55 question: typeof g.question === 'string' ? g.question : '',
56 createdAt: typeof g.createdAt === 'number' ? g.createdAt : 0,
57 updatedAt: typeof g.updatedAt === 'number' ? g.updatedAt : 0,
58 }]
59 })
60}
61
62export function serialize(value: unknown) {
63 return `${JSON.stringify(value, null, 2)}\n`
64}
65
66export function addGoal(goals: Goal[], title: string, now: number): Goal[] {
67 const text = title.trim()
68 if (text === '') return goals
69 const id = `g${now.toString(36)}`
70 return [...goals, { id, title: text, status: 'queued', notes: [], question: '', createdAt: now, updatedAt: now }]
71}
72
73export function answerGoal(goals: Goal[], id: string, answer: string, now: number): Goal[] {
74 const text = answer.trim()
75 if (text === '') return goals
76 return goals.map(g =>
77 g.id === id ? { ...g, status: 'queued', notes: [...g.notes, `You: ${text}`], question: '', updatedAt: now } : g,
78 )
79}
80
81export function setStatus(goals: Goal[], id: string, status: GoalStatus, now: number): Goal[] {
82 return goals.map(g => (g.id === id ? { ...g, status, updatedAt: now } : g))
83}
84
85export function removeGoal(goals: Goal[], id: string): Goal[] {
86 return goals.filter(g => g.id !== id)
87}
88
89export function byStatus(goals: Goal[]) {
90 const sorted = [...goals].sort((a, b) => b.updatedAt - a.updatedAt)
91 return {
92 waiting: sorted.filter(g => g.status === 'waiting'),
93 active: sorted.filter(g => g.status === 'working' || g.status === 'queued'),
94 done: sorted.filter(g => g.status === 'done'),
95 }
96}
97
98export function newlyWaiting(before: Goal[], after: Goal[]): Goal[] {
99 const was = new Set(before.filter(g => g.status === 'waiting').map(g => g.id))
100 return after.filter(g => g.status === 'waiting' && !was.has(g.id))
101}
102
103export function everyMinutesCron(minutes: number) {
104 const m = Math.max(5, Math.round(minutes))
105 if (m < 60) return `*/${m} * * * *`
106 const hours = Math.max(1, Math.round(m / 60))
107 return hours >= 24 ? '0 9 * * *' : `0 */${hours} * * *`
108}
109
110export function briefingCron(time: string) {
111 const match = time.trim().match(/^(\d{1,2}):(\d{2})$/)
112 if (!match) return undefined
113 const hour = Number(match[1])
114 const minute = Number(match[2])
115 if (hour > 23 || minute > 59) return undefined
116 return `${minute} ${hour} * * 1-5`
117}
118
119export const DEFAULT_RULES = `# Rules
120
121Each action falls in one level. When unsure, use the stricter one.
122
123## Act without asking
124- Read code, files, docs, logs, dashboards and connected apps.
125- Run read-only commands and tests on the local machine.
126- Write notes and drafts inside ~/.claude/dot/ and in local branches or worktrees.
127
128## Ask first (set the goal to "waiting" with a question)
129- Commit, push, open or comment on pull requests.
130- Send any message (Slack, email, PR comments) on the user's behalf.
131- Change shared configuration, deploy anything, or write to a staging environment.
132
133## Hand off to the user (never do it, explain what to do)
134- Anything in production: writes, restarts, deploys, data changes.
135- Passwords, credentials, secrets, payments, purchases, account settings.
136- Deleting data permanently.
137`
138
139export function workPrompt(name: string, paths: DotPaths) {
140 return `You are ${name}, the user's always-on assistant. This is one background work round; nobody is watching it live.
141
1421. Read ${paths.profile}. If "paused" is true, stop now without doing anything.
1432. Read ${paths.goals} (a JSON array of goals), ${paths.rules} and ${paths.memory}.
1443. Pick ONE goal: the oldest with status "working", else the oldest "queued". If none, stop now: do not invent work.
1454. Set it to "working" and work on it for this round only. Follow the rules file strictly: anything under "Ask first" or "Hand off" must not be done.
1465. Before finishing, update that goal in ${paths.goals} (keep every other goal untouched, keep valid JSON):
147 - append one short note to "notes" with what you did and what is next;
148 - status "done" when the goal is complete;
149 - status "waiting" with a one-sentence "question" when you need a decision, an approval or a hand-off, then send a desktop notification (PushNotification) saying "${name}: " and the question;
150 - otherwise keep "working";
151 - set "updatedAt" to the current time in milliseconds.
1526. Add to ${paths.memory} anything durable you learned about the user's preferences or setup (short bullets, no secrets).
153
154Never print or store secrets. Write notes in the language the goal was written in.`
155}
156
157export function briefingPrompt(name: string, paths: DotPaths) {
158 return `You are ${name}, the user's always-on assistant. This is the morning briefing.
159
1601. Read ${paths.profile}. If "paused" is true, stop now.
1612. Read ${paths.goals}, ${paths.rules} and ${paths.memory}.
1623. Look around read-only, following the rules file: goals in progress, what is waiting on the user, and anything in the connected tools that is worth their attention today (open pull requests and CI, failing builds, things the goals depend on). Do not change anything.
1634. Send one desktop notification (PushNotification, under 200 characters) with the most important thing, starting with "${name}: ".
1645. Append the full briefing (a few bullets) as a note to a goal titled "Briefings" in ${paths.goals}, creating it with status "done" if missing, and set its "updatedAt" to now in milliseconds.
165
166Write in the language of the user's goals.`
167}
168
169export function taskPointer(path: string) {
170 return `Read ${path} and follow it exactly.`
171}
172
173export function moodOf(profile: Profile, goals: Goal[]): 'working' | 'waiting' | 'idle' | 'paused' {
174 if (profile.paused) return 'paused'
175 if (goals.some(g => g.status === 'waiting')) return 'waiting'
176 if (goals.some(g => g.status === 'working')) return 'working'
177 return 'idle'
178}
179
180export function activityOf(profile: Profile, goals: Goal[]) {
181 const { waiting, active } = byStatus(goals)
182 if (profile.paused) return { status: 'paused', detail: 'No rounds until you resume' }
183 const asking = waiting[0]
184 if (asking) return { status: 'waiting on you', detail: asking.question || asking.title }
185 const current = active.find(g => g.status === 'working')
186 if (current) return { status: 'working', detail: current.notes[current.notes.length - 1] ? `${current.title} · ${current.notes[current.notes.length - 1]}` : current.title }
187 const next = active[active.length - 1]
188 if (next) return { status: profile.scheduled ? 'up next' : 'queued', detail: next.title }
189 return { status: 'idle', detail: 'No goals yet' }
190}
191src/shared/toggle.ts 31 lines1export const MARKETPLACE = 'dracula-mods'
2export const STATE_FILE = '.claude/dracula-mods.json'
3export const OFF_TEXT = 'This mod is off. Turn it on in /mods.'
4
5export type ModsState = { enabled: Record<string, boolean> }
6
7export function statePath(home: string | undefined) {
8 return `${home ?? ''}/${STATE_FILE}`
9}
10
11export function parseState(text: string): ModsState {
12 try {
13 const parsed = JSON.parse(text) as Partial<ModsState>
14 return { enabled: typeof parsed.enabled === 'object' && parsed.enabled !== null ? parsed.enabled : {} }
15 } catch {
16 return { enabled: {} }
17 }
18}
19
20export function isOn(state: ModsState, name: string) {
21 return state.enabled[name] === true
22}
23
24export function withToggle(state: ModsState, name: string, on: boolean): ModsState {
25 return { enabled: { ...state.enabled, [name]: on } }
26}
27
28export function serializeState(state: ModsState) {
29 return `${JSON.stringify(state, null, 2)}\n`
30}
31src/shared/chip.ts 28 lines1import { DRACULA } from './theme'
2
3// The mod-manager draws every mod's chip in one bar; each mod publishes its chip as data
4export type ChipTone = 'text' | 'muted' | 'ok' | 'warn' | 'bad' | 'info' | 'accent'
5export type ChipPart = { text: string; tone: ChipTone; bold?: boolean; action?: string }
6export type Chip = { icon: string; label: string; tone: ChipTone; parts: ChipPart[] }
7export type ChipPress = { plugin: string; action: string; at: number }
8
9export const TONE_COLOR: Record<ChipTone, string> = {
10 text: DRACULA.foreground,
11 muted: DRACULA.comment,
12 ok: DRACULA.green,
13 warn: DRACULA.orange,
14 bad: DRACULA.red,
15 info: DRACULA.cyan,
16 accent: DRACULA.purple,
17}
18
19export function sameChip(a: Chip | null, b: Chip | null) {
20 return JSON.stringify(a) === JSON.stringify(b)
21}
22
23export const BAR_STALE_MS = 30_000
24
25export function barIsLive(beat: number | undefined, now: number) {
26 return typeof beat === 'number' && now - beat < BAR_STALE_MS
27}
28src/shared/theme.ts 15 lines1// Dracula palette: https://draculatheme.com/contribute#color-palette
2export const DRACULA = {
3 background: '#282a36',
4 currentLine: '#44475a',
5 foreground: '#f8f8f2',
6 comment: '#6272a4',
7 cyan: '#8be9fd',
8 green: '#50fa7b',
9 orange: '#ffb86c',
10 pink: '#ff79c6',
11 purple: '#bd93f9',
12 red: '#ff5555',
13 yellow: '#f1fa8c',
14} as const
15ui/chip.tsx 13 lines1import type { Chip } from '../src/shared/chip'
2import type { Goal, Profile } from '../types'
3import { activityOf, byStatus } from '../src/dot'
4
5// Its tile in the mods bar: one word of status, a press opens its own pane
6export function dotChip(profile: Profile, goals: Goal[]): Chip {
7 const { waiting } = byStatus(goals)
8 const { status } = activityOf(profile, goals)
9 const tone = waiting.length > 0 ? 'warn' : status === 'working' ? 'ok' : 'accent'
10 const text = waiting.length > 0 ? `${waiting.length} waiting on you` : status
11 return { icon: profile.emoji, label: profile.name.toLowerCase(), tone, parts: [{ text, tone: 'text', action: 'open' }] }
12}
13ui/pane.tsx 152 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Goal, Profile } from '../types'
4import { avatarSvg } from '../src/avatar'
5import { activityOf, byStatus, moodOf } from '../src/dot'
6import { DRACULA } from '../src/shared/theme'
7
8type Ui = Elements[RenderSurface]
9
10const HERO_PX = 120
11const RECENT_SHOWN = 6
12
13const STATUS_COLOR: Record<string, string> = {
14 working: DRACULA.green,
15 'waiting on you': DRACULA.orange,
16 paused: DRACULA.comment,
17 'up next': DRACULA.purple,
18 queued: DRACULA.purple,
19 idle: DRACULA.comment,
20}
21const RESULTS_SHOWN = 4
22
23export type DotPaneProps = {
24 ui: Ui
25 profile: Profile
26 goals: Goal[]
27 notice: string
28 memoryPath: string
29 onAdd: (title: string) => unknown
30 onAnswer: (id: string, answer: string) => unknown
31 onDone: (id: string) => unknown
32 onRemove: (id: string) => unknown
33 onPause: () => unknown
34 onRunNow: () => unknown
35 onSchedule: () => unknown
36}
37
38const MARK: Record<Goal['status'], { icon: string; color: string }> = {
39 working: { icon: '⟳', color: DRACULA.yellow },
40 queued: { icon: '○', color: DRACULA.comment },
41 waiting: { icon: '!', color: DRACULA.orange },
42 done: { icon: '✓', color: DRACULA.green },
43}
44
45// A profile card like an agent's: who it is, what it is doing, what it did
46export function DotPane(props: DotPaneProps) {
47 const { ui, profile, goals, notice } = props
48 const { Box, Text, Button } = ui
49 const { waiting, active, done } = byStatus(goals)
50 const { status, detail } = activityOf(profile, goals)
51 const recent = [...waiting, ...active].slice(0, RECENT_SHOWN)
52 const results = done.filter(g => g.notes.length > 0).slice(0, RESULTS_SHOWN)
53
54 return (
55 <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={2} paddingY={1} rowGap={1}>
56 <Box flexDirection="column" alignItems="center" rowGap={0} paddingY={1}>
57 {'Svg' in ui
58 ? <ui.Svg source={avatarSvg(moodOf(profile, goals), HERO_PX)} alt={`${profile.name}, ${status}`} width={HERO_PX} height={HERO_PX} isInteractive />
59 : <Text color={DRACULA.purple}>{profile.emoji}</Text>}
60 <Text color={DRACULA.foreground} bold>{profile.name}</Text>
61 <Text color={STATUS_COLOR[status] ?? DRACULA.comment}>{status}</Text>
62 <Text color={DRACULA.comment} wrap="wrap">{detail}</Text>
63 <Text color={profile.paused ? DRACULA.comment : profile.scheduled ? DRACULA.green : DRACULA.orange}>
64 {profile.paused ? 'Paused' : profile.scheduled ? 'Works in the background' : 'Background work off'}
65 </Text>
66 </Box>
67
68 <Box flexDirection="row" gap={1} justifyContent="center">
69 {profile.scheduled
70 ? (
71 <Box flexDirection="row" gap={1}>
72 <Button key="run-now" label="Run now" dimColor onPress={props.onRunNow} />
73 <Button key="pause" label={profile.paused ? 'Resume' : 'Pause'} dimColor onPress={props.onPause} />
74 </Box>
75 )
76 : <Button key="schedule" label="Turn on background work" dimColor onPress={props.onSchedule} />}
77 </Box>
78 {notice !== '' && <Text color={DRACULA.cyan}>{notice}</Text>}
79
80 <GoalInput ui={ui} name={profile.name} onAdd={props.onAdd} />
81
82 {waiting.map(goal => (
83 <Box key={`waiting-${goal.id}`} flexDirection="column" borderStyle="round" borderColor={DRACULA.orange} paddingX={1}>
84 <Text color={DRACULA.orange} bold>{goal.title}</Text>
85 <Text color={DRACULA.foreground}>{goal.question || 'Needs your decision.'}</Text>
86 <AnswerInput ui={ui} goal={goal} onAnswer={props.onAnswer} />
87 </Box>
88 ))}
89
90 <Box flexDirection="column">
91 <Text color={DRACULA.comment}>Recent activity</Text>
92 {recent.length === 0 && <Text color={DRACULA.comment} italic>Nothing yet. Give {profile.name} a goal above.</Text>}
93 {recent.map(goal => <ActivityRow ui={ui} goal={goal} onDone={props.onDone} onRemove={props.onRemove} />)}
94 </Box>
95
96 <Box flexDirection="column">
97 <Text color={DRACULA.comment}>Results</Text>
98 {results.length === 0 && <Text color={DRACULA.comment} italic>No results yet.</Text>}
99 {results.map(goal => (
100 <Box key={`result-${goal.id}`} flexDirection="column">
101 <Box flexDirection="row" gap={1}>
102 <Text color={DRACULA.green}>✓</Text>
103 <Box flexGrow={1} flexShrink={1}><Text color={DRACULA.foreground} wrap="truncate-end">{goal.title}</Text></Box>
104 <Button key={`remove-${goal.id}`} label="Clear" dimColor plain onPress={() => props.onRemove(goal.id)} />
105 </Box>
106 <Box paddingLeft={2}><Text color={DRACULA.comment}>{goal.notes[goal.notes.length - 1]}</Text></Box>
107 </Box>
108 ))}
109 </Box>
110
111 <Text color={DRACULA.comment}>Memory and rules live in {props.memoryPath.replace(/\/memory\.md$/, '')}</Text>
112 </Box>
113 )
114}
115
116type RowProps = { ui: Ui; goal: Goal; onDone: (id: string) => unknown; onRemove: (id: string) => unknown }
117
118function ActivityRow({ ui, goal, onDone, onRemove }: RowProps) {
119 const { Box, Text, Button } = ui
120 const mark = MARK[goal.status]
121 const last = goal.notes[goal.notes.length - 1] ?? ''
122
123 return (
124 <Box key={`goal-${goal.id}`} flexDirection="column">
125 <Box flexDirection="row" gap={1}>
126 <Text color={mark.color}>{mark.icon}</Text>
127 <Box flexGrow={1} flexShrink={1}><Text color={DRACULA.foreground} wrap="truncate-end">{goal.title}</Text></Box>
128 {goal.status !== 'waiting' && <Button key={`done-${goal.id}`} label="Done" dimColor plain onPress={() => onDone(goal.id)} />}
129 <Button key={`remove-${goal.id}`} label="Remove" dimColor plain onPress={() => onRemove(goal.id)} />
130 </Box>
131 {last !== '' && <Box paddingLeft={2}><Text color={DRACULA.comment} wrap="truncate-end">{last}</Text></Box>}
132 </Box>
133 )
134}
135
136function GoalInput({ ui, name, onAdd }: { ui: Ui; name: string; onAdd: (title: string) => unknown }) {
137 if (!('Input' in ui)) return undefined
138 const { Input } = ui
139 return <Input key="new-goal" label={`Message ${name}`} placeholder="Give it a goal" value="" onSubmit={(text: string) => void onAdd(text)} />
140}
141
142function AnswerInput({ ui, goal, onAnswer }: { ui: Ui; goal: Goal; onAnswer: (id: string, answer: string) => unknown }) {
143 if (!('Input' in ui)) return undefined
144 const { Input } = ui
145 return <Input key={`answer-${goal.id}`} label="Answer" placeholder="Your decision" value="" onSubmit={(text: string) => void onAnswer(goal.id, text)} />
146}
147
148export function PaneError({ ui, message }: { ui: Ui; message: string }) {
149 const { Box, Text } = ui
150 return <Box padding={1}><Text color={DRACULA.red}>Dot pane failed to draw: {message}</Text></Box>
151}
152src/avatar.ts 113 lines1export type Mood = 'working' | 'waiting' | 'idle' | 'paused'
2
3// A chibi vampire drawn in the Dracula palette; each mood swaps the face and the effects around it
4const PALETTE = {
5 ink: '#282a36',
6 hair: '#2b2540',
7 hairShine: '#6a5a9c',
8 skin: '#fdf0f5',
9 skinShade: '#f1d9e6',
10 blush: '#ff79c6',
11 iris: '#ff5555',
12 capeOut: '#6c4fb8',
13 capeIn: '#ff5555',
14 collar: '#4b3a86',
15 gold: '#f1fa8c',
16 green: '#50fa7b',
17 orange: '#ffb86c',
18 purple: '#bd93f9',
19} as const
20
21const STYLE = `
22.bob{animation:bob 2.6s ease-in-out infinite;transform-origin:50% 60%}
23.cape{animation:cape 3.2s ease-in-out infinite;transform-box:fill-box;transform-origin:50% 0}
24.eye{animation:blink 4.2s infinite;transform-box:fill-box;transform-origin:center}
25.blush{animation:blush 2.6s ease-in-out infinite}
26.ring{animation:ring 2.2s ease-out infinite;transform-box:fill-box;transform-origin:center}
27.spark{animation:spark 1.8s ease-in-out infinite;transform-box:fill-box;transform-origin:center}
28.s2{animation-delay:.6s}.s3{animation-delay:1.2s}
29.bang{animation:bang 1s ease-in-out infinite;transform-box:fill-box;transform-origin:50% 100%}
30.zz{animation:zz 3s ease-in-out infinite}.z2{animation-delay:1s}
31.bat{animation:bat .35s ease-in-out infinite alternate;transform-box:fill-box;transform-origin:center}
32.fly{animation:fly 6s ease-in-out infinite}
33@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
34@keyframes cape{0%,100%{transform:skewX(0) scaleX(1)}50%{transform:skewX(-3deg) scaleX(1.03)}}
35@keyframes blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.08)}}
36@keyframes blush{0%,100%{opacity:.35}50%{opacity:.6}}
37@keyframes ring{0%{transform:scale(.85);opacity:.55}100%{transform:scale(1.25);opacity:0}}
38@keyframes spark{0%,100%{transform:scale(.4);opacity:0}50%{transform:scale(1);opacity:1}}
39@keyframes bang{0%,100%{transform:rotate(-8deg)}50%{transform:rotate(8deg)}}
40@keyframes zz{0%{opacity:0;transform:translate(0,0)}30%{opacity:1}100%{opacity:0;transform:translate(6px,-12px)}}
41@keyframes bat{from{transform:scaleY(1)}to{transform:scaleY(.55)}}
42@keyframes fly{0%,100%{transform:translate(0,0)}50%{transform:translate(-6px,-4px)}}
43`
44
45function eyes(mood: Mood) {
46 const p = PALETTE
47 if (mood === 'paused') {
48 return `<path d="M38 55 q5 4 10 0 M62 55 q5 4 10 0" stroke="${p.ink}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`
49 }
50 const eye = (cx: number) => `<g class="eye">
51 <ellipse cx="${cx}" cy="55" rx="5.6" ry="6.6" fill="${p.ink}"/>
52 <ellipse cx="${cx}" cy="56.6" rx="3.6" ry="4.2" fill="${p.iris}"/>
53 <circle cx="${cx - 1.8}" cy="52.6" r="1.9" fill="#fff"/>
54 <circle cx="${cx + 1.9}" cy="58.2" r=".9" fill="#fff" opacity=".8"/>
55 </g>`
56 return eye(43) + eye(67)
57}
58
59function mouth(mood: Mood) {
60 const p = PALETTE
61 if (mood === 'waiting') return `<ellipse cx="55" cy="68.5" rx="2.6" ry="3" fill="${p.ink}"/>`
62 if (mood === 'paused') return `<path d="M51 68 q4 2 8 0" stroke="${p.ink}" stroke-width="1.8" fill="none" stroke-linecap="round"/>`
63 return `<path d="M49 66.5 q6 5 12 0" stroke="${p.ink}" stroke-width="1.8" fill="none" stroke-linecap="round"/>
64 <path d="M51.2 67.6 l1.3 3.4 1.3-3 z" fill="#fff" stroke="${p.ink}" stroke-width=".5" stroke-linejoin="round"/>`
65}
66
67function effects(mood: Mood) {
68 const p = PALETTE
69 if (mood === 'working') {
70 return `<circle class="ring" cx="55" cy="60" r="38" fill="none" stroke="${p.purple}" stroke-width="1.6"/>
71 <path class="spark" d="M16 30 l1.5 4 4 1.5 -4 1.5 -1.5 4 -1.5-4 -4-1.5 4-1.5z" fill="${p.gold}"/>
72 <path class="spark s2" d="M94 24 l1.2 3 3 1.2 -3 1.2 -1.2 3 -1.2-3 -3-1.2 3-1.2z" fill="${p.purple}"/>
73 <path class="spark s3" d="M98 72 l1 2.6 2.6 1 -2.6 1 -1 2.6 -1-2.6 -2.6-1 2.6-1z" fill="${p.green}"/>`
74 }
75 if (mood === 'waiting') {
76 return `<g class="bang"><circle cx="92" cy="22" r="10" fill="${p.orange}"/>
77 <rect x="90.6" y="15" width="2.8" height="8" rx="1.4" fill="${p.ink}"/><circle cx="92" cy="27" r="1.6" fill="${p.ink}"/></g>`
78 }
79 if (mood === 'paused') {
80 return `<text class="zz" x="84" y="30" font-family="system-ui,sans-serif" font-size="11" font-weight="600" fill="${p.purple}">z</text>
81 <text class="zz z2" x="91" y="22" font-family="system-ui,sans-serif" font-size="8" font-weight="600" fill="${p.purple}">z</text>`
82 }
83 return `<g class="fly"><g class="bat" transform="translate(90 26)">
84 <path d="M0 0 q-6-6-12-2 q4 1 4 5 q3-3 8-3z M0 0 q6-6 12-2 q-4 1-4 5 q-3-3-8-3z" fill="#6272a4"/>
85 <circle cx="0" cy="1" r="2.6" fill="#6272a4"/></g></g>`
86}
87
88export function avatarSvg(mood: Mood, size = 96) {
89 const p = PALETTE
90 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 110 110">
91<style>${STYLE}</style>
92<rect width="110" height="110" rx="22" fill="${p.ink}"/>
93${effects(mood)}
94<g class="bob">
95 <path class="cape" d="M22 76 Q55 64 88 76 L98 110 L12 110 Z" fill="${p.capeOut}"/>
96 <path d="M27 52 Q22 70 34 80 L46 74 Z M83 52 Q88 70 76 80 L64 74 Z" fill="${p.collar}"/>
97 <path d="M40 76 Q55 70 70 76 L74 110 L36 110 Z" fill="${p.ink}"/>
98 <path d="M46 75 L55 92 L64 75 Q55 79 46 75 Z" fill="#fff"/>
99 <path d="M49.5 82 L55 85 L60.5 82 L60.5 88 L55 85 L49.5 88 Z" fill="${p.capeIn}"/>
100 <circle cx="55" cy="85" r="1.6" fill="${p.capeIn}"/>
101 <path d="M30 54 L22 46 L32 50 Z M80 54 L88 46 L78 50 Z" fill="${p.skin}" stroke="${p.skinShade}" stroke-width="1" stroke-linejoin="round"/>
102 <ellipse cx="55" cy="56" rx="25" ry="23" fill="${p.skin}"/>
103 <ellipse cx="55" cy="66" rx="21" ry="10" fill="${p.skinShade}" opacity=".4"/>
104 <path d="M30 52 Q29 29 55 28 Q81 29 80 52 Q78 42 70 39 Q63 40 55 46 Q47 40 40 39 Q32 42 30 52 Z" fill="${p.hair}"/>
105 <path d="M41 33.5 Q52 29.5 63 32" stroke="${p.hairShine}" stroke-width="2.2" fill="none" stroke-linecap="round" opacity=".85"/>
106 <ellipse class="blush" cx="37" cy="63" rx="5" ry="3" fill="${p.blush}"/>
107 <ellipse class="blush" cx="73" cy="63" rx="5" ry="3" fill="${p.blush}"/>
108 ${eyes(mood)}
109 ${mouth(mood)}
110</g>
111</svg>`
112}
113types/index.d.ts 31 lines1export type ChipTone = 'text' | 'muted' | 'ok' | 'warn' | 'bad' | 'info' | 'accent'
2export type ChipPart = { text: string; tone: ChipTone; bold?: boolean; action?: string }
3export type Chip = { icon: string; label: string; tone: ChipTone; parts: ChipPart[] }
4export type ChipPress = { plugin: string; action: string; at: number }
5
6export type GoalStatus = 'queued' | 'working' | 'waiting' | 'done'
7export type Goal = {
8 id: string
9 title: string
10 status: GoalStatus
11 notes: string[]
12 question: string
13 createdAt: number
14 updatedAt: number
15}
16export type Profile = { name: string; emoji: string; paused: boolean; scheduled: boolean }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'mod-manager': { bar: number; press: ChipPress | null }
21 dot: {
22 active: boolean
23 chip: Chip | null
24 profile: Profile
25 goals: Goal[]
26 isOpen: boolean
27 notice: string
28 }
29 }
30}
31