SLOPSHOPPER

Chatfish

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.

newpaneguardcommandtoaststatus
v0.1.2MITupdated 2026-10-06walangstudio/chatfish
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chatfish
│ ┃ chatfish ✕ › fix the failing auth test and add an audit log call │ ┃ ⇤ STREAM CHAT │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ Stream is offline. /chatfish on ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /chatfish │ ┃ ⎿ chatfish: chatfish is offline (streamer, mixed, 1.2K viewers, no │ ┃ ⎿ chatfish: Run /chatfish help for every option. │ ┃ ⎿ chatfish: Usage: /chatfish <on|start|live|off|stop|offline> [mod │ ┃ ⎿ chatfish: modes: hype, roast, mixed, curious, wholesome, chaos │ ┃ ⎿ chatfish: Live settings: /chatfish config name=<nickname|auto> v │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · chatfish
⇤ STREAM CHAT ● 0 ──────────────────────────────────────────────────────── Stream is offline. /chatfish on
README

chatfish

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.

Requirements

  • Claude Code 2.1.281 or later. Found by bisecting the published builds: 2.1.281 loads chatfish and passes its tests, 2.1.280 doesn't. Older builds print a chatfish: hooks module did not load line, and chatfish shows a warning toast if it ever loads on an older build anyway.
  • Mods switched on. chatfish is a mod (a plugin built on Claude Code's function hooks), which is still early access. Add this to the env block of ~/.claude/settings.json:
  "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  • Works in the terminal and in Claude Desktop's Code tab. Desktop looks better: badges are real icons and the colors are exact. The terminal falls back to emoji badges.

Install

/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

Commands

CommandWhat it does
/chatfish liveGo live. Also on or start. Chat starts empty and fills up as viewers arrive.
/chatfish live roast 5k frequentGo live with a mode, viewer target and chat rate, in any order.
/chatfish offEnd 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.
/chatfishShow the current settings. Also status.
/chatfish helpList every command, setting, mode and rate.

You can also type straight into the "Send a message" box at the bottom of the pane.

Settings

Settings stick across sessions.

/chatfish config name=CodeCat viewers=5k
/chatfish config nickname: CodeCat, audience: 300, mode: roast, rate: spam
/chatfish config name=auto
KeyAliasesValues
namenick, nickname, streamerWhat chat calls you, 1-25 characters, no spaces. auto uses your Claude display name, then the part of your email before the @, then streamer.
viewersaudienceTarget audience, 1 to 1,000,000. 5k works.
modehype, roast, mixed (default), curious, wholesome, chaos
ratechatquiet, normal (default), frequent. less, moderate, spam work too.

What chat sees

  • Your prompts, every tool Claude runs (with the command or file), errors, and what Claude says at the end of a turn.
  • How long nothing has happened, and whether Claude is still thinking or just done.
  • Subagents: when Claude sends one out, what it is doing, and what it reports back.
  • Your replies, plus the last 15 to 30 lines of chat, so conversations keep going.

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.

Cost

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.

Development

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.

License

MIT

Source 4 files
hooks/register.tsx 502 lines
1import { 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}
502
hooks/chat.ts 374 lines
1import 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}
374
hooks/canned.ts 345 lines
1import 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}
345
types/index.d.ts 19 lines
1export 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