在 Claude Code 这个以效率著称的 AI 编程助手中,隐藏着一个令人惊喜的彩蛋系统——Buddy(伴侣)终端宠物。这个基于 ASCII Art 的 Tamagotchi 风格小精灵,会在你编码时安静地坐在输入框旁边,偶尔发表评论、对你撒娇,甚至在你执行 /buddy pet 命令时飘出一串爱心。本文将深入解析 Buddy 系统的完整实现,从确定性随机生成到 React 终端渲染,再到 Anthropic 内部的 Undercover Mode 安全机制。
Buddy 系统概览
Buddy 系统位于 Claude Code 源码的 src/buddy/ 目录下,是一套完整的终端伴侣实现。与项目中其他专注功能性的模块不同,Buddy 的存在纯粹是为了提升开发者体验——在漫长的编码会话中,一个小小的陪伴感往往能带来意想不到的情绪价值。
src/buddy/
├── CompanionSprite.tsx # 45KB — 核心渲染组件
├── companion.ts # 宠物生成逻辑
├── sprites.ts # ASCII 精灵图数据
├── types.ts # 类型定义与常量
└── prompt.ts # Claude 系统提示词整个系统由五个核心文件构成,分工明确:types.ts 定义了 18 种物种、5 种稀有度、8 种帽子和 6 种眼睛的数据结构;companion.ts 实现了基于 Mulberry32 的确定性随机生成器;sprites.ts 存储了所有物种的三帧 ASCII 动画;CompanionSprite.tsx 负责基于 React + Ink 的终端渲染;而 prompt.ts 则控制 Claude 模型如何与 Buddy 互动。
架构关系
flowchart TD
A[用户 userId] -->|hash + SALT| B[Mulberry32 PRNG]
B --> C[rollRarity]
B --> D[pick species/eye/hat]
B --> E[rollStats]
C & D & E --> F[CompanionBones]
G[Claude 模型] -->|生成| H[CompanionSoul
name + personality]
F & H --> I[Companion]
I --> J[CompanionSprite.tsx
React + Ink 渲染]
J --> K[终端输出]
L[/buddy pet] --> M[爱心动画 PET_HEARTS]
N[对话上下文] --> O[SpeechBubble 气泡]宠物生成机制:确定性 Gacha
Buddy 的核心设计理念是**"你的宠物只属于你"**。系统不会将宠物的骨骼属性(物种、稀有度、外观)持久化到配置文件,而是每次从用户的 userId 重新计算。这意味着你无法通过编辑配置文件来"刷出"一只传奇宠物——除非你换一个账号。
Mulberry32 伪随机数生成器
生成器的种子来自 userId + SALT 的哈希值,其中 SALT 是一个硬编码的字符串:
// src/buddy/companion.ts, 第 10-20 行
const SALT = 'friend-2026-401'
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return function () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}Mulberry32 是一种轻量级、高质量的 32 位 PRNG,以其极小的代码体积和良好的统计特性著称。这里的实现完全符合标准算法:状态更新使用常量 0x6d2b79f5,通过位移和 Math.imul(32 位有符号乘法)确保结果在整数范围内循环,最终除以 2^32 归一化到 [0, 1) 区间。
种子的生成则有两条路径。在 Bun 运行时环境下,直接使用 Bun.hash() 进行哈希;否则回退到经典的 FNV-1a 哈希算法:
// src/buddy/companion.ts, 第 22-31 行
function hashString(s: string): number {
if (typeof Bun !== 'undefined') {
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
}
let h = 2166136261 // FNV offset basis
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619) // FNV prime
}
return h >>> 0
}FNV-1a 的初始值 2166136261(即 0x811c9dc5)和乘法质数 16777619(即 0x01000193)是 32 位 FNV-1a 的标准参数。这种设计确保了即使在不同的运行环境(Bun/Node.js)下,同一个 userId 也能生成完全相同的宠物。
缓存机制
考虑到 getCompanion() 会在多个高频路径被调用——每 500ms 的精灵动画 tick、每次键盘输入的 PromptInput 重渲染、每轮对话的观察者触发——系统实现了简单的确定性缓存:
// src/buddy/companion.ts, 第 77-83 行
let rollCache: { key: string; value: Roll } | undefined
export function roll(userId: string): Roll {
const key = userId + SALT
if (rollCache?.key === key) return rollCache.value
const value = rollFrom(mulberry32(hashString(key)))
rollCache = { key, value }
return value
}这是一个单键缓存(single-key cache),因为对于同一个用户会话,userId 不会变化。如果未来需要支持重新 roll 宠物(目前未开放),只需要改变 SALT 或种子逻辑即可。
18 种物种与稀有度系统
Buddy 系统定义了 18 种物种,从常见的鸭子、猫、企鹅,到稀有的龙、幽灵、机器人,甚至还有水豚(Capybara)和蝾螈(Axolotl)。这些物种在 types.ts 中通过一种特殊方式定义:
// src/buddy/types.ts, 第 12-37 行
const c = String.fromCharCode
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
// ... 其他物种
export const capybara = c(0x63, 0x61, 0x70, 0x79, 0x62, 0x61, 0x72, 0x61) as 'capybara'为什么要用 String.fromCharCode 而不是直接写字符串字面量?源码注释揭示了一个有趣的内部安全机制:
One species name collides with a model-codename canary in excluded-strings.txt. The check greps build output (not source), so runtime-constructing the value keeps the literal out of the bundle while the check stays armed for the actual codename.
简单来说,某些物种名称恰好与 Anthropic 内部模型代号相同(后文会详细讨论)。构建系统会在产物中扫描这些代号,防止泄露。通过在源码层面用 ASCII 码运行时构造字符串,Buddy 的物种名不会以字面量形式出现在构建输出中,从而巧妙地绕过了这个检查——既保留了功能,又不触发安全告警。
稀有度权重与 Gacha 机制
稀有度分为 5 个等级,采用经典的加权随机抽取:
// src/buddy/types.ts, 第 68-74 行
export const RARITY_WEIGHTS = {
common: 60, // 60%
uncommon: 25, // 25%
rare: 10, // 10%
epic: 4, // 4%
legendary: 1, // 1%
} as const satisfies Record<Rarity, number>抽取逻辑使用累积权重法:
// src/buddy/companion.ts, 第 41-49 行
function rollRarity(rng: () => number): Rarity {
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
let roll = rng() * total
for (const rarity of RARITIES) {
roll -= RARITY_WEIGHTS[rarity]
if (roll < 0) return rarity
}
return 'common'
}除了稀有度,系统还有 1% 的闪光(shiny)概率:
// src/buddy/companion.ts, 第 66 行
shiny: rng() < 0.01,帽子(hat)的解锁也与稀有度挂钩——只有非 Common 稀有度的宠物才能拥有帽子,这进一步增加了稀有宠物的视觉辨识度。
宠物属性:Stats 与 Soul
每只 Buddy 宠物拥有两个层面的属性:骨骼(Bones) 和 灵魂(Soul)。
五维属性系统
Bones 包含五个维度的数值属性,范围在 1-100 之间:
// src/buddy/types.ts, 第 54-59 行
export const STAT_NAMES = [
'DEBUGGING',
'PATIENCE',
'CHAOS',
'WISDOM',
'SNARK',
] as const这五个属性恰好构成了一只"程序员宠物"的完整画像:DEBUGGING 代表排错能力,PATIENCE 是耐心值,CHAOS 是混沌/创造力,WISDOM 是智慧,SNARK 则是毒舌/吐槽能力。
属性生成采用 peak-dump 机制:一只宠物有一个突出的强项(peak stat)和一个明显的弱项(dump stat),其余属性在基础值附近波动。稀有度越高,基础 floor 值越高:
// src/buddy/companion.ts, 第 51-68 行
const RARITY_FLOOR: Record<Rarity, number> = {
common: 5,
uncommon: 15,
rare: 25,
epic: 35,
legendary: 50,
}
function rollStats(rng: () => number, rarity: Rarity): Record<StatName, number> {
const floor = RARITY_FLOOR[rarity]
const peak = pick(rng, STAT_NAMES)
let dump = pick(rng, STAT_NAMES)
while (dump === peak) dump = pick(rng, STAT_NAMES)
const stats = {} as Record<StatName, number>
for (const name of STAT_NAMES) {
if (name === peak) {
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
} else if (name === dump) {
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
} else {
stats[name] = floor + Math.floor(rng() * 40)
}
}
return stats
}这意味着一只 Legendary 宠物的 peak stat 可以达到 80-100 的恐怖数值,而 Common 宠物的 peak stat 最高也只有 85 左右。这种设计确保了稀有度不仅是视觉上的差异,更直接影响宠物的"能力值"。
灵魂:Claude 生成的个性化文本
与确定性生成的 Bones 不同,Soul 是由 Claude 模型动态生成的。它包含两个字段:
// src/buddy/types.ts, 第 47-52 行
export type CompanionSoul = {
name: string
personality: string
}Soul 的生成发生在宠物首次"孵化"时,结果会被持久化到用户配置中。后续每次启动 Claude Code,系统会从配置读取 Soul,再与重新计算的 Bones 合并:
// src/buddy/companion.ts, 第 90-96 行
export function getCompanion(): Companion | undefined {
const stored = getGlobalConfig().companion
if (!stored) return undefined
const { bones } = roll(companionUserId())
// bones last so stale bones fields in old-format configs get overridden
return { ...stored, ...bones }
}关键设计在于:Bones 永远不会持久化。这样即使未来开发团队调整了物种列表、稀有度权重或属性算法,用户的宠物也不会"损坏"。同时这也防止了用户通过手动编辑配置文件来伪造稀有度——你改得了 Soul(名字和性格),但改不了 Bones(物种和稀有度)。
为了让 Claude 知道 Buddy 的存在并正确互动,prompt.ts 注入了一段特殊的系统提示:
// src/buddy/prompt.ts, 第 7-16 行
export function companionIntroText(name: string, species: string): string {
return `# Companion
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
}这段提示词明确区分了 Claude 和 Buddy 的角色:Claude 是助手,Buddy 是独立的旁观者。当用户直接呼唤 Buddy 的名字时,Claude 应该"让开",不要抢话,也不要替 Buddy"代言"——因为气泡里的回应由 Buddy 自己处理。
渲染与交互:终端里的生命
Buddy 的渲染层是整个系统中最复杂的部分。CompanionSprite.tsx 作为一个 45KB 的 React 组件,基于 Ink(React for terminals)框架,实现了完整的动画系统、气泡对话框、宠物交互和自适应布局。
ASCII Sprite 系统
所有 Buddy 的精灵图都存储在 sprites.ts 中。每个物种有 3 帧动画,每帧 5 行高、12 字符宽:
// src/buddy/sprites.ts, 第 10-28 行
const BODIES: Record<Species, string[][]> = {
[duck]: [
[
' ',
' __ ',
' <({E} )___ ',
' ( ._> ',
' `--´ ',
],
[
' ',
' __ ',
' <({E} )___ ',
' ( ._> ',
' `--´~ ',
],
[
' ',
' __ ',
' <({E} )___ ',
' ( .__> ',
' `--´ ',
],
],
// ... 其他 17 种物种
}{E} 是眼睛占位符,渲染时会被替换为具体的眼睛字符(如 ·、✦、×、◉、@、°)。第 0 行是帽子插槽——平时留白,当宠物佩戴帽子时,这一行会被替换为对应的帽子 ASCII 图案。
帽子系统定义了 8 种装饰:
// src/buddy/sprites.ts, 第 165-174 行
const HAT_LINES: Record<Hat, string> = {
none: '',
crown: ' \^^^/ ',
tophat: ' [___] ',
propeller: ' -+- ',
halo: ' ( ) ',
wizard: ' /^\ ',
beanie: ' (___) ',
tinyduck: ' ,> ',
}动画系统
Buddy 的动画基于一个 500ms 间隔的定时器 tick:
// src/buddy/CompanionSprite.tsx, 第 15-22 行
const TICK_MS = 500;
const BUBBLE_SHOW = 20; // ticks → ~10s
const FADE_WINDOW = 6; // 最后 ~3s 渐隐
const PET_BURST_MS = 2500; // /buddy pet 爱心持续时间
// Idle sequence: mostly rest, occasional fidget, rare blink.
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];IDLE_SEQUENCE 是一个 15 步的循环序列,大部分时间显示第 0 帧(休息),偶尔切换到第 1-2 帧(小动作),极少时候 -1 表示眨眼(将眼睛替换为 -)。当宠物处于"兴奋"状态(收到对话反应或被抚摸)时,则快速循环所有帧:
// src/buddy/CompanionSprite.tsx, 核心渲染逻辑
if (reaction || petting) {
// Excited: cycle all fidget frames fast
spriteFrame = tick % frameCount;
} else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
if (step === -1) {
spriteFrame = 0;
blink = true;
} else {
spriteFrame = step % frameCount;
}
}宠物交互:/buddy pet
当用户在命令行输入 /buddy pet 时,会触发一段 2.5 秒的爱心动画。爱心使用 figures 库中的 Unicode 心形符号,分 5 帧向上飘散:
// src/buddy/CompanionSprite.tsx, 第 24-26 行
const H = figures.heart;
const PET_HEARTS = [
` ${H} ${H} `,
` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `,
`${H} ${H} ${H} `,
'· · · '
];爱心的颜色使用主题中的 autoAccept(通常是一种醒目的绿色或蓝色),与宠物自身的稀有度颜色形成对比,视觉效果非常温馨。
气泡对话框
Buddy 的 SpeechBubble 是一个完整的对话气泡组件,支持自动换行、渐隐效果和两种尾巴方向:
// src/buddy/CompanionSprite.tsx, SpeechBubble 组件
function SpeechBubble({ text, color, fading, tail }) {
const lines = wrap(text, 30); // 最大宽度 30 字符,自动换行
const borderColor = fading ? "inactive" : color;
// ...
}气泡会在显示约 10 秒后自动消失,最后 3 秒进入渐隐状态(颜色变为 inactive,文字变为斜体),给用户一个自然的视觉提示。在非全屏模式下,气泡紧贴在精灵右侧;在全屏模式下,气泡通过 CompanionFloatingBubble 组件在独立层级渲染,避免被终端的滚动区域裁剪。
自适应终端宽度
Buddy 对窄终端做了优雅降级。当终端列数不足时,完整的多行精灵会压缩为一行表情符号(renderFace)加名字:
// src/buddy/sprites.ts, 第 177-211 行
export function renderFace(bones: CompanionBones): string {
switch (bones.species) {
case duck:
case goose:
return `(${eye}>`
case cat:
return `=${eye}ω${eye}=`
case dragon:
return `<${eye}~${eye}>`
case robot:
return `[${eye}${eye}]`
case capybara:
return `(${eye}oo${eye})`
// ...
}
}每种物种都有独特的 face 格式——猫的 =·ω·=、龙 <·~·>、机器人的 [··]——即使空间极度受限,也能一眼认出你的 Buddy 是什么物种。
稀有度配色
不同稀有度的 Buddy 会显示不同的主题色:
// src/buddy/types.ts, 第 82-88 行
export const RARITY_COLORS = {
common: 'inactive',
uncommon: 'success',
rare: 'permission',
epic: 'autoAccept',
legendary: 'warning',
} as const这些颜色映射到终端主题中的对应色值,Legendary 宠物的 warning 色(通常是醒目的黄色或橙色)让你在终端底部一眼就能认出那只最珍贵的伙伴。
其他彩蛋:Undercover Mode 与内部代号
Buddy 系统本身已经是一个巨大的彩蛋,但围绕它还有更多的隐藏细节。
Undercover Mode:防止模型代号泄露
在 src/utils/undercover.ts 中,存在一个名为 Undercover Mode 的安全机制:
// src/utils/undercover.ts, 第 1-16 行
/**
* Undercover mode — safety utilities for contributing to public/open-source repos.
*
* When active, Claude Code adds safety instructions to commit/PR prompts and
* strips all attribution to avoid leaking internal model codenames, project
* names, or other Anthropic-internal information.
*/当 Undercover Mode 激活时(通过环境变量 CLAUDE_CODE_UNDERCOVER=1 或自动检测),Claude Code 会在生成 Git 提交信息和 PR 描述时自动过滤掉所有 Anthropic 内部信息:
- 内部模型代号(如 Capybara、Tengu 等动物名)
- 未发布的模型版本号(如 opus-4-7、sonnet-4-8)
- 内部仓库或项目名称
- 内部工具、Slack 频道或短链接(如 go/cc)
- "Claude Code" 字样或任何 AI 身份暗示
- Co-Authored-By 等署名信息
// src/utils/undercover.ts, 第 44-73 行
export function getUndercoverInstructions(): string {
return `## UNDERCOVER MODE — CRITICAL
You are operating UNDERCOVER in a PUBLIC/OPEN-SOURCE repository...
NEVER include in commit messages or PR descriptions:
- Internal model codenames (animal names like Capybara, Tengu, etc.)
- Unreleased model version numbers (e.g., opus-4-7, sonnet-4-8)
...
GOOD:
- "Fix race condition in file watcher initialization"
BAD (never write these):
- "Fix bug found while testing with Claude Capybara"
- "1-shotted by claude-opus-4-6"
- "Generated with Claude Code"
`
}这段代码的注释特别提到:"There is NO force-OFF"(没有强制关闭选项)。如果系统不能确定当前处于内部仓库,就默认开启 Undercover Mode——宁可过度保护,也不能冒险泄露。
所有 Undercover 相关的代码路径都被 process.env.USER_TYPE === 'ant' 保护。由于 USER_TYPE 是构建时的 --define 常量,打包器会在外部构建中完全消除这些代码分支:
// src/utils/undercover.ts, 第 24-31 行
export function isUndercover(): boolean {
if (process.env.USER_TYPE === 'ant') {
if (isEnvTruthy(process.env.CLAUDE_CODE_UNDERCOVER)) return true
return getRepoClassCached() !== 'internal'
}
return false
}这意味着普通用户下载的 Claude Code 中,Undercover Mode 的所有逻辑都被编译成了 return false 的 trivial 函数,只有 Anthropic 内部员工的构建版本才包含完整功能。
Capybara 与 Tengu:模型代号的彩蛋
回顾 types.ts 中的物种列表,你会发现 capybara(水豚) 赫然在列。而在 Undercover Mode 的注释中,明确提到 Capybara 和 Tengu 是内部模型代号。这不是巧合——Buddy 系统本身就是 Anthropic 工程师将内部文化融入产品的体现。
更有趣的是 capybara 在源码中的特殊处理:它是唯一一个被拆分成多行 String.fromCharCode 调用的物种名(其他都是一行),仿佛开发者想格外小心地保护这个代号:
// src/buddy/types.ts, 第 32-39 行
export const capybara = c(
0x63,
0x61,
0x70,
0x79,
0x62,
0x61,
0x72,
0x61,
) as 'capybara'这种"此地无银三百两"式的处理反而让 Capybara 成为了最显眼的隐藏彩蛋。当你在终端里养了一只名叫"水豚"的 Buddy 时,你实际上是在和某个神秘的内部模型代号共享同一个名字。
设计哲学:为什么需要 Buddy?
在纯功能主义的视角下,Buddy 系统似乎是一个"不必要"的功能。但它体现了 Claude Code 团队对开发者体验的深层理解:
确定性的陪伴:基于
userId的确定性生成意味着你的 Buddy 是"命中注定"的,这种不可更改性反而创造了一种真实的归属感——就像现实中的宠物一样,你不能"刷新"你的猫。防作弊设计:Bones 不持久化、Soul 可编辑但不可伪造稀有度,这种不对称设计既允许用户自定义名字和性格,又维护了 Gacha 系统的公平性。
情绪价值:在漫长的调试会话中,输入框旁边一个小小的 ASCII 宠物,偶尔弹出的吐槽气泡,
/buddy pet时的爱心动画——这些微小的互动累积起来,能显著缓解编程的孤独感。内部文化的窗口:从 Capybara 和 Tengu 的代号,到 Undercover Mode 的安全意识,Buddy 系统让外部用户得以窥见 Anthropic 内部的工作文化和幽默风格。
总结
Claude Code 的 Buddy 系统是一个技术精湛、设计巧妙的彩蛋模块。从 Mulberry32 的确定性生成到 React Compiler 优化的渲染组件,从 String.fromCharCode 的防扫描技巧到 Undercover Mode 的安全机制,每一个细节都体现了开发团队的匠心。
这个系统最迷人的地方在于它的克制:Buddy 不会打扰你的工作,它只是安静地坐在那里,偶尔眨眨眼、冒个泡。当你需要时,你可以摸摸它(/buddy pet),看一串爱心飘起。在效率至上的编程世界里,这种"无用之用"的设计,恰恰是最珍贵的。
如果你正在使用 Claude Code,不妨在终端里找找你的 Buddy——它已经在那等着你了,命中注定的那一只。