A fake Twitch chat in a side pane that watches Claude work and talks back. Requires Claude Code 2.1.281 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

A fake Twitch chat that sits next to your Claude Code session and watches you work.
Claude thinks for three minutes, chat starts asking if it froze. A test fails, someone spams KEKW. You type /chatfish reply should I just rewrite this in rust, and rustacean_rae has opinions. Nobody is actually watching, but it beats staring at a spinner.
chatfish: hooks module did not load line, and chatfish shows a warning toast if it ever loads on an older build anyway.env block of ~/.claude/settings.json: "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
/plugin marketplace add walangstudio/marketplace
/plugin install chatfish@walangstudio
Restart Claude Code (or quit and reopen Claude Desktop), then:
/chatfish live
The marketplace is the only supported way to install chatfish. To update:
/plugin marketplace update walangstudio
/plugin update chatfish@walangstudio
| Command | What it does |
|---|---|
/chatfish live | Go live. Also on or start. Chat starts empty and fills up as viewers arrive. |
/chatfish live roast 5k frequent | Go live with a mode, viewer target and chat rate, in any order. |
/chatfish off | End the stream. Also stop or offline. Closing the pane does the same. |
/chatfish reply <message> | Say something in chat. Works while Claude is busy. @name someone to get them to answer. |
/chatfish config <key=value ...> | Change settings, live or not. See below. |
/chatfish | Show the current settings. Also status. |
/chatfish help | List every command, setting, mode and rate. |
You can also type straight into the "Send a message" box at the bottom of the pane.
Settings stick across sessions.
/chatfish config name=CodeCat viewers=5k
/chatfish config nickname: CodeCat, audience: 300, mode: roast, rate: spam
/chatfish config name=auto
| Key | Aliases | Values |
|---|---|---|
name | nick, nickname, streamer | What chat calls you, 1-25 characters, no spaces. auto uses your Claude display name, then the part of your email before the @, then streamer. |
viewers | audience | Target audience, 1 to 1,000,000. 5k works. |
mode | hype, roast, mixed (default), curious, wholesome, chaos | |
rate | chat | quiet, normal (default), frequent. less, moderate, spam work too. |
Eight regulars show up every stream with their own personalities: async_annie explains things, tabsnotspaces nitpicks your naming, kekw_kevin laughs at every error, and so on. Everyone else is a random handle. Expect about a third of chat to be about your code, a third to be people talking to each other, and a third to be complete nonsense.
The audience isn't a fixed number. Viewers trickle in over the first two minutes, drift on their own, leave when nothing happens for a while, come back when things get going, and now and then another channel raids you.
Chat lines come from Claude Haiku through your normal Claude Code login. chatfish calls it when something new happens on stream, plus every third quiet tick. Ticks are 3, 6 or 12 seconds apart depending on rate, so frequent with a busy session is the expensive end. When a call fails, chat falls back to canned lines.
For working on chatfish itself, not for installing it:
claude plugin validate .
claude plugin test .
claude --plugin-dir . # run this checkout for one session
hooks/register.tsx is the mod itself, hooks/chat.ts holds the parsing and prompt logic, and hooks/chatfish.test.tsx covers both.
MIT
hooks/register.tsx 502 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Activity, Badge, ChatLine, Config } from '../types'
5import {
6 DEFAULT_CONFIG, HELP, MIN_CLAUDE_CODE, NEW_CROWD, fromSaved, PERIOD_MS, USAGE, isOlderThan, liveViewers, maybeRaid, nextViewers, stepCrowd, badgesFor, batchSize, emotify, toHandle, formatViewers,
7 nameColor, parseArgs, parseModelLines, streamOpening, systemPrompt, userPrompt,
8} from './chat'
9import type { Crowd, Parsed } from './chat'
10import { cannedChat } from './canned'
11
12type Engine = EngineInterface
13
14const PANE = 'chatfish'
15const config = atom({ plugin: 'chatfish', key: 'config' } as const, DEFAULT_CONFIG)
16const lines = atom({ plugin: 'chatfish', key: 'lines' } as const, [])
17const activity = atom({ plugin: 'chatfish', key: 'activity' } as const, [])
18const viewerCount = atom({ plugin: 'chatfish', key: 'viewers' } as const, 0)
19// Bumped per message sent from the pane. It keys the input, so each send draws a fresh, empty field;
20// the text stays with the host while typing, so keystrokes never re-render the pane.
21const sentCount = atom({ plugin: 'chatfish', key: 'sent' } as const, 0)
22// In $.state, not the module, so a hot reload does not load saved settings over newer ones.
23const isSettingsLoaded = atom({ plugin: 'chatfish', key: 'isSettingsLoaded' } as const, false)
24
25// Twitch dark theme: surface #18181B, text #EFEFF1, muted #ADADB8, borders #2F2F35, brand purple #9146FF.
26const TW = {
27 bg: '#18181B', text: '#EFEFF1', muted: '#ADADB8', border: '#2F2F35', input: '#464649',
28 purple: '#9146FF', streamer: '#E91916', notice: '#1F1F23', mention: '#4A1A1D', live: '#EB0400',
29}
30
31// Twitch badges are 18px colored tiles with a white icon. Desktop draws them as SVG;
32// the terminal has no images, so it shows the emoji closest to each icon.
33const BADGE: Record<Badge, { emoji: string; bg: string; icon: string }> = {
34 broadcaster: { emoji: '🎥', bg: '#E91916', icon: '<rect x="3" y="6" width="8" height="6" rx="1"/><path d="M11 8.5L15 6.5V11.5L11 9.5Z"/>' },
35 mod: { emoji: '🔨', bg: '#00AD03', icon: '<path d="M13.5 3H15V4.5L8 11.5L6.5 10Z"/><path d="M5 9.5L8.5 13L7.5 14L4 10.5Z"/><path d="M5 12L6 13L4 15L3 14Z"/>' },
36 vip: { emoji: '💎', bg: '#E005B9', icon: '<path d="M4 7L7 4H11L14 7L9 14Z"/>' },
37 sub: { emoji: '⭐', bg: '#9146FF', icon: '<path d="M9 2.5L10.9 6.9L15.5 7.2L12 10.2L13.1 14.8L9 12.3L4.9 14.8L6 10.2L2.5 7.2L7.1 6.9Z"/>' },
38 prime: { emoji: '👑', bg: '#0E9BD8', icon: '<path d="M3 13V6L6 9L9 4L12 9L15 6V13Z"/>' },
39}
40
41const badgeSvg = (b: Badge) =>
42 `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><rect width="18" height="18" rx="3" fill="${BADGE[b].bg}"/><g fill="#fff">${BADGE[b].icon}</g></svg>`
43
44// One live stream. Going live or offline replaces it, so anything still running for the old
45// one sees it is stale and drops its work instead of leaking into the next stream.
46// ponytail: lives in the module, so a mod reload starts a fresh crowd; move to $.state if that shows.
47type Stream = {
48 isBusy: boolean; isQueued: boolean; seenSeq: number; crowdSeq: number; idleTicks: number; crowd: Crowd
49 // Picked once and handed to Haiku once, so the stream opens with one scene instead of re-opening every batch.
50 opening: string | undefined
51 // Subagents Claude itself sent out during this stream. Other loops also carry an agent id (the
52 // engine's own forks for compaction or memory, teammates, other plugins' agents); chat ignores those.
53 subagents: Set<string>
54}
55const newStream = (seq = 0): Stream => ({
56 isBusy: false, isQueued: false, seenSeq: seq, crowdSeq: seq, idleTicks: 0, crowd: NEW_CROWD,
57 opening: streamOpening(Math.random), subagents: new Set(),
58})
59
60let stream = newStream()
61let ticker: Timer | undefined
62let isTurnRunning = false
63let autoName = ''
64
65// Claude Code keeps the claude.ai profile (display name, email) in ~/.claude.json.
66async function lookupName($: Engine) {
67 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
68 if (!home) return ''
69 try {
70 const account = JSON.parse(await $.fs.read(`${home}/.claude.json`)).oauthAccount ?? {}
71 const name = String(account.displayName ?? '').trim() || String(account.emailAddress ?? '').split('@')[0] || ''
72 return toHandle(name)
73 } catch {
74 return ''
75 }
76}
77
78const NOT_SAVED = ' Could not save it for next time.'
79const clip = (s: string) => (s.length > 100 ? `…${s.slice(-99)}` : s)
80// Reports from other agents arrive as whole documents; chat only needs the gist on one line.
81const gist = (s: string, max: number) => s.replace(/\s+/g, ' ').trim().slice(0, max)
82const streamerName = (cfg: Config) => cfg.streamer || autoName || 'streamer'
83const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84
85function addLines($: Engine, parsed: (Parsed | { kind: ChatLine['kind']; user: string; text: string })[]) {
86 return update($, lines, list => {
87 let id = list.at(-1)?.id ?? 0
88 return [...list, ...parsed.map(p => ({ ...p, id: ++id }))].slice(-200)
89 })
90}
91
92async function record($: Engine, kind: Activity['kind'], text: string) {
93 if (!(await read($, config)).live) return
94 const at = await $.clock.now()
95 await update($, activity, list => [...list, { seq: (list.at(-1)?.seq ?? 0) + 1, at, kind, text }].slice(-15))
96}
97
98// For hooks on the session's hot path: chat bookkeeping must never hold up a tool or a turn.
99// Entries passed together are recorded in that order.
100function note($: Engine, ...entries: [Activity['kind'], string][]) {
101 void (async () => {
102 for (const [kind, text] of entries) await record($, kind, text)
103 })().catch(() => {})
104}
105
106async function say($: Engine, cfg: Config, text: string) {
107 await addLines($, [{ kind: 'streamer', user: streamerName(cfg), text }])
108 await record($, 'reply', text)
109 void tick($, true)
110}
111
112// Writes only the settings a command changed, merged into what is stored, so a value
113// this session never touched is never overwritten. False when the store refuses; the
114// setting still applies to this session.
115async function saveSettings($: Engine, patch: Partial<Config>) {
116 const changed = fromSaved(patch)
117 if (Object.keys(changed).length === 0) return true
118 try {
119 await $.store.set('settings', { ...fromSaved(await $.store.get('settings')), ...changed })
120 return true
121 } catch {
122 return false
123 }
124}
125
126const saveNote = (isSaved: boolean) => (isSaved ? '' : NOT_SAVED)
127
128// Settings from earlier sessions, merged in once per session; concurrent callers share one load.
129// A failure is reported once and not retried, so it can never overwrite changes made since.
130let savedLoad: Promise<void> | undefined
131function loadSaved($: Engine) {
132 savedLoad ??= (async () => {
133 if (await read($, isSettingsLoaded)) return
134 try {
135 const saved = fromSaved(await $.store.get('settings'))
136 if (Object.keys(saved).length) await update($, config, c => ({ ...c, ...saved }))
137 } catch {
138 try {
139 $.ui.toast('chatfish could not load its saved settings; using the current ones.')
140 } catch {}
141 }
142 await update($, isSettingsLoaded, () => true)
143 })().catch(() => {})
144 return savedLoad
145}
146
147async function tick($: Engine, isReply = false) {
148 const s = stream
149 // A reply that lands while Haiku is busy gets its own run afterwards; timer ticks just skip.
150 if (s.isBusy) {
151 if (isReply) s.isQueued = true
152 return
153 }
154 s.isBusy = true
155 const isStale = () => s !== stream
156 try {
157 const cfg = await read($, config)
158 if (!cfg.live) return
159 const now = await $.clock.now()
160 const all = await read($, activity)
161 const idleMs = now - Math.max(cfg.startedAt, all.at(-1)?.at ?? 0)
162 if (isStale()) return
163 s.crowd = stepCrowd(s.crowd, { idleMs, events: all.filter(a => a.seq > s.crowdSeq).map(a => a.kind) })
164 s.crowdSeq = all.at(-1)?.seq ?? s.crowdSeq
165 const before = await read($, viewerCount)
166 if (isStale()) return
167 const viewers = nextViewers(before, liveViewers(cfg.viewers, now - cfg.startedAt), s.crowd)
168 const raid = maybeRaid(viewers, cfg.viewers)
169 if (raid) {
170 s.crowd = { ...s.crowd, raid: s.crowd.raid + raid.size }
171 await addLines($, [{ kind: 'notice', user: raid.from, text: `is raiding with ${raid.size} viewers!` }])
172 await record($, 'raid', `${raid.from} just raided the stream with ${raid.size} viewers`)
173 }
174 const trend = viewers < before * 0.98 ? 'falling' : viewers > before * 1.02 ? 'rising' : 'steady'
175 if (isStale()) return
176 await update($, viewerCount, () => viewers)
177 if (isStale()) return
178 $.ui.status(`● LIVE ${formatViewers(viewers)}`)
179 // Someone always answers the streamer once anyone is watching.
180 const count = Math.max(isReply && viewers > 0 ? 1 : 0, batchSize(cfg.rate, viewers))
181 if (count === 0) return
182 const fresh = all.filter(a => a.seq > s.seenSeq)
183 s.seenSeq = all.at(-1)?.seq ?? s.seenSeq
184 // ponytail: Haiku only on new activity or every 3rd idle tick; canned lines fill the rest to cap cost.
185 const useModel = fresh.length > 0 || ++s.idleTicks % 3 === 0
186 let next: Parsed[] = []
187 if (useModel) {
188 s.idleTicks = 0
189 const replies = fresh.filter(a => a.kind === 'reply').map(a => a.text)
190 const said = (await read($, lines)).filter(l => l.kind !== 'system')
191 // A conversation needs more memory than plain hype does.
192 const isTalking = replies.length > 0 || said.slice(-30).some(l => l.kind === 'streamer')
193 const recent = said.slice(isTalking ? -30 : -15).map(l => `${l.user}: ${l.text}`)
194 const r = await $.model.complete({
195 model: 'haiku',
196 effort: 'low',
197 maxTokens: 600,
198 timeoutMs: 20000,
199 system: systemPrompt(cfg.mode, streamerName(cfg)),
200 prompt: userPrompt({
201 viewers,
202 trend,
203 uptimeMs: now - cfg.startedAt,
204 idleMs,
205 isThinking: isTurnRunning,
206 count,
207 activity: fresh.length ? fresh : all.slice(-5),
208 isNew: fresh.length > 0,
209 opening: s.opening,
210 recent,
211 replies,
212 }),
213 }).catch(() => undefined)
214 if (r?.isAnswered) {
215 next = parseModelLines(r.text).slice(0, count + 1)
216 s.opening = undefined
217 }
218 }
219 if (isStale()) return
220 if (next.length === 0) next = cannedChat(cfg.mode, useModel ? count : Math.ceil(count / 2), fresh.at(-1))
221 const at = `@${streamerName(cfg)}`
222 next = next.map(l => ({ ...l, text: l.text.replace(/@streamer(?!\w)/g, () => at) }))
223 const gap = PERIOD_MS[cfg.rate] / next.length
224 next.forEach((line, i) => $.clock.after(Math.round(i * gap), () => {
225 if (!isStale()) void addLines($, [line])
226 }))
227 } finally {
228 s.isBusy = false
229 if (s.isQueued && !isStale()) {
230 s.isQueued = false
231 // Only if the busy tick finished before the reply arrived; otherwise it already answered.
232 const all = await read($, activity).catch(() => [])
233 if (all.some(a => a.seq > s.seenSeq)) void tick($, true)
234 }
235 }
236}
237
238function start($: Engine, cfg: Config) {
239 ticker?.cancel()
240 ticker = $.clock.every(PERIOD_MS[cfg.rate], () => void tick($))
241 void tick($)
242}
243
244function stop($: Engine) {
245 ticker?.cancel()
246 stream = newStream()
247 ticker = undefined
248 $.ui.status(undefined)
249}
250
251function describe(c: Config) {
252 return `${streamerName(c)}, ${c.mode}, ${formatViewers(c.viewers)} viewers, ${c.rate}`
253}
254
255export const register: Register = on => {
256 on('session.start', async ($, e, next) => {
257 await $.command.register({
258 name: 'chatfish',
259 description: 'Fake Twitch chat: on|off [mode] [viewers] [rate], reply <msg>',
260 argumentHint: '<on|off|reply> [mode] [viewers] [rate]',
261 immediate: true,
262 })
263 const { version } = await $.session.version()
264 if (isOlderThan(version, MIN_CLAUDE_CODE)) {
265 $.ui.toast(`chatfish needs Claude Code ${MIN_CLAUDE_CODE} or later (this is ${version}); some parts may not work.`)
266 }
267 autoName = await lookupName($)
268 await loadSaved($)
269 const cfg = await read($, config)
270 if (cfg.live) {
271 stream = newStream((await read($, activity)).at(-1)?.seq ?? 0)
272 void $.ui.open({ id: PANE, title: 'Stream Chat' })
273 start($, cfg)
274 }
275 return next(e)
276 })
277
278 on('command.run', { command: 'chatfish' }, async ($, e) => {
279 const cmd = parseArgs(e.args)
280 await loadSaved($)
281 const cfg = await read($, config)
282 switch (cmd.kind) {
283 case 'error':
284 return { text: cmd.text }
285 case 'help':
286 return { text: HELP }
287 case 'status':
288 return { text: `chatfish is ${cfg.live ? 'LIVE' : 'offline'} (${describe(cfg)})\n${USAGE}` }
289 case 'off':
290 await update($, config, c => ({ ...c, live: false }))
291 stop($)
292 await $.ui.close({ id: PANE })
293 return { text: 'chatfish is offline.' }
294 case 'config': {
295 const nextCfg = await update($, config, c => ({ ...c, ...cmd.patch }))
296 if (nextCfg.live && nextCfg.rate !== cfg.rate) start($, nextCfg)
297 const isSaved = await saveSettings($, cmd.patch)
298 return { text: `chatfish settings: ${describe(nextCfg)}${nextCfg.live ? ' (applied live)' : ''}${saveNote(isSaved)}` }
299 }
300 case 'on':
301 case 'set': {
302 const live = cmd.kind === 'on' || cfg.live
303 const isGoingLive = live && !cfg.live
304 const now = await $.clock.now()
305 const nextCfg = await update($, config, c => ({ ...c, ...cmd.patch, live, startedAt: isGoingLive ? now : c.startedAt }))
306 const isSaved = await saveSettings($, cmd.patch)
307 if (!live) return { text: `chatfish settings ${isSaved ? 'saved' : 'set'} (${describe(nextCfg)}). /chatfish on to go live.${saveNote(isSaved)}` }
308 if (isGoingLive) {
309 stream = newStream()
310 await update($, activity, () => [])
311 await update($, viewerCount, () => 0)
312 await update($, lines, () => [{ id: 1, kind: 'system', user: '', text: 'Welcome to the chat room!' }])
313 }
314 await $.ui.open({ id: PANE, title: 'Stream Chat' })
315 start($, nextCfg)
316 return { text: `chatfish is LIVE (${describe(nextCfg)}).${saveNote(isSaved)}` }
317 }
318 case 'reply':
319 if (!cfg.live) return { text: 'chatfish is offline. /chatfish on first.' }
320 await say($, cfg, cmd.text)
321 return {}
322 }
323 })
324
325 on('ui.close', { id: PANE }, async ($, e, next) => {
326 if (e.origin.kind === 'person') {
327 await update($, config, c => ({ ...c, live: false }))
328 stop($)
329 }
330 return next(e)
331 })
332
333 on('prompt.submit', async ($, e, next) => {
334 const result = await next(e)
335 // Announce only prompts that went through, as the agent received them.
336 // Chat sees what the streamer typed, not text other hooks wrapped around it.
337 if (result.drop === undefined && !e.text.startsWith('/')) note($, ['prompt', `Streamer asked the agent: "${e.text.slice(0, 160)}"`])
338 return result
339 })
340
341 on('tool.call', async ($, e, next) => {
342 const input = e as unknown as Record<string, unknown>
343 const target = [input.command, input.file_path, input.pattern, input.url, input.description, input.query]
344 .find(v => typeof v === 'string') as string | undefined
345 const tool = String(e.tool)
346 // Loops chat does not follow stay quiet. The Agent tool's start is announced by agent.spawn,
347 // but if it is blocked or fails before spawning, chat still hears about it below.
348 if (e.agentId && !stream.subagents.has(e.agentId)) return next(e)
349 const who = e.agentId ? 'A subagent' : 'Agent'
350 if (tool !== 'Agent' && tool !== 'Task') note($, ['tool', `${who} runs ${tool}${target ? `: ${clip(target)}` : ''}`])
351 const ran = await next(e)
352 if ('deny' in ran && ran.deny) note($, ['error', `${who}'s ${tool} was blocked`])
353 else if (ran.isError) note($, ['error', `${who}'s ${tool} failed${ran.text ? `: ${ran.text.slice(0, 100)}` : ''}`])
354 return ran
355 })
356
357 on('agent.spawn', async ($, e, next) => {
358 const result = await next(e)
359 // Only subagents that actually started (an agent id), that Claude itself sent (the engine, from the
360 // main loop or a subagent chat already follows), and that are not agent-team teammates.
361 const isClaudes = next.origin.plugin === 'engine' && (!e.parentAgentId || stream.subagents.has(e.parentAgentId))
362 if ('agentId' in result && result.agentId && isClaudes && !e.isTeammate) {
363 stream.subagents.add(result.agentId)
364 note($, ['subagent', `Claude sent out a subagent (${e.subagentType}) to: ${clip(gist(e.description, 200))}`])
365 }
366 return result
367 })
368
369 on('turn.start', ($, e, next) => {
370 isTurnRunning = true
371 return next(e)
372 })
373
374 on('turn.complete', async ($, e, next) => {
375 if (e.agentId) {
376 // A subagent's turn ends inside the main turn: report how it went, never "done". It stays
377 // followed, since Claude can resume it with a message and it runs again.
378 if (stream.subagents.has(e.agentId)) {
379 const answer = gist(e.answer, 160)
380 if (e.isAborted || e.reason === 'aborted') note($, ['error', 'A subagent was stopped before it finished.'])
381 else if (e.reason === 'error' || e.reason === 'refusal') note($, ['error', 'A subagent hit a wall and gave up.'])
382 else note($, ['subagent', answer ? `A subagent reported back: "${answer}"` : 'A subagent finished its task.'])
383 }
384 return next(e)
385 }
386 isTurnRunning = false
387 // isAborted predates reason on older builds.
388 if (e.isAborted || e.reason === 'aborted') note($, ['error', 'The streamer interrupted Claude mid-task.'])
389 else if (e.reason === 'error') note($, ['error', 'Claude hit an error and stopped.'])
390 else if (e.reason === 'refusal') note($, ['error', 'Claude refused the task.'])
391 else {
392 const answer = e.answer.trim()
393 const done: [Activity['kind'], string] = ['done', 'The agent finished its turn and is waiting for the streamer.']
394 if (answer) note($, ['said', `Claude said: "${answer.slice(0, 200)}"`], done)
395 else note($, done)
396 }
397 return next(e)
398 })
399
400 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
401 const els = $.ui.resolve(e)
402 const { Box, Text } = els
403 const cfg = await read($, config)
404 const all = await read($, lines)
405 const watching = await read($, viewerCount)
406 const sent = await read($, sentCount)
407 // Desktop draws its own field chrome and a proportional font, so cell-based rules look broken there.
408 const isDesktop = e.surface === 'desktop'
409 const mention = new RegExp(`@${escapeRe(streamerName(cfg))}(?![\\p{L}\\p{N}_])`, 'iu')
410 const cols = Math.max(24, e.props.bodyColumns)
411 const rows = Math.max(10, e.props.scroll.bodyRows || (e.viewport?.rows ?? 30))
412 const inner = cols - 2
413 // Rows left for chat after the header, divider and input; desktop pads the input instead of a divider.
414 const budget = rows - (isDesktop ? 8 : 7)
415
416 const shown: ChatLine[] = []
417 let used = 0
418 for (let i = all.length - 1; i >= 0 && used < budget; i--) {
419 const l = all[i]!
420 used += l.kind === 'notice' ? 2 : Math.ceil((l.user.length + l.text.length + 6) / inner)
421 shown.unshift(l)
422 }
423
424 const row = (l: ChatLine) => {
425 if (l.kind === 'system') return <Text key={`m${l.id}`} color={TW.muted}>{l.text}</Text>
426 if (l.kind === 'notice') {
427 return (
428 <Box key={`m${l.id}`} backgroundColor={TW.notice} flexDirection="row">
429 <Box width={1} backgroundColor={TW.purple} />
430 <Box paddingX={1} flexGrow={1}>
431 <Text bold color={TW.text} wrap="wrap">{l.user} {l.text}</Text>
432 </Box>
433 </Box>
434 )
435 }
436 const badges: Badge[] = l.kind === 'streamer' ? ['broadcaster'] : badgesFor(l.user)
437 const isMention = l.kind === 'chat' && mention.test(l.text)
438 const name = <Text bold color={l.kind === 'streamer' ? TW.streamer : nameColor(l.user)}>{l.user}</Text>
439 const body = <Text color={TW.text}>: {emotify(l.text)}</Text>
440 if ('Svg' in els) {
441 return (
442 <Box key={`m${l.id}`} flexDirection="row" alignItems="flex-start" gap={1} backgroundColor={isMention ? TW.mention : undefined}>
443 {badges.map(b => <els.Svg key={b} source={badgeSvg(b)} alt={b} width={18} height={18} />)}
444 <Text wrap="wrap" color={TW.text}>{name}{body}</Text>
445 </Box>
446 )
447 }
448 return (
449 <Box key={`m${l.id}`} backgroundColor={isMention ? TW.mention : undefined}>
450 <Text wrap="wrap" color={TW.text}>
451 {badges.map(b => BADGE[b].emoji).join('')}{badges.length > 0 && ' '}
452 {name}
453 {body}
454 </Text>
455 </Box>
456 )
457 }
458
459 return (
460 <Box flexDirection="column" width={cols} height={rows} backgroundColor={TW.bg}>
461 <Box justifyContent="space-between" paddingX={1}>
462 <Text color={TW.muted}>⇤</Text>
463 <Text bold color={TW.text}>STREAM CHAT</Text>
464 <Text>
465 <Text color={cfg.live ? TW.live : TW.muted}>● </Text>
466 <Text color={TW.muted}>{cfg.live ? formatViewers(watching) : '0'}</Text>
467 </Text>
468 </Box>
469 {!isDesktop && <Text color={TW.border}>{'─'.repeat(cols)}</Text>}
470 <Box flexDirection="column" flexGrow={1} justifyContent="flex-end" overflow="hidden" paddingX={1}>
471 {cfg.live ? shown.map(row) : <Text color={TW.muted}>Stream is offline. /chatfish on</Text>}
472 </Box>
473 {cfg.live && 'Input' in els && (
474 <Box
475 flexDirection="column"
476 {...(isDesktop ? { paddingX: 1, paddingY: 1 } : { marginX: 1, borderStyle: 'round' as const, borderColor: TW.input })}
477 >
478 <els.Input
479 key={`send-${sent}`}
480 placeholder="Send a message"
481 submitLabel="Chat"
482 onSubmit={async text => {
483 // Swap in the empty field before anything slow, then hand the keyboard to it. A refused
484 // move resolves { deny } and leaves focus to the person; keys typed in the instant before
485 // the swap, or a draft in the same pane on another surface, go with the old field.
486 // ponytail: one shared counter; per-surface counters if both surfaces are used at once.
487 const n = await update($, sentCount, c => c + 1)
488 void $.ui.focus({ requestId: PANE, key: `send-${n}` }).catch(() => {})
489 const message = text.trim()
490 const now = await read($, config)
491 if (!message || !now.live) return
492 // The field is already empty, so say so rather than lose the message silently.
493 await say($, now, message).catch(() => $.ui.toast(`chatfish could not post: ${message.slice(0, 60)}`))
494 }}
495 />
496 </Box>
497 )}
498 </Box>
499 )
500 })
501}
502hooks/chat.ts 374 lines1import type { Activity, Badge, Config, Mode, Rate } from '../types'
2
3export const MODES: readonly Mode[] = ['hype', 'roast', 'mixed', 'curious', 'wholesome', 'chaos']
4export const RATES: readonly Rate[] = ['quiet', 'normal', 'frequent']
5export const DEFAULT_CONFIG: Config = { live: false, mode: 'mixed', viewers: 1200, rate: 'normal', startedAt: 0, streamer: '' }
6
7const ON = ['on', 'start', 'live']
8const OFF = ['off', 'stop', 'offline']
9const RATE_ALIASES: Record<string, Rate> = {
10 quiet: 'quiet', less: 'quiet', slow: 'quiet', low: 'quiet',
11 normal: 'normal', moderate: 'normal', medium: 'normal',
12 frequent: 'frequent', fast: 'frequent', high: 'frequent', spam: 'frequent',
13}
14const MODE_ALIASES: Record<string, Mode> = {
15 hype: 'hype', praise: 'hype', praising: 'hype', positive: 'hype',
16 roast: 'roast', roasting: 'roast',
17 mixed: 'mixed', mix: 'mixed', both: 'mixed',
18 curious: 'curious', questions: 'curious',
19 wholesome: 'wholesome', kind: 'wholesome', supportive: 'wholesome',
20 chaos: 'chaos', chaotic: 'chaos',
21}
22
23export type Command =
24 | { kind: 'on'; patch: Partial<Config> }
25 | { kind: 'off' }
26 | { kind: 'set'; patch: Partial<Config> }
27 | { kind: 'reply'; text: string }
28 | { kind: 'status' }
29 | { kind: 'help' }
30 | { kind: 'config'; patch: Partial<Config> }
31 | { kind: 'error'; text: string }
32
33export const USAGE =
34 'Run /chatfish help for every option.\nUsage: /chatfish <on|start|live|off|stop|offline> [mode] [viewers] [rate] | /chatfish reply <message> | /chatfish <mode|rate|viewers>\n' +
35 `modes: ${MODES.join(', ')} rates: ${RATES.join(', ')} viewers: 1-1000000\n` +
36 'Live settings: /chatfish config name=<nickname|auto> viewers=5k mode=roast rate=frequent (key: value, comma separated, works too)'
37
38function parseSettings(tokens: string[]): Partial<Config> | string {
39 const patch: Partial<Config> = {}
40 for (const raw of tokens) {
41 const t = raw.toLowerCase().replace(/,/g, '')
42 const k = /^(\d+(?:\.\d+)?)k$/.exec(t)
43 const n = k ? Math.round(Number(k[1]) * 1000) : /^\d+$/.test(t) ? Number(t) : NaN
44 if (!Number.isNaN(n)) {
45 if (n < 1 || n > 1_000_000) return `viewers must be 1-1000000, got ${raw}`
46 patch.viewers = n
47 } else if (Object.hasOwn(MODE_ALIASES, t)) patch.mode = MODE_ALIASES[t]
48 else if (Object.hasOwn(RATE_ALIASES, t)) patch.rate = RATE_ALIASES[t]
49 else if (t !== 'mode' && t !== 'rate' && t !== 'viewers') return `unknown option "${raw}"`
50 }
51 return patch
52}
53
54const CONFIG_KEYS: Record<string, 'streamer' | 'viewers' | 'mode' | 'rate'> = {
55 name: 'streamer', nick: 'streamer', nickname: 'streamer', streamer: 'streamer',
56 viewers: 'viewers', audience: 'viewers', mode: 'mode', rate: 'rate', chat: 'rate',
57}
58const CONFIG_PAIR = new RegExp(`(\\w+)\\s*[:=]\\s*(.*?)(?=(?:\\s*,\\s*|\\s+)(?:${Object.keys(CONFIG_KEYS).join('|')})\\s*[:=]|\\s*,?\\s*$)`, 'gi')
59
60export function parseConfig(text: string): Partial<Config> | string {
61 const patch: Partial<Config> = {}
62 let found = 0
63 for (const [, rawKey = '', rawValue = ''] of text.matchAll(CONFIG_PAIR)) {
64 found++
65 const key = Object.hasOwn(CONFIG_KEYS, rawKey.toLowerCase()) ? CONFIG_KEYS[rawKey.toLowerCase()] : undefined
66 if (!key) return `unknown setting "${rawKey}"`
67 const value = rawValue.replace(/^["']|["']$/g, '').trim()
68 if (key === 'streamer') {
69 const name = value.replace(/^@/, '')
70 if (name.toLowerCase() === 'auto') patch.streamer = ''
71 else if (name && toHandle(name) === name) patch.streamer = name
72 else return `name must be 1-25 characters with no spaces, commas, = or :, got "${value}"`
73 continue
74 }
75 const one = parseSettings([value])
76 if (typeof one === 'string') return one
77 if (one[key] === undefined) return `bad value for ${rawKey}: "${value}"`
78 Object.assign(patch, { [key]: one[key] })
79 }
80 const leftover = text.replace(CONFIG_PAIR, '').replace(/[\s,]/g, '')
81 if (leftover) return `could not read "${leftover}"`
82 return found ? patch : 'nothing to set'
83}
84
85// Settings kept across sessions. The store is a file anyone can edit, so every field is re-checked.
86export type Saved = Pick<Config, 'mode' | 'viewers' | 'rate' | 'streamer'>
87
88export function fromSaved(v: unknown): Partial<Saved> {
89 if (typeof v !== 'object' || v === null) return {}
90 const o = v as Record<string, unknown>
91 const out: Partial<Saved> = {}
92 if (typeof o.mode === 'string' && (MODES as readonly string[]).includes(o.mode)) out.mode = o.mode as Mode
93 if (typeof o.rate === 'string' && (RATES as readonly string[]).includes(o.rate)) out.rate = o.rate as Rate
94 if (typeof o.viewers === 'number' && Number.isInteger(o.viewers) && o.viewers >= 1 && o.viewers <= 1_000_000) out.viewers = o.viewers
95 if (typeof o.streamer === 'string' && (o.streamer === '' || toHandle(o.streamer) === o.streamer)) out.streamer = o.streamer
96 return out
97}
98
99export const HELP = [
100 'chatfish - a fake Twitch chat that watches Claude work.',
101 '',
102 '/chatfish live [mode] [viewers] [rate] go live (also on, start); options in any order',
103 '/chatfish off end the stream (also stop, offline)',
104 '/chatfish reply <message> talk to chat (also say); @name someone to get them to answer',
105 '/chatfish <mode|viewers|rate> change a setting directly, e.g. /chatfish roast 5k',
106 '/chatfish config key=value ... change settings, live or not; they stick across sessions',
107 '/chatfish status show the current settings (also bare /chatfish)',
108 '/chatfish help this help',
109 '',
110 'config keys:',
111 ' name what chat calls you (nick, nickname, streamer); auto = your Claude name',
112 ' viewers target audience, 1-1000000 (audience); 5k works',
113 ` mode ${MODES.join(', ')}`,
114 ` rate ${RATES.join(', ')} (chat); less, moderate, spam work too`,
115 '',
116 'Example: /chatfish config name=CodeCat viewers=5k mode=roast rate=frequent',
117].join('\n')
118
119export function parseArgs(args: string): Command {
120 const trimmed = args.trim()
121 if (trimmed === '' || trimmed.toLowerCase() === 'status') return { kind: 'status' }
122 if (/^(help|-h|--help|\?)(\s|$)/i.test(trimmed)) return { kind: 'help' }
123 const [head = '', ...rest] = trimmed.split(/\s+/)
124 const verb = head.toLowerCase()
125 if (verb === 'reply' || verb === 'say') {
126 const text = trimmed.slice(head.length).trim()
127 return text ? { kind: 'reply', text } : { kind: 'error', text: 'Nothing to say. /chatfish reply <message>' }
128 }
129 if (OFF.includes(verb)) return { kind: 'off' }
130 if (verb === 'config' || verb === 'set') {
131 const patch = parseConfig(trimmed.slice(head.length))
132 return typeof patch === 'string' ? { kind: 'error', text: `${patch}\n${USAGE}` } : { kind: 'config', patch }
133 }
134 const isOn = ON.includes(verb)
135 const patch = parseSettings(isOn ? rest : [head, ...rest])
136 if (typeof patch === 'string') return { kind: 'error', text: `${patch}\n${USAGE}` }
137 return isOn ? { kind: 'on', patch } : { kind: 'set', patch }
138}
139
140export const PERIOD_MS: Record<Rate, number> = { quiet: 12000, normal: 6000, frequent: 3000 }
141
142// ponytail: fixed 2-minute ramp; make it a setting if people want slow-burn streams.
143export const RAMP_MS = 120_000
144
145export const liveViewers = (target: number, elapsedMs: number) =>
146 Math.round(target * Math.min(1, Math.max(0, elapsedMs) / RAMP_MS) ** 2)
147
148export function batchSize(rate: Rate, viewers: number, rand = Math.random) {
149 if (viewers < 1) return 0
150 const base = { quiet: 2, normal: 4, frequent: 6 }[rate]
151 const scale = viewers < 200 ? viewers / 200 : 1 + Math.log10(viewers / 200) / 2
152 const expected = Math.min(10, base * scale)
153 return Math.floor(expected) + (rand() < expected % 1 ? 1 : 0)
154}
155
156// An audience has three moving parts: interest follows what happens on stream,
157// wander is the slow random drift every stream has, raid is a burst that fades.
158export type Crowd = { interest: number; wander: number; raid: number }
159export const NEW_CROWD: Crowd = { interest: 1, wander: 1, raid: 0 }
160
161const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
162
163export function stepCrowd(c: Crowd, o: { idleMs: number; events: readonly Activity['kind'][]; rand?: () => number }): Crowd {
164 const rand = o.rand ?? Math.random
165 let interest = c.interest
166 for (const k of o.events) interest += k === 'error' || k === 'reply' ? 0.04 : k === 'done' ? 0.03 : 0.01
167 if (o.idleMs > 45_000) interest *= 0.97
168 else if (o.idleMs < 15_000) interest += 0.005
169 return {
170 interest: clamp(interest, 0.5, 1.25),
171 wander: clamp(c.wander + (rand() - 0.5) * 0.04, 0.85, 1.15),
172 raid: c.raid < 1 ? 0 : c.raid * 0.9,
173 }
174}
175
176// Where the audience is heading this tick; the count eases toward it with a little jitter.
177export function nextViewers(before: number, base: number, c: Crowd, rand = Math.random) {
178 const goal = (base * c.interest * c.wander + c.raid) * (1 + (rand() - 0.5) * 0.03)
179 return Math.max(0, Math.round(before + (goal - before) * 0.35))
180}
181
182const RAIDERS = ['pixel_pirate', 'grumpy_gopher', 'tea_and_types', 'byte_bandit', 'nightowl_dev', 'vim_queen', 'lofi_lambda']
183
184// ponytail: about one raid per 12 minutes at the normal rate; tie it to stream size if that feels off.
185export function maybeRaid(viewers: number, target: number, rand = Math.random) {
186 if (viewers < 20 || rand() >= 0.008) return undefined
187 const from = `${RAIDERS[Math.floor(rand() * RAIDERS.length)]}${Math.floor(rand() * 100)}`
188 return { from, size: Math.max(3, Math.round(target * (0.1 + rand() * 0.4))) }
189}
190
191// A chat handle from any display name: no leading @, separators, controls or bidi marks,
192// at most 25 code points. Zero-width joiners stay so emoji sequences survive.
193export const toHandle = (name: string) =>
194 Array.from(
195 name
196 .replace(/[\p{Cc}\p{Co}\p{Cn}\u200B\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/gu, '')
197 .trim()
198 .replace(/^@+/, '')
199 .replace(/[\s,=:]+/g, '_'),
200 ).slice(0, 25).join('')
201
202export function hash(s: string) {
203 let h = 2166136261
204 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
205 return h >>> 0
206}
207
208// Twitch's default name palette, lightened the way its dark theme does for readability.
209const NAME_COLORS = [
210 '#FF4A4A', '#8A8AFF', '#33CC33', '#E25555', '#FF7F50', '#9ACD32', '#FF4500', '#3CB371',
211 '#DAA520', '#E07B39', '#5F9EA0', '#1E90FF', '#FF69B4', '#B580FF', '#00FF7F',
212]
213export const nameColor = (user: string) => NAME_COLORS[hash(user) % NAME_COLORS.length]
214
215export function badgesFor(user: string): Badge[] {
216 const h = hash(user + '#badge') % 100
217 if (h < 4) return ['mod', 'sub']
218 if (h < 8) return ['vip', 'sub']
219 if (h < 38) return ['sub']
220 if (h < 48) return ['prime']
221 return []
222}
223
224export function formatViewers(n: number) {
225 if (n >= 999_500) return `${(n / 1e6).toFixed(1).replace(/\.0$/, '')}M`
226 return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '')}K` : String(n)
227}
228
229export type Parsed = { kind: 'chat' | 'notice'; user: string; text: string }
230
231export function parseModelLines(text: string): Parsed[] {
232 const out: Parsed[] = []
233 for (const line of text.split('\n')) {
234 const s = line.trim().replace(/^`+|`+$/g, '').replace(/^(?:(?:[-*•]|\d+[.)])\s+)+/, '')
235 .replace(/^(\*{1,2}|_)(@?[A-Za-z0-9_]{3,25})\1:/, '$2:')
236 if (/^NOTICE:/i.test(s)) {
237 const notice = /^NOTICE:\s*@?([A-Za-z0-9_]{3,25}):?\s+(.+)$/i.exec(s)
238 if (notice) out.push({ kind: 'notice', user: notice[1]!, text: notice[2]!.slice(0, 140) })
239 continue
240 }
241 const chat = /^@?([A-Za-z0-9_]{3,25}):\s*(.+)$/.exec(s)
242 if (chat) out.push({ kind: 'chat', user: chat[1]!, text: chat[2]!.slice(0, 200) })
243 }
244 return out
245}
246
247const PERSONA: Record<Mode, string> = {
248 hype: 'Chat is hyped and praises every move: Pog, GOATED, "clean code", "W streamer".',
249 roast: 'Chat roasts the code, the tool choices and the slowness: KEKW, LUL, "ratio", backseat coding. Playful, never cruel.',
250 mixed: 'Half the chat hypes, half roasts, they argue with each other sometimes.',
251 curious: 'Chat asks genuine questions about what is happening and why, learns things, says "TIL".',
252 wholesome: 'Chat is supportive and kind: "you got this", hearts, encouragement, celebrating small wins.',
253 chaos: 'Chaotic chat: copypasta, emote spam, backseat coding, random non sequiturs, fake donations.',
254}
255
256export const REGULARS: readonly { name: string; persona: string }[] = [
257 { name: 'async_annie', persona: 'senior backend dev, patient teacher, loves architecture talk, gets quietly annoyed by bad advice in chat' },
258 { name: 'tabsnotspaces', persona: 'grumpy style pedant, argues about naming and formatting, secretly very helpful' },
259 { name: 'kekw_kevin', persona: 'class clown, laughs at every error, short attention span, first to say he is bored' },
260 { name: 'deploy_friday', persona: 'reckless ops guy, "just ship it", tells prod outage war stories, cheerful chaos' },
261 { name: 'cozy_compiler', persona: 'wholesome, greets everyone, talks about tea and her cat, gets sad when people leave' },
262 { name: 'lurkerlarry', persona: 'quiet, rare one-liners, now and then drops surprisingly deep wisdom' },
263 { name: 'rustacean_rae', persona: 'Rust evangelist, competitive, teases other languages, thrilled by performance wins' },
264 { name: 'nullpointer_nate', persona: 'junior dev learning to code, earnest questions, a bit anxious about AI taking jobs' },
265]
266
267export function systemPrompt(mode: Mode, streamer: string) {
268 return [
269 'You write fake Twitch chat for a live coding stream. The streamer is a developer; an AI coding agent (Claude) does the work on screen.',
270 'Output ONLY chat lines, one per line, as `username: message`. No commentary, no numbering.',
271 'Chat is a crowd of individuals, not a commentary track. Roughly: 40% react to what is on stream (be specific: name the file, command, error or what Claude said), 30% chatters talk to each other (@name replies, arguing, inside jokes, asking each other things), 30% off-topic rubbish (copypasta, emote spam, "what song is this", random life updates, "chat is this real", misreading the situation).',
272 'Some viewers are actually smart: they spot real bugs, explain things to newer chatters, predict what Claude will do next, or call out a bad approach. Others are clueless or trolling.',
273 `Regulars who are almost always here (keep their voice, history and opinions consistent):\n${REGULARS.map(r => `- ${r.name}: ${r.persona}`).join('\n')}\nThe rest of chat is random viewers with invented handles.`,
274 'Everyone has emotions that shift with what happens: frustrated when Claude is stuck, hyped on a win, bored in a silence, defensive when teased, warm when someone is kind to them. Let the mood show in how they write.',
275 'When the streamer talks in chat it is a real conversation, not a cue for emote spam: the person addressed (or whoever cares about the topic) answers in character with a real opinion, knowledge, a joke, a follow-up question, a disagreement or a personal story. Keep threads going across batches until they naturally die out.',
276 'Claude sometimes sends subagents off to do part of the work. Chat follows them like side characters: guesses what they will find, cheers or doubts them, reacts when they report back.',
277 'Viewers notice pacing. A long silence gets jokes about the AI being slow, "is it frozen", "he is thinking", "brb food", people saying they are leaving. When action resumes after a silence, chat wakes up.',
278 'Messages are short (1-12 words), lowercase-ish, Twitch slang. Use emote names (KEKW, Pog, LUL, Kappa, monkaS, PepeHands, catJAM, 5Head, Clueless, Copium, o7, <3) and emoji freely, spam them when hyped.',
279 'Rarely (at most one line) add a subscription/raid/bits event as `NOTICE: username subscribed for 3 months!` or similar.',
280 "Never use slurs, sexual content, or attack anyone's identity; roast only code and decisions.",
281 `The streamer goes by ${streamer}; chatters address them as @${streamer} or just ${streamer}. Never write lines as the streamer.`,
282 `Persona of this chat: ${PERSONA[mode]}`,
283 ].join('\n')
284}
285
286const secs = (ms: number) => (ms < 90_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`)
287
288// Each stream opens with its own scene, assembled from parts and picked once per stream.
289const EARLY_WHEN = [
290 'The stream started a moment ago.', 'Brand new stream.', 'The stream just went live.', 'A quiet start to the stream.',
291 'The stream has been up for under a minute.', 'Fresh stream, the title barely changed.', 'Going live late tonight.',
292 'An unannounced stream just started.', 'Early morning stream, just went live.', 'A lunchtime stream just started.',
293]
294const EARLY_NEWCOMER = [
295 'a lurker from last week', 'someone who found the stream on the front page', "a viewer drifting over from a friend's stream",
296 'a student procrastinating on homework', 'someone on their lunch break', 'a night owl from another timezone',
297 'a viewer who only watches for the code', 'a long-time subscriber back after months away', 'a first-time viewer',
298]
299const EARLY_DOING = [
300 'greets the streamer by name.', 'asks what is being built today.', 'reacts to whatever is on screen right away.',
301 'picks up a running joke from last stream.', 'asks what language this is.', 'comments on the setup or the theme.',
302 'says where they found the stream.', 'complains about their own code at work.', 'asks if the AI is doing all the work.',
303 'immediately backseat-codes.', 'asks how long the stream will be.', 'shares what they are eating.',
304 'wonders out loud whether the bug from last time got fixed.', 'asks the streamer how their day went.',
305]
306const EARLY_MOOD = [
307 'Chat is sleepy.', 'Chat is curious.', 'Chat is chaotic from the first line.', 'Chat is cozy and slow.',
308 'Chat is hyped for no clear reason.', 'Chat is half asleep, half caffeinated.', 'Chat is sarcastic.', 'Chat is wholesome.',
309 'Chat is distracted by something off-topic.', 'Chat is very talkative for so few people.',
310]
311const pick = <T,>(xs: readonly T[], rand: () => number) => xs[Math.floor(rand() * xs.length)]!
312
313// About 24,000 openings. Naming who speaks first matters: left open, the friendliest regular
314// greets everyone first on every single stream.
315export function streamOpening(rand: () => number) {
316 const opener = rand() < 0.4 ? `${pick(EARLY_NEWCOMER, rand)} (invent their handle)` : pick(REGULARS, rand).name
317 return `${pick(EARLY_WHEN, rand)} The first message comes from ${opener}, who ${pick(EARLY_DOING, rand)} ${pick(EARLY_MOOD, rand)} ` +
318 'Every line should open differently; no stock "first" or "just got here".'
319}
320
321export function userPrompt(o: {
322 viewers: number
323 trend: 'rising' | 'falling' | 'steady'
324 uptimeMs: number
325 idleMs: number
326 isThinking: boolean
327 count: number
328 activity: readonly Activity[]
329 isNew: boolean
330 recent: readonly string[]
331 replies: readonly string[]
332 opening?: string
333}) {
334 const parts = [`Stream uptime: ${secs(o.uptimeMs)}. Viewers watching: ${o.viewers} (${o.trend}).`]
335 if (o.uptimeMs < 90_000 && o.opening) parts.push(o.opening)
336 if (o.trend === 'falling' && o.idleMs >= 45_000) parts.push('Viewers are leaving because nothing is happening; a few say bye or complain it is boring.')
337 if (o.activity.length) {
338 const label = o.isNew ? 'Just happened on stream (oldest first)' : 'Earlier on stream (oldest first)'
339 parts.push(`${label}:\n` + o.activity.map(a => `- ${a.text}`).join('\n'))
340 }
341 if (o.idleMs >= 20_000) {
342 parts.push(o.isThinking
343 ? `Claude has been thinking for ${secs(o.idleMs)} with nothing new on screen.`
344 : `Nothing has happened for ${secs(o.idleMs)}: Claude is done and the streamer has not typed anything.`)
345 }
346 if (o.recent.length) parts.push('Recent chat (oldest first):\n' + o.recent.join('\n'))
347 if (o.replies.length) {
348 parts.push(`The streamer just typed in chat: ${o.replies.map(r => JSON.stringify(r)).join(' then ')}\nMost new lines must react to that directly. Anyone the streamer @mentions answers first, in character. Answer questions properly, give real opinions, ask the streamer something back.`)
349 }
350 parts.push(`Write ${o.count} new chat lines.`)
351 return parts.join('\n\n')
352}
353
354// Twitch draws emotes as images; the closest a terminal cell gets is an emoji.
355const EMOTES: Record<string, string> = {
356 KEKW: '😂', LUL: '😆', OMEGALUL: '🤣', Pog: '😮', PogChamp: '😮', POGGERS: '😮', Kappa: '😏',
357 monkaS: '😰', PepeHands: '😢', catJAM: '🐱', '5Head': '🧠', Clueless: '🙂', Copium: '😤',
358 '<3': '💜', BibleThump: '😭', ResidentSleeper: '😴', NotLikeThis: '🙈', Kreygasm: '😩', o7: '🫡',
359}
360export const emotify = (text: string) => text.split(' ').map(w => (Object.hasOwn(EMOTES, w) ? EMOTES[w] : w)).join(' ')
361
362// Oldest build that loads the mod and passes its tests, bisected over published builds:
363// 2.1.280's validator rejects passing $ to the state helpers imported from claude-code.
364export const MIN_CLAUDE_CODE = '2.1.281'
365
366export function isOlderThan(version: string, min: string) {
367 const a = (version.match(/\d+/g) ?? []).map(Number)
368 const b = (min.match(/\d+/g) ?? []).map(Number)
369 for (let i = 0; i < b.length; i++) {
370 if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) < (b[i] ?? 0)
371 }
372 return false
373}
374hooks/canned.ts 345 lines1import type { Activity, Mode } from '../types'
2
3// Offline chat for when Haiku is unavailable or a tick skips it. Hand-written lines
4// give the voice, templates with interchangeable parts give the volume.
5
6type Rand = () => number
7const pick = <T,>(xs: readonly T[], rand: Rand) => xs[Math.floor(rand() * xs.length)]!
8
9const EMOTES = [
10 'KEKW', 'LUL', 'OMEGALUL', 'Pog', 'PogChamp', 'POGGERS', 'Kappa', 'monkaS', 'PepeHands', 'catJAM',
11 '5Head', 'Clueless', 'Copium', 'o7', '<3', 'BibleThump', 'ResidentSleeper', 'NotLikeThis', 'Kreygasm',
12]
13const LAUGH = ['KEKW', 'LUL', 'OMEGALUL', 'LMAO', 'lmaooo', 'XD', 'ICANT', 'HAHAHA', 'lol', 'dead']
14const HYPE = ['Pog', 'PogChamp', 'POGGERS', 'LETS GOOO', 'W', 'HUGE', 'insane', 'GG', 'sheesh']
15const SAD = ['PepeHands', 'BibleThump', 'NotLikeThis', 'F', 'oof', 'pain', 'rip', 'monkaS', 'Sadge']
16const NEUTRAL = [
17 'KEKW', 'LUL', 'Kappa', '5Head', 'Clueless', 'catJAM', 'o7', 'lol', 'fr', 'ngl', 'tbh', 'hmm', 'interesting',
18 'Copium', 'monkaS', 'OMEGALUL', 'true', 'real', 'facts', 'huh', 'wild', 'noted', 'sheesh', 'ok', 'bro',
19]
20const WARM = ['<3', 'catJAM', 'o7', 'Pog', 'love it', 'so wholesome', 'proud of you', 'nice']
21const GREET = ['hi', 'hello', 'yo', 'hey', 'sup', 'hiii', 'heyo', 'good morning', 'gm', 'evening', 'howdy', 'hola', 'oi', 'wassup', 'heya']
22const WHO = [
23 'chat', 'everyone', 'streamer', 'gamers', 'nerds', 'friends', 'bestie', 'fam', 'lurkers', 'mods',
24 'devs', 'coders', 'night owls', 'regulars', 'new people', 'besties', 'legends', 'gremlins', 'goblins', 'team',
25]
26const LANGS = [
27 'rust', 'go', 'python', 'typescript', 'java', 'c++', 'zig', 'haskell', 'php', 'lua', 'elixir', 'kotlin',
28 'c#', 'ruby', 'swift', 'scala', 'ocaml', 'clojure', 'dart', 'bash',
29]
30// Two different languages per line; "rust > rust" is not a take.
31const VERSUS = ['>', 'is better than', 'is just worse than', 'could never beat', 'vs'].flatMap(cmp =>
32 LANGS.flatMap(a => LANGS.filter(b => b !== a).map(b => `${a} ${cmp} ${b}`)),
33)
34// Singular subjects only, so "is" and "looks" always agree.
35const THINGS = [
36 'the test suite', 'the build', 'that regex', 'the type system', 'the linter', 'the deploy', 'the database', 'the cache',
37 'the CI', 'the documentation', 'that function', 'the config', 'the API', 'the merge', 'the migration', 'the readme',
38 'that loop', 'the error handling', 'the naming', 'the commit message', 'that if statement', 'the indentation',
39 'the PR', 'the stack trace', 'the log output', 'the dependency tree', 'node_modules', 'the lockfile', 'the env file',
40 'the dockerfile', 'the schema', 'the query', 'the retry logic', 'that one comment', 'the TODO list', 'the git history',
41 'the folder structure', 'that hotfix', 'the pipeline', 'the test runner', 'the router', 'the parser', 'the CLI',
42 'the frontend', 'the backend', 'the auth flow', 'the release', 'that branch', 'the diff', 'this codebase',
43]
44const OPEN = [
45 'ok but', 'ngl', 'honestly', 'lowkey', 'highkey', 'not gonna lie', 'bro', 'chat', 'wait', 'yo',
46 'hold on', 'real talk', 'actually', 'fr', 'tbh', 'no way', 'bruh', 'dude', 'hmm', 'ok',
47]
48const PRAISE = [
49 'is cooking', 'is goated', 'is beautiful', 'is lowkey genius', 'is peak engineering', 'is actually clean',
50 'deserves a raise', 'is art', 'is immaculate', 'sparks joy', 'is a vibe', 'just works', 'is so smooth',
51 'is chef kiss', 'is the cleanest thing today', 'is elite', 'looks great', 'is surprisingly good', 'is a masterpiece',
52 'is how it should be done',
53]
54const SHADE = [
55 'looks cursed', 'is held together by tape', 'is a crime', 'makes no sense', 'needs a rewrite', 'will break in prod',
56 'is giving 2009', 'is suspicious', 'passed somehow', 'scares me', 'is pure chaos', 'needs therapy', 'is why prod is down',
57 'is spaghetti', 'hurts my eyes', 'is legacy already', 'is 90% vibes', 'is fine trust', 'is a war crime', 'is cooked',
58]
59const NICE_ADJ = ['clean', 'beautiful', 'goated', 'elegant', 'blessed', 'cozy', 'pristine', 'crisp', 'tidy', 'slick']
60const MEAN_ADJ = ['cursed', 'sus', 'scary', 'mid', 'ancient', 'chaotic', 'questionable', 'illegal', 'unhinged', 'feral']
61const ADJ = [...NICE_ADJ, ...MEAN_ADJ]
62const SNACK = [
63 'ramen', 'coffee', 'tea', 'pizza', 'cereal', 'an energy drink', 'toast', 'leftovers', 'dumplings', 'a sandwich',
64 'popcorn', 'cold pizza', 'boba', 'instant noodles', 'chips', 'a burrito', 'sushi', 'pancakes', 'a salad', 'cookies',
65]
66const TIMES = ['5 min', '2 hours', 'the whole stream', 'all week', 'three years', 'forever', 'a decade', '10 seconds', 'an hour', 'all day', 'like 20 min', '3 streams']
67const PLACES = [
68 'brazil', 'germany', 'the philippines', 'canada', 'the uk', 'japan', 'india', 'mexico', 'france', 'poland',
69 'australia', 'korea', 'spain', 'italy', 'sweden', 'argentina', 'nigeria', 'vietnam', 'turkey', 'the netherlands',
70]
71const CHECKIN = ['greetings from', 'watching from', 'hello from', 'its 3am in', 'chilling in', 'up late in', 'lunch break in', 'raining in']
72const QSTART_CURIOUS = ['what is', 'who wrote', 'when did we change', 'can someone explain']
73const QSTART = [...QSTART_CURIOUS, 'why is', 'wait why is', 'who approved', 'is anyone else scared of']
74const QEND = ['?', '??', '? genuinely asking', '? asking for a friend', 'lol', '?!']
75const BRB = ['brb getting', 'back with', 'just grabbed', 'ok making', 'having', 'got']
76const WATCH = ['watching', 'lurking', 'here', 'vibing', 'learning', 'procrastinating']
77
78type Part = string | readonly string[]
79
80// About 60% of real messages end with nothing; the rest end with a word or emote that fits the mood.
81export const endings = (words: readonly string[]) => [...Array<string>(Math.round(words.length * 1.5)).fill(''), ...words]
82
83// How a mode feels about the code: hype and wholesome only praise, roast only shades, the rest mix.
84type Tone = 'nice' | 'mean' | 'any'
85const toneOf = (mode: Mode): Tone => (mode === 'hype' || mode === 'wholesome' ? 'nice' : mode === 'roast' ? 'mean' : 'any')
86
87// The parts that carry a mood: what chat says about the code and how it signs off.
88function moodOf(mode: Mode) {
89 const tone = toneOf(mode)
90 // Mixed modes pick praise or shade per line, so they sign off with something that fits either.
91 const end =
92 mode === 'hype' ? HYPE
93 : mode === 'wholesome' ? WARM
94 : mode === 'roast' ? [...LAUGH, ...SAD]
95 : NEUTRAL
96 return {
97 tone,
98 verdict: tone === 'nice' ? PRAISE : tone === 'mean' ? SHADE : [...PRAISE, ...SHADE],
99 adj: tone === 'nice' ? NICE_ADJ : tone === 'mean' ? MEAN_ADJ : ADJ,
100 end: endings(end),
101 }
102}
103
104// Each template is a row of fixed text and word lists; one line picks one word from every list.
105function templatesFor(mode: Mode): readonly (readonly Part[])[] {
106 const { tone, verdict, adj, end } = moodOf(mode)
107 const nice = tone === 'nice'
108 return [
109 [OPEN, THINGS, verdict, end],
110 [THINGS, verdict, end],
111 [THINGS, 'looking', adj, end],
112 [GREET, WHO, end],
113 [CHECKIN, PLACES, end],
114 [BRB, SNACK, end],
115 ['been', WATCH, 'for', TIMES, end],
116 [nice ? QSTART_CURIOUS : QSTART, THINGS, QEND],
117 [adj, 'stream today', end],
118 ['the vibes are', adj, end],
119 // Language fights and digs at the code are for modes that are allowed to be rude.
120 ...(nice
121 ? []
122 : ([[VERSUS, end], ['this would be 3 lines in', LANGS, end], ['just rewrite it in', LANGS, end], ['who touched', THINGS, end]] as const)),
123 ]
124}
125
126const sizeOf = (t: readonly Part[]) =>
127 t.reduce((m, p) => (typeof p === 'string' ? m : m * new Set(p).size), 1)
128
129type Table = { templates: readonly (readonly Part[])[]; weights: number[]; total: number }
130
131// Built on first use per mode, so any mode the type allows gets a table.
132// Big templates are picked more often than small ones (by the square root of their size),
133// so a greeting does not come up as often as a full sentence about the code.
134const tables: Partial<Record<Mode, Table>> = {}
135function tableFor(mode: Mode): Table {
136 return (tables[mode] ??= (() => {
137 const templates = templatesFor(mode)
138 const weights = templates.map(t => Math.sqrt(sizeOf(t)))
139 return { templates, weights, total: weights.reduce((a, b) => a + b, 0) }
140 })())
141}
142
143// Template combinations available in a mode: the sum over templates of their lists' distinct sizes multiplied.
144export const templateCombos = (mode: Mode) => tableFor(mode).templates.reduce((n, t) => n + sizeOf(t), 0)
145
146function pickTemplate(mode: Mode, rand: Rand) {
147 const { templates, weights, total } = tableFor(mode)
148 let left = rand() * total
149 for (let i = 0; i < templates.length; i++) {
150 left -= weights[i]!
151 if (left < 0) return templates[i]!
152 }
153 return templates[templates.length - 1]!
154}
155
156const fill = (t: readonly Part[], r: Rand) =>
157 t.map(p => (typeof p === 'string' ? p : pick(p, r))).filter(Boolean).join(' ').replace(/ \?/g, '?')
158
159const COMMON = [
160 'first', 'second', 'is this live?', 'what are we building', 'what is he making', 'chat is this real',
161 'what song is this', 'song name?', 'any% speedrun', 'can we get a W in chat', 'W', 'L', 'ratio',
162 'true', 'real', 'so true', 'facts', 'based', 'cringe', 'mid', 'no shot', 'bro what', 'wait what',
163 'huh', '???', 'lmao', 'i see', 'interesting', 'hmm', 'ok', 'chat is sleeping', 'who is this',
164 'new here', 'just got here', 'followed', 'raid incoming?', 'love the setup', 'what font is that',
165 'what theme is this', 'what keyboard', 'dark mode gang', 'light mode users KEKW', 'vim or emacs',
166 'tabs or spaces', 'hydrate', 'posture check', 'stretch break?', 'go to sleep', 'what time is it there',
167 'greetings from brazil', 'hello from germany', 'philippines represent', 'canada here', 'uk chat',
168 'its 3am here', 'should be studying', 'skipping class for this', 'my boss thinks im working',
169 'cooking', 'he cooking', 'let him cook', 'trust the process', 'its giving senior dev', 'peak content',
170 'this is my roman empire', 'certified banger', 'chat behave', 'mods?', 'ban him', 'unban him',
171 'who let him code', 'touch grass', 'never touching grass', 'average tuesday', 'actual wizard',
172 'ok but why', 'no thoughts head empty', 'brain hurts', 'my brain is melting', 'i understood nothing',
173 'i understood everything trust', 'taking notes', 'screenshotting this', 'clipped', 'clip it',
174 'someone clip that', 'that was smooth', 'smoothest stream', 'calm stream today', 'cozy stream',
175]
176
177const BY_MODE: Record<Mode, readonly string[]> = {
178 hype: [
179 'GOATED streamer', 'clean code no cap', '10x dev fr', 'bro is cracked', 'this is art', 'masterclass',
180 'insane speed', 'teach me', 'senior engineer energy', 'big brain move', 'flawless', 'hire this man',
181 'industry should be scared', 'thats how you do it', 'textbook', 'better than my whole team',
182 ],
183 roast: [
184 'bro is still thinking LUL', 'my grandma codes faster', 'skill issue', 'did he just... KEKW',
185 'just use a for loop 5Head', 'stack overflow copy paste speedrun', 'who taught you this',
186 'my linter is crying', 'this code has a smell', 'git blame is gonna be fun', 'the AI is carrying',
187 'AI doing all the work streamer just vibing', 'readme driven development', 'tests? never heard of em',
188 'works on my machine energy', 'technical debt speedrun', 'this is why prod is down',
189 ],
190 mixed: [
191 'is this good or bad', 'he cooking or burning?', 'half of chat says W half says L',
192 'honestly not bad', 'could be worse', 'ive seen worse at work', 'kinda clean kinda cursed',
193 'respect but why', 'bold strategy', 'it works so its fine', 'controversial but ok',
194 ],
195 curious: [
196 'what does that do?', 'why that approach?', 'is that a hook?', 'TIL', 'what lang is this',
197 'how long does this usually take?', 'can someone explain', 'whats the plan here', 'why not use a library',
198 'how does the AI know what to do', 'is this safe?', 'what happens if it fails', 'whats a mod',
199 'is this open source', 'where can I learn this', 'how did you set this up',
200 ],
201 wholesome: [
202 'you got this <3', 'love these streams', 'take your time', 'nice progress!', 'hi chat <3',
203 'so calm, love it', 'proud of you', 'small wins count', 'drink some water <3', 'this chat is so nice',
204 'best part of my day', 'hope everyone is doing ok', 'sending good vibes', 'good luck with the build',
205 ],
206 chaos: [
207 'FIRST', 'catJAM catJAM catJAM', 'type !discord', 'can you play minecraft', 'chat is this real',
208 'Copium', 'monkaS monkaS', '!uptime', '!song', 'BAN ME', 'I AM THE CAPTAIN NOW', 'flashbang incoming',
209 'dono goal when', 'kappa kappa kappa', 'react to this', 'my cat walked on my keyboard asdfgh',
210 'every day I wake up and choose violence', 'its free real estate', 'spam o7 for the build o7 o7',
211 ],
212}
213
214const REACT: Partial<Record<Activity['kind'], readonly string[]>> = {
215 tool: [
216 'here we go', 'TERMINAL ARC', 'its doing something monkaS', 'watching closely', 'cooking', 'go go go',
217 'the AI is typing', 'lock in', 'focus mode', 'speedrun', 'trust', 'oh he is actually doing it',
218 ],
219 error: [
220 'KEKW error', 'F', 'PepeHands', 'thats red chat', 'monkaS it broke', 'NotLikeThis', 'red text arc',
221 'error any%', 'its fine its fine', 'who could have seen this coming', 'skill issue', 'read the error',
222 'have you tried turning it off and on', 'F in chat', 'the stack trace is longer than the code',
223 'you got this', 'happens to the best of us', 'debug time', 'easy fix probably',
224 ],
225 done: [
226 'GG', 'GGs', 'did it work??', 'ship it', 'o7', 'W', 'merge it', 'deploy on friday', 'clean', 'next',
227 'what now', 'that was fast', 'ez', 'he did it', 'okay that was impressive',
228 ],
229 reply: ['hi!', 'he reads chat Pog', 'streamer noticed me', 'LUL', 'true', 'real', 'facts', 'agreed', 'no way'],
230 said: ['the AI said it like its easy', 'trust the AI', 'chat GPT who', 'AI explained it better than my prof', 'big words'],
231 prompt: ['new task Pog', 'oh this should be fun', 'here we go again', 'good luck', 'that sounds hard', 'easy'],
232 raid: ['RAID', 'welcome raiders', 'hi raiders <3', 'RAID HYPE', 'welcome in', 'raiders say hi'],
233 subagent: [
234 'minions deployed', 'sending in the clones', 'delegation king', 'the AI hired an intern', 'subagent arc',
235 'agents all the way down', 'it made a friend', 'team of AIs Pog', 'outsourcing speedrun', 'go little guy go',
236 'the subagent is cooking', 'report back soldier o7', 'multitasking legend', 'how many of them are there monkaS',
237 ],
238}
239
240// Shared lines that only fit one side: nice modes never mock, roast never gushes.
241const SPICY = new Set([
242 'L', 'ratio', 'cringe', 'mid', 'light mode users KEKW', 'ban him', 'who let him code', 'touch grass',
243 'KEKW error', 'skill issue', 'read the error', 'who could have seen this coming', 'error any%', 'red text arc',
244 'have you tried turning it off and on', 'the stack trace is longer than the code', 'ok but why',
245])
246const SWEET = new Set([
247 'love the setup', 'certified banger', 'actual wizard', 'peak content', 'smoothest stream', 'cozy stream',
248 'calm stream today', 'that was smooth', 'its giving senior dev', 'okay that was impressive', 'he did it',
249 'let him cook', 'trust the process', 'he cooking', 'cooking', 'you got this', 'happens to the best of us',
250 'easy fix probably',
251])
252const forTone = (lines: readonly string[], tone: Tone) =>
253 tone === 'any' ? lines : lines.filter(l => !(tone === 'nice' ? SPICY : SWEET).has(l))
254
255// Handles: prefix + suffix, sometimes with digits. 70 x 40 shapes before numbers.
256const NAME_A = [
257 'pixel', 'byte', 'null', 'async', 'tabs', 'vim', 'cozy', 'lofi', 'kernel', 'sudo', 'segfault', 'lambda',
258 'cache', 'stack', 'heap', 'rusty', 'go', 'py', 'node', 'docker', 'git', 'merge', 'deploy', 'debug', 'regex',
259 'syntax', 'binary', 'quantum', 'neon', 'retro', 'sleepy', 'grumpy', 'happy', 'spicy', 'salty', 'chill',
260 'dark', 'night', 'coffee', 'tea', 'ramen', 'taco', 'potato', 'cat', 'dog', 'frog', 'duck', 'owl', 'fox',
261 'panda', 'goblin', 'wizard', 'ninja', 'pirate', 'captain', 'lil', 'big', 'not', 'just', 'its', 'the',
262 'real', 'xX', 'mr', 'ms', 'sir', 'dr', 'tiny', 'mega', 'ultra',
263]
264const NAME_B = [
265 'dev', 'coder', 'hacker', 'gamer', 'enjoyer', 'andy', 'lord', 'king', 'queen', 'master', 'goblin',
266 'wizard', 'bandit', 'pilot', 'monk', 'witch', 'gremlin', 'nerd', 'potato', 'cat', 'frog', 'fan',
267 'lurker', 'main', 'boi', 'gal', 'dude', 'bot', 'fiend', 'head', 'brain', 'hands', 'mode', 'zone',
268 'tv', 'jr', 'sr', 'xd', 'irl', 'og',
269]
270
271export function cannedName(rand: Rand = Math.random) {
272 const sep = pick(['_', '', '_', ''], rand)
273 const tail = rand() < 0.45 ? String(Math.floor(rand() * (rand() < 0.5 ? 100 : 10000))) : ''
274 return `${pick(NAME_A, rand)}${sep}${pick(NAME_B, rand)}${tail}`
275}
276
277// The file a tool event is about, e.g. "register.tsx" from "Agent runs Edit: hooks/register.tsx".
278// Only path-looking tokens count, so prose, shell flags and redirects never become a subject.
279export function subject(a: Activity | undefined) {
280 if (!a || (a.kind !== 'tool' && a.kind !== 'error')) return undefined
281 const target = a.text.split(': ').slice(1).join(': ')
282 const path = target.split(/\s+/).filter(t => /^[\w@.:\\/-]+$/.test(t) && /[./\\]/.test(t) && !t.startsWith('-')).at(-1)
283 const file = path?.split(/[\\/]/).filter(Boolean).at(-1)
284 return file && /\w\.\w/.test(file) ? file.slice(0, 40) : undefined
285}
286
287// Lines about the file the agent is touching, sorted by tone so each mode keeps its mood.
288const ABOUT: Record<'nice' | 'mean', readonly ((s: string, r: Rand) => string)[]> = {
289 nice: [
290 s => `what is ${s}`,
291 s => `oh we're in ${s} now`,
292 (s, r) => `${s} is ${pick(NICE_ADJ, r)}`,
293 (s, r) => `${s} ${pick(HYPE, r)}`,
294 s => `${s} looking good`,
295 ],
296 mean: [
297 (s, r) => `${s} again ${pick(LAUGH, r)}`,
298 (s, r) => `not ${s} ${pick(SAD, r)}`,
299 (s, r) => `${s} is ${pick(MEAN_ADJ, r)}`,
300 s => `i was scared of ${s} too`,
301 ],
302}
303const ABOUT_ANY = [...ABOUT.nice, ...ABOUT.mean]
304
305export function cannedLine(mode: Mode, last: Activity | undefined, rand: Rand = Math.random) {
306 const tone = toneOf(mode)
307 const roll = rand()
308 const about = subject(last)
309 if (about && roll < 0.08) return pick(tone === 'any' ? ABOUT_ANY : ABOUT[tone], rand)(about, rand)
310 const react = last && REACT[last.kind]
311 if (react && roll < 0.2) return pick(forTone(react, tone), rand)
312 // Mode lines get a fixed share whatever just happened, so the mode always shows.
313 const flavour = rand()
314 if (flavour < 0.2) return pick(BY_MODE[mode], rand)
315 if (flavour < 0.26) return pick(forTone(COMMON, tone), rand)
316 return fill(pickTemplate(mode, rand), rand)
317}
318
319// Lines and names shown lately, so the same thing does not come back a minute later.
320const recentLines: string[] = []
321const recentNames: string[] = []
322
323function fresh(make: () => string, recent: string[], keep: number) {
324 let s = make()
325 for (let i = 0; i < 6 && recent.includes(s); i++) s = make()
326 recent.push(s)
327 if (recent.length > keep) recent.shift()
328 return s
329}
330
331export function cannedChat(mode: Mode, count: number, last: Activity | undefined, rand: Rand = Math.random) {
332 const out: { kind: 'chat' | 'notice'; user: string; text: string }[] = []
333 for (let i = 0; i < count; i++) {
334 out.push({
335 kind: 'chat',
336 user: fresh(() => cannedName(rand), recentNames, 30),
337 text: fresh(() => cannedLine(mode, last, rand), recentLines, 80),
338 })
339 }
340 if (rand() < 0.04) {
341 out.push({ kind: 'notice', user: cannedName(rand), text: `subscribed for ${1 + Math.floor(rand() * 24)} months!` })
342 }
343 return out
344}
345types/index.d.ts 19 lines1export type Mode = 'hype' | 'roast' | 'mixed' | 'curious' | 'wholesome' | 'chaos'
2export type Rate = 'quiet' | 'normal' | 'frequent'
3export type Badge = 'broadcaster' | 'mod' | 'vip' | 'sub' | 'prime'
4// streamer: the nickname chat uses; '' means the Claude display name, then the email's local part.
5export type Config = { live: boolean; mode: Mode; viewers: number; rate: Rate; startedAt: number; streamer: string }
6export type ChatLine = {
7 id: number
8 kind: 'chat' | 'streamer' | 'notice' | 'system'
9 user: string
10 text: string
11}
12export type Activity = { seq: number; at: number; kind: 'prompt' | 'tool' | 'error' | 'done' | 'reply' | 'said' | 'raid' | 'subagent'; text: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 chatfish: { config: Config; lines: ChatLine[]; activity: Activity[]; viewers: number; isSettingsLoaded: boolean; sent: number }
17 }
18}
19