Typewriter clack and a screen-centre card for each typed character; the sent prompt as a full-screen title card with a sting; a thinking-time card with excuses…

A Claude Code mod that turns your prompts into a 70s-anime-style title call.
All sounds and music are original, synthesised by helper/gen_sounds.py.
helper/lupin-overlay, shipped as a universal binary)At the prompt of a Claude Code terminal session:
/plugin install lupin-title --marketplace necobit/claude-lupin-title
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install lupin-title --marketplace necobit/claude-lupin-title
| Command | What it does |
|---|---|
/lupin off | Stops everything and quits the helper. Nothing relaunches it until you turn it back on. |
/lupin on | Turns it back on. |
/lupin | Shows whether it is on. |
The setting is remembered across sessions.
The mod (hooks/register.ts) cannot draw over the whole screen, so it launches a tiny resident helper, helper/lupin-overlay, and talks to it over HTTP on 127.0.0.1:47391. The helper draws a click-through overlay window and plays the sounds. It is shared by all sessions and quits after 30 idle minutes.
To rebuild the helper or the sounds yourself:
cd helper
swiftc -O -target arm64-apple-macos13 -o lupin-arm64 LupinOverlay.swift
swiftc -O -target x86_64-apple-macos13 -o lupin-x86_64 LupinOverlay.swift
lipo -create -output lupin-overlay lupin-arm64 lupin-x86_64
python3 gen_sounds.py ../soundshooks/register.ts 167 lines1import type { EngineInterface, Register } from 'claude-code'
2import { liveExcusePrompt, subjectOf, templateExcuse } from './excuses'
3
4const BASE = 'http://127.0.0.1:47391'
5const MAX_TYPED = 40 // longer inserts are pastes: no card per character
6const MAX_TITLE = 300
7
8// Shown until the model's own excuses for this prompt arrive.
9const STOCK_EXCUSES = [
10 'ただいま全力で考えております',
11 '銭形警部に追われておりまして',
12 'コーヒーを淹れ直しております',
13 'トークンを一枚ずつ数えております',
14 '念のため、もう一度読み直しております',
15 'いい感じの言い回しを探しております',
16 '今、ちょうどいいところなんです',
17 'キャッシュが温まるのを待っております',
18]
19
20const EXCUSE_PROMPT = (prompt: string) =>
21 `次の依頼を受けたアシスタントが、返事に時間がかかっている言い訳を8個書いてください。
22依頼の内容にちなんだ、ユーモアのある日本語で、1つ25文字以内。
231行に1つ、番号や記号や引用符は付けず、言い訳の文だけを書いてください。
24
25依頼:
26${prompt.slice(0, 600)}`
27
28let sessionId: string | undefined
29let enabled = true
30// The terminal app this session runs in; the helper draws only while it is frontmost.
31let hostApp = ''
32let currentRequest = ''
33let thinking = false
34let haikuBusy = false
35
36async function post($: EngineInterface, path: string, body: string) {
37 if (!enabled) return
38 const url = `${BASE}${path}${hostApp ? `?app=${encodeURIComponent(hostApp)}` : ''}`
39 try {
40 await $.http.fetch(url, { method: 'POST', body })
41 } catch {
42 await launch($)
43 await $.http.fetch(url, { method: 'POST', body }).catch(() => undefined)
44 }
45}
46
47async function ensureHelper($: EngineInterface) {
48 const up = await $.http.fetch(`${BASE}/ping`).then(r => r.ok, () => false)
49 if (!up) await launch($)
50}
51
52// The helper is shared by every session; one that finds the port taken exits.
53async function launch($: EngineInterface) {
54 const root = $.plugin.root
55 await $.process
56 .run(['/bin/sh', '-c', `nohup "$0/helper/lupin-overlay" "$0/sounds" >/dev/null 2>&1 &`, root])
57 .catch(() => undefined)
58 // Wait for it to answer: a first launch under Rosetta takes a few seconds.
59 for (let i = 0; i < 15; i++) {
60 await $.clock.sleep(200)
61 if (await $.http.fetch(`${BASE}/ping`).then(r => r.ok, () => false)) return
62 }
63}
64
65function shuffled<T>(items: readonly T[]): T[] {
66 const out = [...items]
67 for (let i = out.length - 1; i > 0; i--) {
68 const j = Math.floor(Math.random() * (i + 1))
69 ;[out[i], out[j]] = [out[j]!, out[i]!]
70 }
71 return out
72}
73
74async function writeExcuses($: EngineInterface, id: string, prompt: string) {
75 const reply = await $.model.complete({ model: 'haiku', prompt: EXCUSE_PROMPT(prompt), maxTokens: 600 })
76 if (!reply.isAnswered) return
77 const excuses = reply.text
78 .split('\n')
79 .map(line => line.replace(/^\s*(?:\d+[.).、]|[-・*])\s*/, '').replace(/^["「]|["」]$/g, '').trim())
80 .filter(line => line.length > 0 && line.length <= 40)
81 if (excuses.length > 0) await post($, '/think/excuses', JSON.stringify({ id, excuses }))
82}
83
84async function liveExcuse($: EngineInterface, tool: string, input: Record<string, unknown>) {
85 const id = sessionId ?? 'session'
86 const subject = subjectOf(tool, input)
87 await post($, '/think/now', JSON.stringify({ id, excuse: templateExcuse(tool, subject) }))
88 if (haikuBusy) return // one at a time: a burst of calls keeps its templates
89 haikuBusy = true
90 try {
91 const reply = await $.model.complete({ model: 'haiku', prompt: liveExcusePrompt(tool, subject, currentRequest), maxTokens: 100 })
92 const line = reply.isAnswered ? reply.text.trim().split('\n')[0]?.replace(/^["「]|["」]$/g, '').trim() : undefined
93 if (thinking && line && line.length <= 40) await post($, '/think/now', JSON.stringify({ id, excuse: line }))
94 } finally {
95 haikuBusy = false
96 }
97}
98
99export const register: Register = on => {
100 on('session.start', async ($, e, next) => {
101 sessionId = await $.session.id()
102 hostApp = (await $.env.get('__CFBundleIdentifier')) ?? ''
103 enabled = (await $.store.get('enabled')) !== false
104 await $.command.register({
105 name: 'lupin',
106 description: 'ルパン風タイトル演出のオン/オフ',
107 argumentHint: 'on | off',
108 })
109 if (enabled) await ensureHelper($)
110 return next(e)
111 })
112
113 on('command.run', { command: 'lupin' }, async ($, e) => {
114 const arg = e.args.trim().toLowerCase()
115 if (arg === 'off') {
116 enabled = false
117 thinking = false
118 await $.store.set('enabled', false)
119 await $.http.fetch(`${BASE}/quit`, { method: 'POST', body: '' }).catch(() => undefined)
120 return { text: 'ルパン演出をオフにしました(ヘルパーも終了)。/lupin on で再開します。' }
121 }
122 if (arg === 'on') {
123 enabled = true
124 await $.store.set('enabled', true)
125 await ensureHelper($)
126 return { text: 'ルパン演出をオンにしました。/lupin off で止まります。' }
127 }
128 return { text: `ルパン演出は${enabled ? 'オン' : 'オフ'}です。/lupin on か /lupin off で切り替えます。` }
129 })
130
131 on('prompt.edit', async ($, e, next) => {
132 const box = await next(e)
133 if (!enabled) return box
134 const typed = e.inputText
135 if (typed.length > 0 && typed.length <= MAX_TYPED) void post($, '/char', typed)
136 return box
137 })
138
139 on('prompt.submit', async ($, e, next) => {
140 const text = e.text.trim()
141 if (enabled && e.command === undefined && text.length > 0) {
142 const shown = text.length > MAX_TITLE ? `${text.slice(0, MAX_TITLE)}…` : text
143 const id = sessionId ?? 'session'
144 currentRequest = text
145 thinking = true
146 void post($, '/title', shown)
147 .then(() => post($, '/think/start', JSON.stringify({ id, excuses: shuffled(STOCK_EXCUSES) })))
148 .then(() => writeExcuses($, id, text))
149 .catch(() => undefined)
150 }
151 return next(e)
152 })
153
154 // Whatever runs now, the main loop's or a subagent's, gets its own excuse.
155 on('tool.call', async ($, e, next) => {
156 if (thinking) void liveExcuse($, String(e.tool), e as unknown as Record<string, unknown>).catch(() => undefined)
157 return next(e)
158 })
159
160 on('turn.complete', async ($, e, next) => {
161 const result = await next(e)
162 if (e.agentId === undefined) thinking = false
163 if (e.agentId === undefined) void post($, '/think/stop', JSON.stringify({ id: sessionId ?? 'session' }))
164 return result
165 })
166}
167hooks/excuses.ts 55 lines1// Excuses for what the session is doing right now, one per tool call.
2
3const clip = (s: string, n = 18) => (s.length > n ? `${s.slice(0, n)}…` : s)
4const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
5const pick = <T>(xs: readonly T[]): T => xs[Math.floor(Math.random() * xs.length)]!
6
7/** What the call is about, in a few words: the command, file, pattern or query. */
8export function subjectOf(tool: string, input: Record<string, unknown>): string {
9 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : undefined)
10 const firstWord = (cmd: string) => cmd.trim().split(/\s+/).slice(0, 2).join(' ')
11 switch (tool) {
12 case 'Bash': return clip(firstWord(str('command') ?? ''))
13 case 'Read': case 'Edit': case 'Write': case 'NotebookEdit':
14 return clip(base(str('file_path') ?? str('notebook_path') ?? ''))
15 case 'Grep': case 'Glob': return clip(str('pattern') ?? '')
16 case 'WebSearch': return clip(str('query') ?? '')
17 case 'WebFetch': return clip((str('url') ?? '').replace(/^https?:\/\//, ''))
18 case 'Agent': return clip(str('description') ?? '')
19 case 'Skill': return clip(str('skill') ?? '')
20 default: return ''
21 }
22}
23
24const TEMPLATES: Record<string, readonly ((s: string) => string)[]> = {
25 Bash: [s => `「${s}」が言うことを聞きませんで`, s => `${s} の機嫌を伺っております`, s => `ターミナルで ${s} と格闘中です`],
26 Read: [s => `${s} を熟読しております`, s => `${s} の行間を読んでおります`],
27 Edit: [s => `${s} に赤ペンを入れております`, s => `${s} を慎重に手術中です`],
28 Write: [s => `${s} を一筆したためております`, s => `${s} を清書しております`],
29 NotebookEdit: [s => `${s} のノートを書き直しております`],
30 Grep: [s => `「${s}」を草の根分けて探しております`, s => `「${s}」の聞き込み中です`],
31 Glob: [s => `${s} に合うファイルを点呼中です`],
32 WebSearch: [s => `「${s}」でネットの海を泳いでおります`, s => `「${s}」を世界中に聞き回っております`],
33 WebFetch: [s => `${s} まで出張しております`],
34 Agent: [s => `応援を呼んでおります(${s})`, () => `部下に仕事を振っております`],
35 Skill: [s => `${s} の巻物を広げております`],
36}
37const FALLBACK: readonly ((tool: string) => string)[] = [
38 t => `${t} の扱いに手間取っております`,
39 t => `${t} を説得しております`,
40]
41
42/** An instant excuse built from the tool and its subject. */
43export function templateExcuse(tool: string, subject: string): string {
44 const forTool = TEMPLATES[tool]
45 if (forTool !== undefined && subject !== '') return pick(forTool)(subject)
46 return pick(FALLBACK)(tool)
47}
48
49export const liveExcusePrompt = (tool: string, subject: string, request: string) =>
50 `アシスタントが作業中で、返事を待たせています。今まさにしている作業についての、ユーモアのある日本語の言い訳を1つだけ書いてください。
5125文字以内、言い訳の文だけ、記号や引用符なし。
52
53ユーザーの依頼: ${request.slice(0, 200)}
54今の作業: ${tool}${subject ? `(${subject})` : ''}`
55