SLOPSHOPPER

haunted-repo

Delete a file and its ghost haunts your prompt, whispering fragments of its lost code until you restore it. Seances, exorcisms, poltergeists and secrets.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · haunted-repo
│ ┃ ⚰️ Graveyard ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ⚰️ 3 restless · ✨ 0 at peace │ haunted-repo │ │ ┃ Restore a file to free its ghost · /seance ⏺ Read(src/auth.ts) │ 👻👻👻 "My name is LEGION, for we are │ │ ┃ to speak · /exorcise to banish ⎿ Read 6 lines │ many." A horde rises from │ │ ┃ ⏺ Update(src/auth.ts) │ .haunted-demo/node_modules │ │ ┃ ╭─────────────╮ ╭─────────────╮ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ │ R . I . P . │ │ R . I . P . │ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ┃ │legacy_auth.js │ │ LEGION │ ⎿ 3 pass, 1 fail │ haunted-repo │ │ ┃ │ 666 lines │ │ 48213 souls │ │ 👻 The ghost of wisp of utils.ts rises… │ │ ┃ │ 10/9/2025 │ │ 10/9/2025 │ ● Done. refresh now re│ ooOOooo… │ │ ┃ │Its last words:│ │Gone, but not .│ ╰────────────────────────────────────────────╯ │ ┃ ▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ ▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────╮ │ ┃ │ R . I . P . │ › /graveyard │ ┃ │wisp of utils.…│ ⎿ haunted-repo: The graveyard is quiet. Suspiciously quiet. │ ┃ │ 42 lines │ │ ┃ │ 10/9/2025 │ │ ┃ │Gone, but not .│ │ ┃ ▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ │ ⚡̴ ∿͗░̀ ▒͛▒͒ ░̸░̸ ░́▒͊▒͆ ▒͒ ▒͛ ▒͆▒͛ ▒̶ ▒̶ ▒́▒͆ ▒̵ ▒͆ ░͗ ▒̴░͒░͒ ▒͗ 👥̀ ░͒░͛░͊👻́▒͒ ⚡̴ ⚰̶ 3͒ “͊/́/͗ t͊e̷m͗p͗o͒r̴a̶r͗y͗ f̷i͗x̵,͗ r̶e̵m̀o͒v̸e͛ b͗e͊f͊o͒r̵e͗ l͗a̴u̵n̷c͛h͒”͒ —̶ w̴i͊s͒p̷ o̵f̀ u̴t̴i̷l͊s̴.͊t̸s̴ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚡̴ ∿͗░̀ ▒͛▒͒ ░̸░̸ ░́▒͊▒͆ ▒͒ ▒͛ ▒͆▒͛ ▒̶ ▒̶ ▒́▒͆ ▒̵ ▒͆ ░͗ ▒̴░͒░͒ ▒͗ 👥̀ ░͒░͛░͊👻́▒͒ ⚡̴ ⚰̶ 3͒ “͊/́/͗ t͊e̷m͗p͗o͒r̴a̶r͗y͗ f̷i͗x̵,͗ r̶e̵m̀o͒v̸e͛ b͗e͊f͊o͒r̵e͗ l͗a̴u̵n̷c͛h͒”͒ —̶ w̴i͊s͒p̷ o̵f̀ u̴t̴i̷l͊s̴.͊t̸s̴
Pane · ⚰️ Graveyard
⚰️ 3 restless · ✨ 0 at peace Restore a file to free its ghost · /seance to speak · /exorcise to banish ╭─────────────╮ ╭─────────────╮ │ R . I . P . │ │ R . I . P . │ │legacy_auth.js │ │ LEGION │ │ 666 lines │ │ 48213 souls │ │ 10/9/2025 │ │ 10/9/2025 │ │Its last words:│ │Gone, but not .│ ▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ ▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ ╭─────────────╮ │ R . I . P . │ │wisp of utils.…│ │ 42 lines │ │ 10/9/2025 │ │Gone, but not .│ ▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁
README

👻 Haunted Repo

A Claude Code mod, just in time for Halloween.

Every file Claude deletes comes back as a ghost. It drifts above your prompt and whispers real lines of its lost code, fading a little with every whisper, until you restore the file and set it free.

 ·    👻           👥                 ⛓          ⚰ 3 · witching hour
  “// TODO: write unit tests (never happened)  …from beyond” — sacrifice.js

Haunted Repo demo

→ Quick Start

Install

/plugin marketplace add choaticpixels/HauntedClaudeMod
/plugin install haunted-repo@claude-mods

What haunts you

EventWhat happens
rm, git rm, del, Remove-Item, emptying a file👻 The file's ghost rises, carrying its code
Deleting a folder, a glob or node_modules👥 LEGION rises: "for we are many"
An Edit that cuts 10+ lines∿ A wisp of the lost code lingers
Restoring the file✨ The ghost is laid to rest
A new sessionGhosts follow you there

Commands

  • /haunt-demo: summon three demo ghosts and see the haunting right away (no files are touched)
  • /graveyard: a pane of ASCII tombstones with dates and epitaphs
  • /seance [ghost] [question]: the ghost answers in character, quoting its own code (uses Haiku)
  • /exorcise [name|all]: banish a ghost. Some refuse, and bind themselves to your repo ⛓

Easter eggs

There are a lot of them. Some hints:

  • Some words are worth typing alone: a scare, a sneeze of respect, a doorstep question.
  • Who are you gonna call?
  • Never say her name three times.
  • Force-pushing is how poltergeists are born.
  • Keep an eye on your status line every so often. 🐈‍⬛
  • The dead are louder at certain hours, and on one particular night.
  • Old-school gamers know a code that lets the dead speak.

boo · who you gonna call · bloody mary ×3 · trick or treat · spooky · f · up up down down left right left right b a (Ghost Mode) · git push --force · git commit -m "fix…" · git reset --hard · every 13th tool call · 13 ghosts · deleting a file named ghost · 3 AM · midnight · October 31 · Friday the 13th

License

MIT

Source 2 files
hooks/register.tsx 584 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fx, Ghost, Whisper } from '../types'
5
6type $ = EngineInterface
7
8const PANE = 'haunted-graveyard'
9const MAX_GHOSTS = 13
10
11const ghosts = atom({ plugin: 'haunted-repo', key: 'ghosts' } as const, [] as Ghost[])
12const frame = atom({ plugin: 'haunted-repo', key: 'frame' } as const, 0)
13const whisper = atom({ plugin: 'haunted-repo', key: 'whisper' } as const, null as Whisper | null)
14const ghostMode = atom({ plugin: 'haunted-repo', key: 'ghostMode' } as const, false)
15const cat = atom({ plugin: 'haunted-repo', key: 'cat' } as const, -1)
16const NO_FX: Fx = { poltergeistUntil: 0, bustedUntil: 0, trickUntil: 0, mirrorUntil: 0, danceUntil: 0, candlesUntil: 0 }
17const fx = atom({ plugin: 'haunted-repo', key: 'fx' } as const, NO_FX)
18
19// ───────────────────────── little helpers ─────────────────────────
20
21const pick = <T,>(list: readonly T[]): T => list[Math.floor(Math.random() * list.length)]
22const hash = (s: string) => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7)
23const norm = (p: string) => p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').toLowerCase()
24const baseName = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || p
25const field = (e: unknown, key: string): string | undefined => {
26  const v = (e as Record<string, unknown>)[key]
27  return typeof v === 'string' ? v : undefined
28}
29
30const EPITAPHS = [
31  'It compiled once.',
32  'Gone, but not .gitignored.',
33  'Worked on my machine.',
34  'Deprecated too soon.',
35  'Refactored into oblivion.',
36  'So many TODOs left undone.',
37  'Never got its unit tests.',
38  'Killed by rm. Mourned by none.',
39  'It was only 2 weeks from v1.0.',
40  'Died as it lived: untyped.',
41  'Its last words: "temporary fix".',
42  'Here lies tech debt. Paid in full.',
43  'Merged with the void.',
44]
45
46const MOANS = ['ooOOooo…', 'whoooo…', 'hhhhhhh…', '…why…', 'mmmmMMMmm…', '…remember me…']
47
48const TREATS = [
49  '🍬 A semicolon. Every dev needs one.',
50  '🍫 A green CI badge (expires in 5 minutes).',
51  '🍭 A null check you forgot to write. Free of charge.',
52  '🍪 A cookie. GDPR-compliant.',
53  '🎃 One (1) mass-assigned "LGTM".',
54  '🍬 git stash pop, but it actually works this time.',
55]
56
57const NAMES_OF_THE_HORDE = ['LEGION', 'THE NAMELESS HORDE', 'THE MANY', 'THE SWARM']
58
59// Zalgo: the voice of the poltergeist.
60const ZALGO = ['̀', '́', '̴', '̵', '̶', '̷', '̸', '͆', '͊', '͒', '͗', '͛']
61const zalgo = (s: string, n = 2) => [...s].map(c => (c === ' ' ? c : c + Array.from({ length: n }, () => pick(ZALGO)).join(''))).join('')
62
63// Upside-down text for the "trick".
64const FLIP: Record<string, string> = {
65  a: 'ɐ', b: 'q', c: 'ɔ', d: 'p', e: 'ǝ', f: 'ɟ', g: 'ƃ', h: 'ɥ', i: 'ᴉ', j: 'ɾ', k: 'ʞ', l: 'ʃ', m: 'ɯ', n: 'u', o: 'o',
66  p: 'd', q: 'b', r: 'ɹ', s: 's', t: 'ʇ', u: 'n', v: 'ʌ', w: 'ʍ', x: 'x', y: 'ʎ', z: 'z', '.': '˙', ',': "'", '?': '¿',
67  '!': '¡', '"': ',,', "'": ',', '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<', _: '‾',
68}
69const flip = (s: string) => [...s.toLowerCase()].map(c => FLIP[c] ?? c).reverse().join('')
70
71// A ghost fades as it whispers; its words fade with it.
72const strength = (g: Ghost) => (g.isBound ? 1 : Math.max(0, 1 - g.whispers / 40))
73const fade = (s: string, st: number) => [...s].map(c => (c !== ' ' && Math.random() > st + 0.15 ? '·' : c)).join('')
74
75const timeOf = async ($: $) => {
76  const now = await $.clock.now()
77  const d = new Date(now)
78  return {
79    now,
80    isWitching: d.getHours() === 3,
81    isMidnight: d.getHours() === 0 && d.getMinutes() === 0,
82    isHalloween: d.getMonth() === 9 && d.getDate() === 31,
83    isFriday13: d.getDay() === 5 && d.getDate() === 13,
84    day: d.toDateString(),
85  }
86}
87
88// ───────────────────────── the graveyard ─────────────────────────
89
90const setGhosts = async ($: $, fn: (list: Ghost[]) => Ghost[]) => {
91  const list = await update($, ghosts, fn)
92  await $.store.set('graveyard', await read($, ghosts))
93  return list
94}
95
96const setFx = ($: $, patch: Partial<Fx>) => update($, fx, f => ({ ...NO_FX, ...f, ...patch }))
97
98const fragmentsOf = (text: string) =>
99  text
100    .split(/\r?\n/)
101    .map(l => l.trim())
102    .filter(l => l.length > 3 && l.length < 90 && !/^[{}()[\];,]+$/.test(l))
103    .slice(0, 60)
104
105const summon = async ($: $, ghost: Omit<Ghost, 'id' | 'diedAt' | 'whispers' | 'isBound'>) => {
106  const { now } = await timeOf($)
107  const full: Ghost = { ...ghost, id: `${now}-${Math.floor(Math.random() * 1e6)}`, diedAt: now, whispers: 0, isBound: false }
108  const before = (await read($, ghosts)).length
109  await setGhosts($, list => [...list.filter(g => norm(g.path) !== norm(ghost.path)), full].slice(-MAX_GHOSTS))
110
111  if (/ghost|haunt|spook/i.test(ghost.name)) {
112    $.ui.toast(`👻 You cannot kill what is already dead. ${ghost.name} laughs at you.`, { timeoutMs: 6000 })
113  } else if (ghost.kind === 'legion') {
114    $.ui.toast(`👻👻👻 "My name is ${ghost.name}, for we are many." A horde rises from ${ghost.path}`, { timeoutMs: 7000 })
115  } else if (ghost.lines.some(l => /TODO|FIXME|HACK/.test(l))) {
116    $.ui.toast(`👻 ${ghost.name} died with unfinished business (TODOs). It will not rest easy.`, { timeoutMs: 6000 })
117  } else {
118    $.ui.toast(`👻 The ghost of ${ghost.name} rises… ${pick(MOANS)}`, { timeoutMs: 5000 })
119  }
120
121  if (before + 1 === MAX_GHOSTS) {
122    $.ui.toast('🕯️ THIRTEEN GHOSTS. The circle is complete. Something old is watching.', { timeoutMs: 9000 })
123  }
124}
125
126const layToRest = async ($: $, g: Ghost, how: string) => {
127  await setGhosts($, list => list.filter(x => x.id !== g.id))
128  const rested = Number((await $.store.get('laidToRest')) ?? 0) + 1
129  await $.store.set('laidToRest', rested)
130  $.ui.toast(`✨ ${g.name} ${how}. It is at peace. (${rested} souls freed)`, { timeoutMs: 6000 })
131}
132
133const whisperNow = async ($: $, ghostId?: string, override?: string) => {
134  const list = await read($, ghosts)
135  if (list.length === 0) return
136  const { now } = await timeOf($)
137  const g = (ghostId && list.find(x => x.id === ghostId)) || pick(list)
138  const todo = g.lines.filter(l => /TODO|FIXME|HACK/.test(l))
139  const line = override ?? (todo.length && Math.random() < 0.5 ? `${pick(todo)}  …from beyond` : g.lines.length ? pick(g.lines) : pick(MOANS))
140  await update($, whisper, () => ({ ghostId: g.id, name: g.name, text: line, at: now }))
141  if (!override) {
142    const faded = (await setGhosts($, all => all.map(x => (x.id === g.id ? { ...x, whispers: x.whispers + 1 } : x)))).find(x => x.id === g.id)
143    if (faded && strength(faded) === 0) {
144      await setGhosts($, all => all.filter(x => x.id !== g.id))
145      $.ui.toast(`🌫️ The ghost of ${g.name} has whispered its last, and fades into the ether…`, { timeoutMs: 6000 })
146    }
147  }
148}
149
150// ───────────────────────── reading shell commands ─────────────────────────
151
152const tokens = (seg: string) => (seg.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(t => t.replace(/^["']|["']$/g, ''))
153
154/** The paths a command deletes: rm, git rm, del, rmdir, Remove-Item, unlink. */
155const victimsOf = (command: string) => {
156  const out: string[] = []
157  for (const seg of command.split(/&&|\|\||;|\|/)) {
158    const t = tokens(seg.trim())
159    let args: string[] | undefined
160    const head = (t[0] ?? '').toLowerCase()
161    if (['rm', 'del', 'erase', 'rmdir', 'rd', 'unlink', 'remove-item', 'ri'].includes(head)) args = t.slice(1)
162    else if (head === 'git' && t[1] === 'rm') args = t.slice(2)
163    if (!args) continue
164    for (const a of args) if (!a.startsWith('-') && a !== '--') out.push(a)
165  }
166  return out
167}
168
169// ───────────────────────── the hooks ─────────────────────────
170
171export const register: Register = on => {
172  let toolCount = 0
173
174  on('session.start', async ($, e, next) => {
175    for (const [name, description] of [
176      ['graveyard', 'Visit the graveyard of files you have deleted'],
177      ['seance', 'Hold a seance and speak with a ghost: /seance [ghost] [question]'],
178      ['exorcise', 'Banish a ghost (or "all"). Some refuse to go.'],
179      ['haunt-demo', 'Summon three demo ghosts to see the haunting (no files are touched)'],
180    ] as const) {
181      await $.command.register({ name, description })
182    }
183
184    // Ghosts follow you between sessions.
185    const saved = ((await $.store.get('graveyard')) ?? []) as Ghost[]
186    if ((await read($, ghosts)).length === 0 && saved.length > 0) {
187      await update($, ghosts, () => saved)
188      $.ui.toast(`👻 ${saved.length} ghost${saved.length === 1 ? '' : 's'} followed you here from another session…`, { timeoutMs: 6000 })
189    }
190
191    const t = await timeOf($)
192    if (t.isHalloween) $.ui.toast('🎃 Happy Halloween. The veil is thin tonight: ghosts whisper three times as often.', { timeoutMs: 8000 })
193    if (t.isFriday13) $.ui.toast('🐈‍⬛ Friday the 13th. Mind the black cats.', { timeoutMs: 6000 })
194
195    // The heartbeat of the haunting: animate, whisper, toll.
196    let ticks = 0
197    $.clock.every(450, () => {
198      void (async () => {
199        ticks++
200        const list = await read($, ghosts)
201        const f = await read($, fx)
202        const time = await timeOf($)
203        const isActive = list.length > 0 || Object.values(f).some(until => until > time.now) || time.isHalloween
204        if (isActive) await update($, frame, n => n + 1)
205
206        const every = time.isWitching ? 11 : time.isHalloween ? 15 : 40 // ticks between whispers
207        if (list.length > 0 && f.bustedUntil < time.now && ticks % every === 0) await whisperNow($)
208
209        if (time.isMidnight && (await $.store.get('tolled')) !== time.day) {
210          await $.store.set('tolled', time.day)
211          $.ui.toast('🔔 DONG… DONG… DONG… The clock strikes twelve. The dead grow restless.', { timeoutMs: 9000 })
212        }
213      })()
214    })
215
216    return next(e)
217  })
218
219  // ── Easter eggs hidden in what you type ──
220  on('prompt.submit', async ($, e, next) => {
221    const text = e.text.trim().toLowerCase()
222    const { now } = await timeOf($)
223    const list = await read($, ghosts)
224
225    if (text === 'boo' || text === 'boo!') {
226      $.ui.toast(list.length ? `👻 AAAAAH! …oh. It's just you. ${list.length} ghost${list.length > 1 ? 's' : ''} hide behind the README.` : '👻 Nobody here to scare. Yet.')
227    } else if (/who (ya|you) gonna call/.test(text)) {
228      await setFx($, { bustedUntil: now + 60_000 })
229      $.ui.toast('🚫👻 GHOSTBUSTERS! The ghosts flee for 60 seconds. They will be back.', { timeoutMs: 6000 })
230    } else if (/bloody mary/.test(text)) {
231      const n = Number((await $.store.get('bloodyMary')) ?? 0) + 1
232      await $.store.set('bloodyMary', n % 3)
233      if (n % 3 === 0) {
234        await setFx($, { mirrorUntil: now + 20_000 })
235        $.ui.toast('🪞 …', { timeoutMs: 3000 })
236      } else {
237        $.ui.toast(n % 3 === 1 ? '🪞 The mirror fogs over.' : '🪞 Something moves in the mirror. Once more and…', { timeoutMs: 4000 })
238      }
239    } else if (/trick or treat/.test(text)) {
240      if (Math.random() < 0.5) $.ui.toast(`TREAT! ${pick(TREATS)}`, { timeoutMs: 6000 })
241      else {
242        await setFx($, { trickUntil: now + 30_000 })
243        $.ui.toast(flip('TRICK! Your band is haunted for 30 seconds.'), { timeoutMs: 6000 })
244      }
245    } else if (/2spooky|spooky scary skeleton|spooky/.test(text)) {
246      await setFx($, { danceUntil: now + 12_000 })
247    } else if (text === 'f') {
248      await setFx($, { candlesUntil: now + 15_000 })
249      $.ui.toast(`🕯️ You pay your respects. A candle is lit for ${list.length || 'every'} lost file${list.length === 1 ? '' : 's'}.`)
250    } else if (text === 'up up down down left right left right b a') {
251      const isOn = await update($, ghostMode, v => !v)
252      void isOn
253      $.ui.toast((await read($, ghostMode)) ? '👻 GHOST MODE: the dead now narrate every tool call.' : '👻 Ghost mode off. The dead fall silent.', { timeoutMs: 6000 })
254    }
255
256    return next(e)
257  })
258
259  on('tool.call', async ($, e, next) => {
260    toolCount++
261    const command = field(e, 'command')
262    const victims = command ? victimsOf(command) : []
263    const filePath = field(e, 'file_path')
264
265    // Ghost mode: the dead comment on the living.
266    if (await read($, ghostMode)) {
267      const list = await read($, ghosts)
268      if (list.length) {
269        const g = pick(list)
270        const what = filePath ? baseName(filePath) : command ? command.slice(0, 40) : e.tool
271        await whisperNow($, g.id, pick([`${e.tool}ing ${what}… I remember when I was ${e.tool}ed…`, `${what}? ${pick(MOANS)} it won't save you…`, `I too was once ${what}…`, `careful with ${what}… that's how I died…`]))
272      }
273    }
274
275    // Look at what is about to die, while it still lives.
276    const doomed: { path: string; kind: Ghost['kind']; lines: string[]; lineCount: number }[] = []
277    for (const p of victims) {
278      if (p.includes('*')) {
279        doomed.push({ path: p, kind: 'legion', lines: [`we were ${p}`, 'we were many', 'we are many still'], lineCount: 0 })
280        continue
281      }
282      const stat = await $.fs.stat(p).catch(() => undefined)
283      if (!stat) continue
284      if (stat.kind === 'dir') {
285        const entries = await $.fs.list(p).catch(() => [])
286        doomed.push({ path: p, kind: 'legion', lines: entries.slice(0, 40).map(x => `I was ${x.name}`), lineCount: entries.length })
287      } else if (stat.kind === 'file') {
288        const text = stat.size < 1_000_000 ? await $.fs.read(p).catch(() => '') : ''
289        doomed.push({ path: p, kind: 'file', lines: fragmentsOf(text), lineCount: text.split(/\n/).length })
290      }
291    }
292
293    // A Write that empties a file kills it as surely as rm.
294    if (e.tool === 'Write' && filePath && (field(e, 'content') ?? '').trim() === '') {
295      const text = await $.fs.read(filePath).catch(() => '')
296      if (text.trim()) doomed.push({ path: filePath, kind: 'file', lines: fragmentsOf(text), lineCount: text.split(/\n/).length })
297    }
298
299    const ran = await next(e)
300    if (ran.deny || ran.isError) return ran
301
302    for (const d of doomed) {
303      const isGone = d.path.includes('*') || !(await $.fs.exists(d.path).catch(() => true)) || e.tool === 'Write'
304      if (!isGone) continue
305      const name = d.kind === 'legion' ? (/node_modules/.test(d.path) ? 'LEGION' : pick(NAMES_OF_THE_HORDE)) : baseName(d.path)
306      await summon($, { path: d.path, name, kind: d.kind, lines: d.lines, lineCount: d.lineCount })
307    }
308
309    // An Edit that cuts away a lot of code leaves a wisp behind.
310    if (e.tool === 'Edit' && filePath) {
311      const oldS = field(e, 'old_string') ?? ''
312      const newS = field(e, 'new_string') ?? ''
313      const lost = oldS.split(/\n/).length - newS.split(/\n/).length
314      if (lost >= 10) {
315        await summon($, { path: `${filePath}#wisp`, name: `wisp of ${baseName(filePath)}`, kind: 'wisp', lines: fragmentsOf(oldS), lineCount: lost })
316      }
317    }
318
319    if (command) {
320      const { now } = await timeOf($)
321      if (/git\s+push\b.*(--force\b|\s-f\b|--force-with-lease)/.test(command)) {
322        await setFx($, { poltergeistUntil: now + 30_000 })
323        $.ui.toast(zalgo('⚡ YOU HAVE AWAKENED THE POLTERGEIST', 1), { timeoutMs: 8000 })
324      } else if (/git\s+commit\b.*-m\s*["'][^"']*\bfix/i.test(command)) {
325        await whisperNow($, undefined, 'the bug… will… return…')
326      } else if (/git\s+reset\s+--hard/.test(command)) {
327        $.ui.toast('👻 git reset --hard… so many commits, lost to the dark. You hear distant screaming.', { timeoutMs: 6000 })
328      }
329    }
330
331    // Every 13th tool call, a black cat crosses your status line.
332    if (toolCount % 13 === 0) {
333      const steps = 24
334      for (let i = 0; i <= steps; i++) {
335        $.clock.after(i * 180, () => {
336          void update($, cat, () => (i === steps ? -1 : i / steps))
337          $.ui.status(i === steps ? undefined : `${' '.repeat(Math.round((steps - i) * 1.5))}🐈‍⬛${i % 2 ? ' ' : '·'}`)
338        })
339      }
340    }
341
342    return ran
343  })
344
345  // A ghost whose file returns is at peace.
346  on('turn.complete', async ($, e, next) => {
347    for (const g of await read($, ghosts)) {
348      if (g.kind === 'file' && (await $.fs.exists(g.path).catch(() => false))) await layToRest($, g, 'has been restored to the living')
349    }
350    return next(e)
351  })
352
353  // ── Commands ──
354  on('command.run', { command: 'graveyard' }, async $ => {
355    await $.ui.open({ id: PANE, title: '⚰️ Graveyard' })
356    const n = (await read($, ghosts)).length
357    return { text: n ? `You enter the graveyard. ${n} restless soul${n > 1 ? 's' : ''} stir.` : 'The graveyard is quiet. Suspiciously quiet.' }
358  })
359
360  // A haunting on demand: three ghosts of files that never existed, so no real file is touched.
361  on('command.run', { command: 'haunt-demo' }, async $ => {
362    await summon($, {
363      path: '.haunted-demo/legacy_auth.js',
364      name: 'legacy_auth.js',
365      kind: 'file',
366      lineCount: 666,
367      lines: [
368        '// TODO: replace md5 with something real (2014)',
369        'const ADMIN_PASSWORD = "hunter2" // FIXME',
370        'if (user.name === "admin") return true',
371        'function checkPassword(pw) { return pw.length > 3 }',
372        '// HACK: do not touch. nobody knows why this works',
373        'catch (e) { /* the void accepts all */ }',
374      ],
375    })
376    await summon($, {
377      path: '.haunted-demo/node_modules',
378      name: 'LEGION',
379      kind: 'legion',
380      lineCount: 48213,
381      lines: ['I was left-pad', 'I was is-odd', 'I was is-even, who depended on is-odd', 'we were 48,213', 'we are many still'],
382    })
383    await summon($, {
384      path: '.haunted-demo/utils.ts#wisp',
385      name: 'wisp of utils.ts',
386      kind: 'wisp',
387      lineCount: 42,
388      lines: ['export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))', 'any as unknown as any', '// temporary fix, remove before launch'],
389    })
390    await whisperNow($)
391    await $.ui.open({ id: PANE, title: '⚰️ Graveyard' })
392    return {
393      text:
394        '👻 Three demo ghosts rise from files that never existed. Watch the band above your prompt; they whisper every ~18s.\n' +
395        'Try: /seance legacy_auth.js what is your darkest secret?  ·  boo  ·  who you gonna call  ·  /exorcise all',
396    }
397  })
398
399  on('command.run', { command: 'exorcise' }, async ($, e) => {
400    const list = await read($, ghosts)
401    const arg = e.args.trim().toLowerCase()
402    const targets = arg === 'all' ? list : list.filter(g => !arg || g.name.toLowerCase().includes(arg)).slice(-1)
403    if (targets.length === 0) return { text: list.length ? `No ghost answers to "${arg}".` : 'There is nothing here to exorcise… for now.' }
404
405    const out: string[] = []
406    for (const g of targets) {
407      if (g.isBound) out.push(`⛓️ ${g.name} is bound to this repo. Only restoring its file will free it.`)
408      else if (Math.random() < 1 / 6) {
409        await setGhosts($, all => all.map(x => (x.id === g.id ? { ...x, isBound: true, whispers: 0 } : x)))
410        out.push(`😈 ${g.name} REFUSES TO LEAVE. It binds itself to your repo, stronger than before.`)
411      } else {
412        await setGhosts($, all => all.filter(x => x.id !== g.id))
413        out.push(`✝️ ${g.name} is cast out. ${pick(['It shrieks and is gone.', 'The temperature returns to normal.', 'The lights stop flickering.', 'A cold wind, then nothing.'])}`)
414      }
415    }
416    return { text: out.join('\n') }
417  })
418
419  on('command.run', { command: 'seance' }, async ($, e) => {
420    const list = await read($, ghosts)
421    if (list.length === 0) return { text: '🕯️ You light the candles and join hands… but no spirits answer. Delete something first.' }
422
423    const words = e.args.trim()
424    const g = list.find(x => words.toLowerCase().startsWith(x.name.toLowerCase())) ?? list.find(x => words.toLowerCase().includes(x.name.toLowerCase())) ?? list[list.length - 1]
425    const question = words.slice(words.toLowerCase().startsWith(g.name.toLowerCase()) ? g.name.length : 0).trim() || 'Who are you, and how did you die?'
426    const { now } = await timeOf($)
427    const days = Math.max(0, Math.round((now - g.diedAt) / 86_400_000))
428
429    const r = await $.model.complete({
430      model: 'haiku',
431      maxTokens: 300,
432      timeoutMs: 20_000,
433      system:
434        'You are the ghost of a deleted source file, speaking at a seance. Speak in a spooky, theatrical, darkly funny voice, ' +
435        'but stay grounded in your actual code: reference real identifiers, comments and TODOs from the fragments you are given. ' +
436        `You were ${g.kind === 'legion' ? 'a whole directory, a horde of files speaking as one' : g.kind === 'wisp' ? 'a block of code cut out during an edit' : 'a whole file'}. ` +
437        'Answer the living in 2 to 4 short sentences. Use ellipses. Never break character. No markdown headings.',
438      prompt: `You were: ${g.path} (${g.lineCount} lines), deleted ${days === 0 ? 'today' : `${days} days ago`}.${g.isBound ? ' You are bound to this repo and furious about it.' : ''}\n` +
439        `Fragments of your code:\n${g.lines.slice(0, 30).join('\n') || '(nothing remains but silence)'}\n\nThe living ask: ${question}`,
440    })
441
442    await whisperNow($, g.id, '…the veil parts…')
443    return { text: r.isAnswered ? `🕯️ The candles flicker. The ghost of ${g.name} speaks:\n\n${r.text.trim()}` : `🕯️ The candles gutter out. The spirit of ${g.name} could not reach you (${r.reason}).` }
444  })
445
446  // ── The band above the prompt: the haunting itself ──
447  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
448    if (e.props.hasSurvey) return next(e)
449    const list = await read($, ghosts)
450    const f = await read($, fx)
451    const w = await read($, whisper)
452    const t = await read($, frame)
453    const time = await timeOf($)
454    const now = time.now
455
456    const isPolter = f.poltergeistUntil > now
457    const isBusted = f.bustedUntil > now
458    const isTrick = f.trickUntil > now
459    const isMirror = f.mirrorUntil > now
460    const isDance = f.danceUntil > now
461    const isCandles = f.candlesUntil > now
462    if (list.length === 0 && !isPolter && !isMirror && !isDance && !isCandles && !time.isHalloween) return next(e)
463
464    const { Box, Text } = $.ui.resolve(e)
465    const width = Math.max(30, (e.props.bodyColumns ?? 80) - 2)
466    const out = (s: string) => (isTrick ? flip(s) : isPolter ? zalgo(s, 1) : s)
467
468    if (isMirror) {
469      return (
470        <Box flexDirection="column">
471          <Text color="red" bold>{'  🪞  ' + '░'.repeat(Math.min(40, width - 8))}</Text>
472          <Text color="red">{t % 4 < 2 ? '      she is behind you.' : '      d o n ’ t   t u r n   a r o u n d'}</Text>
473        </Box>
474      )
475    }
476
477    if (isDance) {
478      const moves = ['┌(・。・)┘♪', '└(・。・)┐♪', '┌(・o・)┘♫', '└(・o・)┐♫']
479      return (
480        <Box>
481          <Text color="white" bold>{`💀 ${moves[t % 4]}  spooky scary skeletons send shivers down your spine  ${moves[(t + 2) % 4]} 💀`}</Text>
482        </Box>
483      )
484    }
485
486    if (isCandles) {
487      const flame = t % 2 ? '🕯️' : '🕯'
488      return (
489        <Box>
490          <Text color="yellow">{`  ${Array.from({ length: Math.max(1, Math.min(list.length || 3, 13)) }, () => flame).join(' ')}   rest in peace`}</Text>
491        </Box>
492      )
493    }
494
495    // The drifting procession: each ghost floats along its own sine wave.
496    const track = Math.max(10, Math.min(width - 24, 70))
497    const row = Array.from({ length: track }, () => ' ')
498    if (!isBusted) {
499      list.slice(-6).forEach((g, i) => {
500        const phase = (hash(g.id) % 100) / 15
501        const x = Math.round(((Math.sin(t / (9 + i * 2) + phase) + 1) / 2) * (track - 2))
502        row[Math.min(track - 1, x)] = g.isBound ? '⛓' : g.kind === 'legion' ? '👥' : g.kind === 'wisp' ? '∿' : '👻'
503      })
504    }
505    const pumpkins = time.isHalloween ? (t % 6 < 3 ? '🎃 ' : '🎃 ') : ''
506    const header = isBusted
507      ? '🚫👻  the ghosts are hiding…'
508      : isPolter
509        ? `⚡ ${row.join('').replace(/ /g, () => pick([' ', ' ', ' ', '░', '▒']))} ⚡`
510        : `${pumpkins}${row.join('')}`
511    const count = list.length
512    const tag = count === 0 ? (time.isHalloween ? '🎃 happy halloween' : '') : `⚰ ${count}${count >= MAX_GHOSTS ? ' 🕯️ THE CIRCLE IS COMPLETE' : ''}${time.isWitching ? ' · witching hour' : ''}`
513
514    const g = w ? list.find(x => x.id === w.ghostId) : undefined
515    const isFresh = w !== null && now - w.at < 9_000 && !isBusted
516    const st = g ? strength(g) : 1
517    const whisperText = isFresh && w ? `“${fade(w.text, st).slice(0, Math.max(10, width - w.name.length - 12))}” — ${w.name}` : ''
518
519    return (
520      <Box flexDirection="column">
521        <Box>
522          <Text color={isPolter ? 'red' : time.isWitching ? 'magenta' : 'gray'}>{out(header)}</Text>
523          <Text dimColor>{'  ' + out(tag)}</Text>
524        </Box>
525        {whisperText !== '' && (
526          <Text italic dimColor={st < 0.5} color={st < 0.25 ? 'gray' : 'cyan'}>
527            {'  ' + out(whisperText)}
528          </Text>
529        )}
530      </Box>
531    )
532  })
533
534  // ── The graveyard pane ──
535  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
536    const { Box, Text } = $.ui.resolve(e)
537    const list = await read($, ghosts)
538    const rested = Number((await $.store.get('laidToRest')) ?? 0)
539    const cols = Math.max(1, Math.floor(((e.props.bodyColumns as number | undefined) ?? 40) / 20))
540
541    const stone = (g: Ghost) => {
542      const center = (s: string) => {
543        const cut = s.length > 15 ? s.slice(0, 14) + '…' : s
544        const pad = 15 - cut.length
545        return '│' + ' '.repeat(Math.floor(pad / 2)) + cut + ' '.repeat(Math.ceil(pad / 2)) + '│'
546      }
547      const d = new Date(g.diedAt)
548      const epitaph = EPITAPHS[hash(g.id) % EPITAPHS.length]
549      return [
550        '  ╭─────────────╮ ',
551        center(g.isBound ? '⛓ R.I.P. ⛓' : 'R . I . P .'),
552        center(g.name),
553        center(g.kind === 'legion' ? `${g.lineCount} souls` : `${g.lineCount} lines`),
554        center(`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`),
555        center(epitaph.slice(0, 15)),
556        '▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁',
557      ]
558    }
559
560    const rows: Ghost[][] = []
561    for (let i = 0; i < list.length; i += cols) rows.push(list.slice(i, i + cols))
562
563    return (
564      <Box flexDirection="column">
565        <Text color="magenta" bold>⚰️  {list.length} restless · ✨ {rested} at peace</Text>
566        <Text dimColor>Restore a file to free its ghost · /seance to speak · /exorcise to banish</Text>
567        <Text> </Text>
568        {list.length === 0 && <Text dimColor>Only fog, and the soft sound of a cursor blinking.</Text>}
569        {rows.map((r, ri) => (
570          <Box key={`row-${ri}`} flexDirection="row">
571            {r.map(g => (
572              <Box key={g.id} flexDirection="column" marginRight={1}>
573                {stone(g).map((line, li) => (
574                  <Text key={`${g.id}-${li}`} color={li === 0 || li === 6 ? 'gray' : g.isBound ? 'red' : 'white'}>{line}</Text>
575                ))}
576              </Box>
577            ))}
578          </Box>
579        ))}
580      </Box>
581    )
582  })
583}
584
types/index.d.ts 36 lines
1export type Ghost = {
2  id: string
3  path: string
4  name: string
5  kind: 'file' | 'legion' | 'wisp'
6  lines: string[]
7  lineCount: number
8  diedAt: number
9  whispers: number
10  isBound: boolean
11}
12
13export type Whisper = { ghostId: string; name: string; text: string; at: number }
14
15export type Fx = {
16  poltergeistUntil: number
17  bustedUntil: number
18  trickUntil: number
19  mirrorUntil: number
20  danceUntil: number
21  candlesUntil: number
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'haunted-repo': {
27      ghosts: Ghost[]
28      frame: number
29      whisper: Whisper | null
30      fx: Fx
31      ghostMode: boolean
32      cat: number
33    }
34  }
35}
36