SLOPSHOPPER

Dot

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…

newpanebandcommandtoasttimer
v1.2.1MITupdated 2026-10-09lucasleandro08/claude-mods/plugins/dot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dot
│ ┃ dot ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ This mod is off. Turn it on in /mods. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dot │ ⎿ dot: This mod is off. Turn it on in /mods. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dot
This mod is off. Turn it on in /mods.
README

claude-mods

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.

Install

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.

Mods

ModWhat it doesCommands
mod-managerThe 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-diffA 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-paneA ⎈ 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-paneYour 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
guardrailsBlocks 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-loopHands 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-doneA toast and a chime when a long turn finishes.—
aws-profileEach 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-paneA 🐳 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-watchWatches 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
worktreesA 🌳 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
dotAn 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`

Options

ModOptionDefault
mod-managerShow the Mods chip · open on start until a mod is on · claude CLI pathon · on · PATH
kube-paneProduction context pattern · default namespace · refresh interval · pods per page · kubectl pathprod · default · 15 s · 25 · PATH
pr-paneIntegration branch · Codex bot login · refresh interval · PRs to load · gh pathstaging · chatgpt-codex-connector[bot] · 5 min · 50 · PATH
guardrailsProduction pattern · confirm production writes · block kubeconfig changes · block printing secrets · protect Docker portsprod · on · on · on · on
promote-branchTarget branch · protected branchesstaging · main,master
live-diffContext lines · edits kept2 · 100
turn-doneLong turn threshold · play sound60 s · on
codex-loopCodex bot login · maximum review rounds · wait for Codexchatgpt-codex-connector[bot] · 5 · 15 min
aws-profileProduction pattern · profiles · check interval · aws pathprod · all · 5 min · PATH
docker-paneRefresh interval · docker path60 s (10 s while open) · PATH
deploy-watchProjects (name=branch, comma-separated) · AWS profile · region · poll interval · missing build warning · production pattern · aws pathnone · CLI default · profile's · 60 s · 3 min · prod · PATH
worktreesRoots (folders holding your repos) · refresh interval · gh pathsession repo only · 5 min · PATH
dotWork round interval · morning briefing (weekdays) · open its pane on start30 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.

Rolling out to a whole organization

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.

Development

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.

Layout

.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.

License

MIT


🇧🇷 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.

Source 9 files
hooks/register.tsx 276 lines
1import { 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}
276
src/dot.ts 191 lines
1import 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}
191
src/shared/toggle.ts 31 lines
1export 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}
31
src/shared/chip.ts 28 lines
1import { 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}
28
src/shared/theme.ts 15 lines
1// 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
15
ui/chip.tsx 13 lines
1import 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}
13
ui/pane.tsx 152 lines
1import 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}
152
src/avatar.ts 113 lines
1export 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}
113
types/index.d.ts 31 lines
1export 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