dsh-pet
一个 DeepSeek Harness 的 桌面宠物 插件(Codex / Shimeji 风格):一个透明、置顶、可拖拽的小家伙,实时感知 dsh 的工作状态并切换动作与表情,让枯燥的终端 agent 变得有「陪伴感」。
- 待机 idle —— 待在角落呼吸、偶尔眨眼/伸懒腰。
- 忙碌 busy 及子状态 —— dsh 在跑工具调用时,按工具类型切换具体动作(读文件/写代码/跑命令/联网搜索/卡住/排队)。
- 开心 happy —— 任务成功完成。
- 沮丧 sad —— 出错/失败,弹一句简短错误摘要(不是完整堆栈)。
- 打盹 sleep —— 长时间无活动自动入睡。
宠物是独立 Electron 进程(detached 拉起),dsh 主机退出后它仍继续存活,直到你手动关闭——这是「陪伴感」的重点。
目录
- [安装](#安装)
- [使用](#使用)
- [内置皮肤](#内置皮肤)
- [皮肤格式](#皮肤格式)
- [台词池](#台词池)
- [busy 子状态与工具映射](#busy-子状态与工具映射)
- [配置](#配置)
- [架构](#架构)
- [开发](#开发)
- [常见问题](#常见问题)
安装
dsh plugin --profile web add <dsh-pet 的绝对路径>
# 或在本目录下:
dsh plugin --profile web add .dsh plugin add 会转发给 profile 目录下的 pnpm,并自动把本插件(声明了 dsh.bundle.patch)注册进 bundle 列表。若 profile 正在运行,需重启一次让它加载新 bundle。
一次性:给宠物窗口装 Electron
Electron 刻意不放进插件根目录的依赖里,这样 dsh plugin add 保持轻量(只依赖 ws)。装一次 Electron:
cd <path/to/dsh-pet>/pet-process
pnpm install # 下载 Electronpnpm 可能会要求你用 pnpm approve-builds 批准 Electron 的 post-install(二进制下载)——这是本插件唯一的、预期内的审批。也可以用已有的 Electron:
# 在 profile 的 cordis.patch.yml 里针对 dsh-pet 这一行覆盖:
- id: dsh-pet
config:
electronPath: 'C:/path/to/electron.exe'
# 或设置环境变量 DSH_PET_ELECTRON使用
插件在所有 dsh 界面(Web / TUI / headless)注册了一个人类命令 pet:
/pet start [--skin <名字|路径>] 启动桌面宠物
/pet stop 停止桌面宠物
/pet status 查看宠物进程状态
/pet help 查看帮助--skin 支持裸名字(自动解析到 skins/ 目录下,如 pixel-v1、pixel-human-v1、default)或绝对路径。
说明:
start幂等——若已有存活 pid 会提示「已在运行」而不是重复拉起。stop可靠地杀掉整个 Electron 进程树(Windows:taskkill /T /F;POSIX:SIGTERM→SIGKILL),并清除 pid 文件。- 宠物在 dsh 主机退出后继续存活。主机重启后需
/pet stop再/pet start重新挂载到新主机。
右键菜单
- 对话气泡 —— 开关气泡。
- 静音(不打扰) —— 关掉宠物所有「碎嘴」(俏皮话 + 场景台词);错误摘要仍会显示。
- 切换皮肤 —— 在
skins/下所有皮肤之间切换。 - 打开设置 —— 打开持久化设置 JSON(位置、皮肤、开关)。
- 退出 —— 退出宠物。
点击(不拖动)宠物会随机触发一句 idle_click 俏皮话。
内置皮肤
| 皮肤 | 类型 | 说明 |
|---|---|---|
skins/default | emoji 占位 | 最早的兜底皮肤,零图片资源 |
skins/pixel-v1 | 程序化像素史莱姆 | 64×64、描边 + 分色块阴影 + 抖动过渡,代码生成 |
skins/pixel-human-v1 | 程序化像素小人 | 戴兜帽的小码农/法师,有独立手臂腿,代码生成 |
三套皮肤都对齐同一套 11 状态状态机(idle / busy / reading / writing / executing / searching / stuck / happy / sad / queued / sleep)。
重新生成像素皮肤(零外部美术依赖,纯 Node 内置 zlib 写 PNG):
node scripts/generate-skin.mjs # 重新生成 skins/pixel-v1
node scripts/generate-human-skin.mjs # 重新生成 skins/pixel-human-v1每个皮肤目录里都有:
manifest.json—— 皮肤描述与各状态帧数/fps;preview.png—— 每行标注状态名的彩色预览图;*-comparison.png—— 黑白剪影对比图(轮廓辨识度核对用)。
皮肤格式
皮肤 = 一个文件夹,含 manifest.json(以及可选的 sprite sheet + lines.json):
{
"name": "my-pixel-pet",
"version": 1,
"size": { "width": 64, "height": 64 }, // 单帧像素尺寸
"scale": 2, // 窗口放大倍数(像素画用 image-rendering: pixelated)
"anchor": { "x": 0.5, "y": 1.0 }, // 脚底居中锚点(信息性)
"emoji": "🐣", // 某状态无 sheet 时的 emoji 兜底
"fps": 4, // 默认播放速率
"states": {
"idle": { "sheet": "idle.png", "frames": 6, "fps": 2 },
"busy": { "sheet": "busy.png", "frames": 6, "fps": 6 },
"reading": { "sheet": "reading.png", "frames": 5, "fps": 4 },
"writing": { "sheet": "writing.png", "frames": 6, "fps": 7 },
"executing": { "sheet": "executing.png", "frames": 6, "fps": 6 },
"searching": { "sheet": "searching.png", "frames": 5, "fps": 4 },
"stuck": { "sheet": "stuck.png", "frames": 5, "fps": 3 },
"happy": { "sheet": "happy.png", "frames": 6, "fps": 8 },
"sad": { "sheet": "sad.png", "frames": 4, "fps": 3 },
"queued": { "sheet": "queued.png", "frames": 4, "fps": 3 },
"sleep": { "sheet": "sleep.png", "frames": 4, "fps": 1 }
}
}sheet是相对皮肤目录的图片路径;帧水平排列,每帧size.width × size.height;实际显示尺寸为size × scale。- 某状态没有
sheet(或图片缺失)时回退到emoji+ CSS 动画——default皮肤就是这么零资源工作的。 - 皮肤可自带
lines.json覆盖台词池;否则使用pet-process/renderer/lines.json。
台词池
lines.json 是「场景 → 台词数组」的对象,每个触发点随机抽一句,纯装饰、不接 LLM:
| 场景 | 触发时机 |
|---|---|
idle_click | 点击宠物 |
task_start | 任务开始(进入忙碌) |
task_success | 任务成功(happy) |
task_fail | 任务失败(sad,与错误摘要合并显示) |
idle_too_long | 空闲约 60% 时长,每次空闲段触发一次 |
sleep_wake | 从 sleep 醒来 |
random_ambient | 空闲时每随机 3–8 分钟自动弹一句 |
旧的扁平字符串数组格式仍兼容,会被包成 idle_click。
busy 子状态与工具映射
busy 被拆成子状态,映射到实际调用的工具。映射表在 tool-behavior-map.json(独立于代码,加/删工具名无需重新构建):
{
"reading": ["read", "read_image", "glob", "grep", "ssh_list"],
"writing": ["write", "edit", "str_replace_editor"],
"executing": ["bash", "pwsh", "ssh_exec", "ssh_cluster", "ssh_upload", "ssh_download", "ssh_tunnel"],
"searching": ["web_search", "web_fetch"]
}| 子状态 | 触发 | 说明 |
|---|---|---|
reading | 读文件/搜索代码类工具 | 翻书/扫视 |
writing | 写文件/编辑类工具 | 打字 |
executing | shell / ssh 类工具 | 抡锤/举臂 |
searching | 联网搜索/抓取类工具 | 举天线 |
stuck | 某工具调用超过 stuckThresholdMs(默认 20s)仍未返回 | 抓头 |
busy | 未映射的陌生工具 | 通用忙碌兜底 |
- 宿主侧状态机用「在飞调用栈」跟踪:最新调用驱动当前子状态;顶调用超时则切
stuck,调用结束后回到 happy/sad。 queued未接线:dsh 事件总线没有暴露工具调度队列长度,没有诚实的触发信号,所以不做伪造数据触发。queued的 sprite sheet 已生成,等以后有队列信号即可启用。
配置
配置都在 dsh-pet 这一行(见 cordis.patch.yml),可在 profile 自己的 cordis.patch.yml 里按 id 覆盖:
| key | 默认 | 含义 |
|---|---|---|
idleTimeoutMs | 600000 | 无活动多久后打盹 |
stuckThresholdMs | 20000 | 工具调用超过该时长切 stuck |
bubbleOnError | true | 失败时显示错误摘要气泡 |
electronPath | — | Electron 二进制绝对路径 |
skin | 内置 skins/default | 默认皮肤目录 |
架构
dsh 主机(本插件) Electron 宠物(detached)
┌──────────────────────────────┐ WebSocket ┌──────────────────────────────┐
│ src/index.ts apply(ctx) │(回环随机端口│ pet-process/main.js │
│ ├─ /pet start|stop|status │ + token) │ ├─ 透明置顶窗口 │
│ ├─ tools/execute → busy 子状态│ ─────────► │ ├─ WS 客户端(断线重连) │
│ ├─ tools/result → happy/sad│ │ ├─ 原生右键菜单 │
│ ├─ jobs.onJobDone → happy/sad│ │ └─ 光标轮询式鼠标穿透/拖拽 │
│ └─ jobs.onJobsChanged → busy │ │ renderer/ │
│ src/state.ts 情绪状态机(子状态+stuck) │ ├─ skin-loader.js(精灵表) │
│ src/ipc.ts WS 服务端 │ └─ pet.js(情绪/气泡/台词) │
└──────────────────────────────┘ └──────────────────────────────┘用到的状态信号(已对照实际发布的包核实,非臆测):
| 信号 | 来源 | 含义 |
|---|---|---|
tools/execute(waterfall) | @deepseek-ai/dsh-tools | 工具开始 → 按工具名切 busy 子状态 |
tools/result(emit) | @deepseek-ai/dsh-tools | 工具结束 → happy / sad |
ctx.jobs.onJobDone | @deepseek-ai/dsh-jobs | 后台 job 结束 → happy / sad |
ctx.jobs.onJobsChanged | @deepseek-ai/dsh-jobs | job 注册表变化 → 有界 busy 脉冲 |
进程生命周期:pid/port/token 记录在 $DSH_HOME/dsh-pet.json(缺省 ~/.dsh)。start 复用存活 pid;stop 杀掉记录的 pid 并清文件。Electron 窗口的位置/皮肤/开关存在它自己的 userData 目录。
开发
# 宿主侧:TypeScript 源码在 src/,发布入口是已编译的 lib/
pnpm install # 装 dev 依赖(typescript、@types/*)
pnpm build # tsc: src/*.ts -> lib/*.js
node scripts/smoke-lib.mjs # 冒烟测试(状态机 + IPC + 接线,跑编译后的 lib)
# 像素皮肤(零依赖,纯 Node zlib PNG 编码器)
node scripts/generate-skin.mjs # 重新生成 skins/pixel-v1
node scripts/generate-human-skin.mjs # 重新生成 skins/pixel-human-v1
node scripts/generate-silhouette-comparison.mjs # 史莱姆剪影对比(旧脚本)
node scripts/ascii-preview.mjs <state> # 把生成的帧转成字符画核对
node scripts/frame-diff.mjs # 逐帧像素差异,检查动画是否真的在动
# 宠物侧(需要 pet-process 里装好 Electron)
cd pet-process && pnpm start # 单独跑窗口
node scripts/pet-demo-substates.mjs --skin pixel-human-v1 # 演示全部 11 状态(--skin <名字>)
node scripts/pet-demo.mjs pixel-v1 # 演示 5 种基础情绪(位置参数皮肤名)
node scripts/pet-boot-test.mjs pixel-v1 # 对 mock 宿主做启动/握手测试(位置参数皮肤名)
node scripts/pet-restart.mjs pixel-v1 # 原地重启真实宠物并切换皮肤(位置参数皮肤名)
node scripts/pet-observe.mjs # 直连宿主抓取真实状态帧(验证分类)lib/ 已提交入库,是 dsh plugin add 实际加载的入口;只有改了 src/*.ts 才需要重新 pnpm build。
常见问题
- 装 Electron 卡在 approve-builds:
pnpm approve-builds批准electron,或改用npm install(npm 默认执行 postinstall),或用electronPath/DSH_PET_ELECTRON指向已有 Electron。 - 切皮肤后还是老样子:先
/pet stop再/pet start --skin <名字>;start对已运行的宠物不会热切换。 - 路径含中文/空格:皮肤路径会自动做
file://百分号编码,正常解析;--skin用裸名字最省事。 - 改了代码要生效:
src/*.ts改完需pnpm build并重启 dsh 主机;pet-process/或skins/改完只需重启宠物(pet-restart.mjs或/pet stop+/pet start)。
License
[MIT](./LICENSE)