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

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
/plugin marketplace add choaticpixels/HauntedClaudeMod
/plugin install haunted-repo@claude-mods
| Event | What 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 session | Ghosts follow you there |
/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 ⛓There are a lot of them. Some hints:
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
MIT
hooks/register.tsx 584 lines1import { 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}
584types/index.d.ts 36 lines1export 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