dsh-workbuddy-switcher
Verifieddsh-workbuddy-switcher · v0.3.9 · MIT · Web UI
Bring WorkBuddy desktop app models into DeepSeek Harness with one install — manage a local pool of accounts, switch between them, and rotate automatically when an account runs out of quota.
Install
dsh plugin add dsh-workbuddy-switcher Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
DSH WorkBuddy Switcher
English | 中文
在 DeepSeek Harness 里管理一组 WorkBuddy 账号,随时切换;账号额度用尽时自动换号并重试当前请求。
一个插件装完就能用:把 WorkBuddy 的模型接进 DSH 的那部分(模型提供者、凭据读取、用量与签到接口)已经内置在本包里,不需要再装别的东西。
解决的痛点
WorkBuddy 一次只能使用一个账号,切换账号要么重开 WorkBuddy 客户端登录,要么手改配置文件。而 WorkBuddy 每天有额度上限,一个账号用完后当天就不能再用了。
多台机器(比如公司和家里)之间,账号库还是各自独立的——在 A 机器上登录过的账号,B 机器上得重新登录一遍。
本插件做五件事:
- 账号库——把每个账号的登录凭据存档到本地,之后切换不需要再打开客户端
- 一键切换——设置页卡片或右下角悬浮按钮,切换后模型列表立刻刷新
- 自动轮换——命中额度限制时,自动换下一个账号并重试,你几乎感觉不到中断
- 跨机器同步——导出一个文件,在另一台机器导入,账号库就对齐了
- 每日自动签到——每个账号每天自动领 100 积分,关机错过会在开机后补上
功能
账号库与切换
- 归档目录:
~/.dsh/workbuddy-accounts/,一个账号一个 JSON - 账号行的标题是打码手机号(如
180****0331),不再是 uid 片段——同一台机器上挂着几个号时,只有手机号能让你一眼认出是哪个 - 这个号码 WorkBuddy 服务端只下发打码形态,插件也只存、只渲染这一形态:解析器会明确拒绝完整号码,所以这个插件不可能显示出一个能拨通的手机号
- 客户端当前登录的账号会自己进列表并存档,不用点「导入当前账号」;那个按钮仍然留着,只在插件读不到客户端时才用(见添加账号)
- 同一账号的多份副本会自动去重,只显示规范文件
- 界面每 10 秒自动刷新
跨机器同步(导出 / 导入)
在公司和家里各用一台机器时,用它同步账号:
- 导出凭证——把整个账号库打包成一个文件下载
- 导入凭证——选一个导出文件,合并进本机账号库
导入是合并,不是覆盖,规则如下:
| 情况 | 处理 |
|---|---|
| 本机没有该账号 | 新增 |
| 本机有,且导出文件的刷新令牌更晚 | 替换(旧文件先备份成 <uid>.json.imported-<时间戳>) |
| 本机有,且本机的更晚 | 保留本地,不动 |
最后一条是关键:在导出它的那台机器上重新导入同一个文件,不会把自己登出。
⚠️ 导出文件是明文令牌,等同于这些账号的登录凭据。请只放进你自己的加密存储,用完删掉。
提醒:凭证一旦离开本机,无法远程吊销。WorkBuddy 服务端只有账号级吊销(改密码),没有按会话/设备踢人的接口。
自动轮换
命中额度限制时自动换号,规则如下:
| 上游返回 | 处理 | 原因 |
|---|---|---|
429 soft_rate(没有积分措辞) |
✅ 只冷却这个模型,换号并重试 | 换账号能立刻恢复;但同账号的其它模型仍可用 |
429 soft_rate + 积分措辞 |
✅ 冷却整个账号,换号并重试 | 实际是积分用尽,只是状态码写成了 429 |
402 hard_credit |
✅ 冷却整个账号,换号并重试 | 同上 |
401 / 12153 |
❌ 不换号 | 换账号解决不了,需要重新登录 |
| 内容审核拦截 | ❌ 不换号 | 与账号无关 |
5xx |
❌ 不换号 | 交给 dsh-llm-retry 退避重试 |
限额标记跟模型绑定
这是本插件和「一遇 429 就把账号判死」的关键区别。
WorkBuddy 对单个模型被限流和账号积分用尽返回的是同一个 429 soft_rate,
光看状态码分不出来。所以冷却记在实际被限制的那个范围上:
| 判定依据 | 冷却范围 |
|---|---|
402 / 消息里出现「积分不足、额度不足、quota exceeded」等 |
整个账号(所有模型) |
只有 429 soft_rate |
只有失败请求所用的那个模型 |
| 拿不到失败的模型名 | 整个账号(宁可保守) |
后果是:某账号在 claude-sonnet-4-5 上被限流后,
- 它会从这个模型的候选里退出,请求换到别的账号;
- 但它在其它模型上仍是健康账号,仍然参与轮换和回收;
- 界面上的标记也会写成「模型名 冷却至 …」,而不是让整个账号看起来都没额度了。
冷却时长:
- 上游消息带「将在 … 重置」→ 精确冷却到那一刻
- 没有重置时间 → 第 1-2 次冷却 10 分钟,第 3 次起 24 小时
冷却状态持久化,重启不丢;到期自动失效,并通过每分钟一次的扫描自动切回更优的账号。
每日自动签到
账号库里的每个账号每天自动领一次签到积分(当前是 100 积分/天),不需要打开 WorkBuddy 客户端。
- 开机补签——启动 90 秒后跑一次,之后每 30 分钟扫一次;昨晚关机错过的那次,开机就补上
- 幂等——上游按天去重,重复领只会返回「今天已签到」,不会多给也不会重复扣
- 只对能用得上的账号发请求——国际版(WorkBuddy AI)没有这个活动,直接标「不适用」,不发请求
- 签到季感知——活动结束后(
active: false)每天最多查一次状态,界面显示结束日期 - 看不懂的失败不会拖垮别的——一个账号 401 / 超时只影响它自己,其余账号照常签,轮换和回收完全不受影响
界面上每个账号一行标签:已领 +100 · 连签 3 天 / 今日已签 +100 · 连签 12 天 /
签到失败 · 令牌失效 / 签到季未开启 · 10-31 结束;顶部有 今日签到 3/4 的汇总,
底部是「每日自动签到」开关和「全部签到」按钮,每个账号卡片上还有单独的「签到」按钮。
令牌会自己续:签到遇到 401 时,插件用存档里的刷新令牌换一个新的 access token, 原子写回存档(写回前会比对文件是否被别人改过,被改过就放弃本次写回)。 这是为了让长期没被选中、不参与对话的账号也不会因为令牌过期而签不上。
签到结果的含义
| 标签 | 含义 | 后续 |
|---|---|---|
已领 +100 |
这次真的领到了 | 今天不再请求这个账号 |
今日已签 +100 |
今天之前已经领过(可能是客户端领的) | 今天不再请求 |
签到季未开启 |
活动没开或已结束 | 今天最多查一次 |
国际版无签到 |
WorkBuddy AI 没有这个活动 | 不发请求 |
签到失败 · 令牌失效 |
401/403,刷新令牌也没救回来 | 不再重试,需要重新登录该账号 |
签到失败 · 网络异常 |
超时 / 5xx / 429 | 10 / 30 / 60 分钟后重试 |
签到失败 · 异常 |
其它业务错误 | 同样退避重试,鼠标悬停看原始 msg |
手动签到
- 「全部签到」——立刻把整个账号库跑一遍,忽略「今天已签」的短路
- 单个账号的「签到」——只跑这一个,用来验证某个号到底能不能领
两个都是幂等的:今天已经领过再点,只会看到「今日已签」。
连点很安全。 快速连点会被合并成一轮,不会叠加请求:
- 浏览器侧:正在跑的请求存进
pendingCheckinref,后续点击直接复用它 - Host 侧:进程级
sweepOncepromise,让定时器与按钮共用同一次扫描
实测 8 次并发点击 + 4 个账号 = 4 次上游调用(每账号一次),不是 32 次。
被动回收
冷却到期只是让账号重新可用,并不会自动切回去。插件每分钟检查一次:如果当前账号不再是健康账号里最优的那个,就切回去。
两道保护:
- 当前账号对当前路由的模型仍在冷却中 → 跳过(让轮换自由进行); 如果它只是别的模型被限,不会挡住回收
- 有 Agent 正在跑任务 → 跳过(不打断进行中的回合)
手动切换的保护期
手动点「切换到此账号」后,24 小时内不会被自动切回更优账号。
原因:被动回收是按「刷新令牌寿命最长」排序的,如果手动选的账号恰好不是最长寿的那个, 下一轮扫描(≤60 秒)就会把它改回去 —— 表现为「点了 66ce,过一会自己跳回 b058」。
保护期只挡住被动回收,不挡限流轮换:
| 期间发生 | 行为 |
|---|---|
| 到点 / 冷却到期,想切回更长寿账号 | ⏸ 不动(保护期内) |
| 当前账号命中 429 / 402 | ✅ 照常换号并重试 |
也就是说 24 小时内只有被限流才会自动切走。保护期到期后回收恢复正常, 界面顶部会显示「手动选定保留至 …」,到期自动消失。
切换来源记录在状态文件的 selected.source(user / auto)与 selected.holdUntilMs。
界面
两个入口,共用同一份数据:
| 位置 | 形态 |
|---|---|
| 插件设置页 | 完整列表、账号详情、冷却状态、自动轮换开关 |
| 右下角悬浮按钮 | 快速切换弹层 |
插件设置页(左侧栏「插件」→ dsh-workbuddy-switcher):每个账号以打码手机号作标题(如 180****0331),旁边是区域、签到状态与冷却标记,冷却倒计时后面紧跟清掉这条冷却的「解除」按钮;下面一行是完整 uid 与刷新令牌到期时间,底部是自动轮换与每日签到两个开关。
账号曾经用 uid 片段当标题(8bc5fd09…b058),但一串十六进制说明不了「这是哪个号」——同一台机器上挂着几个账号时,手机号才是你认得出的东西。号码来自 WorkBuddy 桌面端的身份文件,而上游只下发打码形态,插件也只存、只渲染这一形态:完整号码的形状会被解析器直接拒掉,所以这里不会、也不能显示出一个能拨通的号码。

悬浮按钮(默认右下角):聊天时随手切换,按钮上的标签就是当前账号的打码手机号;面板里同样能看到冷却状态、导入账号,每条冷却标记后面紧跟着它自己的「解除」小按钮。可用鼠标或触屏拖动,位置保存在当前浏览器中;窗口缩小时自动保持在可见范围内。单击或键盘 Enter / Space 仍可展开面板。

图中 4dba68a4 处于冷却(「冷却至 10/19 09:56」),正是额度用尽被自动切走后留下的状态;当前使用的是 128a8f9c。这两张截图取自手机号标签上线之前,所以标题还是 uid 片段——装上新版后同一行会显示成 180****0331 这样的打码号码。
每日签到:顶部「今日签到 4/4」汇总本次结果,每个账号一行状态标签——今日已签 +100 · 连签 12 天 这种就是今天已经领过了。

四个账号同一天的四种状态都能在图里看到:已签(连签 12 天 / 1 天)、正在使用中、以及某个账号同时挂着模型级冷却(deepseek-v4.1-flash 冷却至 10/19 09:56)却依然签到成功——冷却和签到互不影响。
安装
前置
- DSH
0.2.1-rc.2或更新的内核 - 本机已登录过至少一个 WorkBuddy 账号
一句话安装(推荐)
把下面这句交给本机已有的 Agent(这个 DSH 会话就够用):没装就装,装了但不是最新版就更新。
安装 dsh-workbuddy-switcher 插件(npm 包名 dsh-workbuddy-switcher,仓库 https://github.com/ddGuo/dsh-workbuddy-switcher),本机没有就装,已有但不是最新版就更新。装完提醒我完全退出并重开 DSH。
它背后跑的就是下面这一条。首次安装用它;升级要写版本号(原因见下):
dsh plugin --profile desktop add dsh-workbuddy-switcher --config.strict-dep-builds=false
升级必须写版本号。 第一次安装可以不写(取 npm 的 latest),但只要已经装过,profile 里记的就是一个精确版本("dsh-workbuddy-switcher": "0.3.6"),再跑一次不带版本号的 add 会被 pnpm 判为已经满足、不会升级——实测:add dsh-workbuddy-switcher 返回 0,装的还是 0.3.6。所以要升级就明确写上目标版本:
dsh plugin --profile desktop add "[email protected]" --config.strict-dep-builds=false
- 必须带
--config.strict-dep-builds=false:pnpm 11 会把@google/genai、protobufjs的构建脚本被忽略当成失败(ERR_PNPM_IGNORED_BUILDS),整条命令随之退出。- 这个包名在 npm 上是
dsh-workbuddy-switcher,不要加 scope,也不叫dsh-workbuddy-connect——后者是 0.3.0 就并进来的前身包,单独装会出现两个workbuddyprovider。
从旧版本升级
先看当前装的是哪一版:
dsh plugin --profile desktop list --depth 0
然后按来源选一条:
| 当前来源 | 升级命令 |
|---|---|
npm(0.3.x) |
dsh plugin --profile desktop add "dsh-workbuddy-switcher@<目标版本>" --config.strict-dep-builds=false(必须写版本,见上) |
git 标签(github:…#v0.3.x) |
同上;会换成 npm 来源,输出里出现 - 0.3.6 / + 0.3.9 这一对 |
本地 file: |
先 remove dsh-workbuddy-switcher 再 add(见下节) |
从 0.2.x 上来还要多一步:0.3.0 把模型提供者并进了本插件,旧的独立包必须卸掉,留着会有两个 provider 抢同一个模型名:
dsh plugin --profile desktop remove dsh-workbuddy-connect
dsh plugin --profile desktop add "dsh-workbuddy-switcher@<目标版本>" --config.strict-dep-builds=false
旧包没装过就只跑第二条。它自带的 llm-workbuddy 提供者层随包消失,现在由本插件那一行(id: workbuddy-switcher)承载,所以要配置提供者(authFile、探针端口等)就写在那一行下面。
如果之前给旧包加过版本兼容豁免,顺手撤销:
dsh plugin --profile desktop version-exemptions # 看还有没有
本插件的 peer 与 engines.dsh 都写成了范围(>=0.2.1-rc.2 <0.3.0-0),DSH 升级不会再被兼容闸门拦住,这类豁免已经没用。
从本地源码装(改代码时用)
dsh plugin --profile desktop add "file:E:\dsh\dsh-workbuddy-switcher"
注意:
file:安装是复制不是软链接,而且重复add会被 pnpm 判为Already up-to-date。改完源码要么remove+add,要么直接同步:Copy-Item "E:\dsh\dsh-workbuddy-switcher\lib" ` "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-workbuddy-switcher\" -Recurse -Force改过
lib/client.js或lib/bridge/client.js之后,先跑npm run build:client重新生成lib/client.bundle.js——DSH 只会加载打包好的那一份。
装完怎么确认版本
# 装的版本
(Get-Content "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-workbuddy-switcher\package.json" -Raw | ConvertFrom-Json).version
# npm 上的最新版
npm view dsh-workbuddy-switcher version
界面里也有提示:插件设置页有更新提醒浮层,只在确实有新版本时出现,点开会给出升级命令(可直接复制给 Agent)。
重启
Host 侧组件(agent/request-error 监听器、回收定时器、fetch 兜底)只在启动时注册,安装后必须完全退出 DSH 再打开。
使用
添加账号
在 WorkBuddy 客户端登录目标账号即可,其它什么都不用做:Host 每 20 秒扫一次桌面端身份文件(启动 5 秒后先跑一次),发现登录的账号不在账号库里就自动导入并存档,界面每 10 秒轮询一次,所以最迟半分钟内它就会出现在列表里。
账号以打码手机号命名(如 180****0331),号码在导入时一并记进存档(account.nickname),之后列表不必再解密任何东西就能标出这一行。这串号码是 WorkBuddy 自己给的打码形态,服务端从不返回完整号码,插件也只接受这一形态——完整号码的形状会被解析器拒掉。也就是说,仓库里、界面上都不可能存在一个能拨通的手机号。
本次改动之前导入的旧存档没有记号码,那些行会先退回 uid 片段(8bc5fd09…b058)。这个没有迁移脚本也不需要:WorkBuddy 只暴露当前登录账号的手机号,所以当那个号在客户端重新登录一次、扫描补上号码时,它就会换成手机号——补号码只写 account.nickname 这一个字段,不会碰 token。
「导入当前账号」按钮仍然在,用在插件读不到客户端的时候:WorkBuddy 没装、身份文件不可读,或者你需要立刻导入而不想等下一轮扫描。
不管走哪条路,账号库目录里都会多出一个 <uid>.json。
切换账号
在卡片里点「切换到此账号」,或点右下角悬浮按钮(按钮上的标签就是当前账号的打码手机号)。约 30 秒内生效(插件的凭据轮询周期)。
同步到另一台机器
公司机器:
- 卡片里点「导出凭证」
- 浏览器直接下载
workbuddy-accounts-<时间戳>.json
家里机器:
- 卡片里点「导入凭证」,选那个文件
- 界面报告「新增 N,更新 M,保留本地 K」
导出文件约 2.4 KB / 账号(3 个账号 ≈ 7 KB)。同步完成后建议把文件删掉。
自动轮换
默认开启。开关在卡片底部:「额度用尽时自动切号」。
关闭后所有额度错误都会直接报给用户,不自动换号。
手动解除冷却
账号还在冷却但确认额度已恢复时,点这条冷却标记后面紧跟着的「解除」。
每条冷却都是一个「标记 + 解除」的小组,按钮只清它左边那条标记,两者按不可拆分的行内组渲染,换行时也不会被拆开:
- 账号级——标记是「账号额度 冷却至 …」,悬停按钮显示的就是这条冷却的原因(没有记录原因时退回一句通用提示);点它清掉整个账号的冷却
- 模型级——标记是「模型名 冷却至 …」,只清这一个模型的冷却,同一账号上其它模型的记录原样保留;悬停提示会说「只有这个模型被限流,其他模型仍可用这个账号」
按钮上的字一律是「解除」,作用范围由它贴在哪条标记后面表达;读屏软件读出的名称把范围写全(「解除冷却:账号额度」/「解除该模型冷却:模型名」),鼠标悬停也能看到对应说明。这解决了一个实际问题:同一个账号同时挂着两个模型级冷却时,早期版本会在操作栏里并排渲染两个一模一样的「解除该模型冷却」,没人知道点哪个会清掉哪个模型。现在冷却按钮不再进操作栏——卡片底部的操作栏只保留「切换到此账号」「删除」「签到」。

上图是同一个账号同时挂着三项冷却(账号额度 + 两个模型级)时的前后对比。修改前(上):冷却标记挤在名称行,各自的解除按钮却排在下面的操作栏里,渲染成两个一模一样的「解除该模型冷却」,看不出点哪个清哪个模型。修改后(下):每条冷却标记后面紧跟属于它的「解除」,操作栏只剩「删除」和「签到」。(示意图用真实组件渲染,账号名和时间为固定样例。)
工作原理
切换机制
内置的模型提供者按「配置的 authFile → 环境变量 → 平台默认路径」的顺序解析凭据,且每次解析都会重新读取 process.env。
所以切换只需要两步:
process.env.WORKBUDDY_AUTH_FILE = '/path/to/archive.json'
插件的下一次凭据轮询(≤30 秒)就会用上新账号,无需重启、无需改配置文件。
存档格式
存档必须使用 desktop 格式({auth, account}),不是插件自留副本格式({version, credential}):
{
"auth": {
"accessToken": "...",
"refreshToken": "...",
"expiresAt": 1794728770,
"domain": "www.workbuddy.cn",
"refreshExpiresAt": 1794901570
},
"account": { "uid": "697af213-...", "nickname": "180****0331" }
}
原因:authFile 指向的文件由 parseWorkBuddyAuth 解析,它只认 desktop 格式。写入自留副本格式会让插件读到 undefined,把整个模型分组隐藏掉——这是静默失败,不报错。
account.nickname 是这里唯一一个非凭据字段:导入(以及后续补号码)时写入的打码手机号,列表用它当行标题。它只接受打码形态,其它值一律不写——所以这个字段里不会出现完整号码。
轮换钩子
DSH 的 agent/request-error 是一个 waterfall:
const action = await this.dispatch.waterfall('agent/request-error', {
turn, step, provider, failure, retryPolicy, signal,
}, () => Promise.resolve(undefined));
if (action?.kind !== 'retry') throw new LlmError(...); // 不重试就抛错
continue; // 重试
- 监听器最外层先跑(
prepend: true) - 不调用
next()会否决后续链路,所以dsh-llm-retry看不到这个错误,不会浪费重试次数 - 返回
{kind: 'retry'}让 agent 重跑这一步
插件正是利用这三点实现「无感切换」。
渠道校验兜底
WorkBuddy 网关会对请求文本做渠道指纹校验:命中就返回 400 / code 11128 /
msg: Illegal API invocation from an unapproved channel,界面上显示为「请求被拦截,请重新发送」。
官方客户端从不发这些文本,所以同一个账号在 App 里是好的。
这条规则换过三次,所以兜底做法也换了三次:
| 日期 | 实测的触发条件 | 当时的解法 |
|---|---|---|
| 2026-10-22 | system 里出现写实的 DeepSeek Harness,一处就够 |
把每一处换成 DeepSeek\u200b Harness |
| 2026-10-28 | 触发变成提示词里另一段更长的精确子串(~110 字符,跨句子) | 每 ~24 字符插一个零宽空格,打断所有长串 |
| 2026-10-29 | 零宽空格完全失效:同一段文本原样 400、每隔 24 字符加零宽空格 400、每隔 8 字符也 400,只改一个可见的词就 200——网关开始在匹配前抹掉不可见字符 |
见下 |
2026-10-29 那天还测出了网关到底读哪一部分:
| 同一段触发文本放在哪里 | 结果 |
|---|---|
system 消息 |
400 |
assistant 轮次 |
400 |
user 消息 |
200 |
108 个工具声明(tools[].function.description)里 |
200 |
所以现在不再猜指纹、也不再往提示词里掺东西,而是把指令文本挪出网关会读的角色:
- 8 KB 系统提示词一字不改地装进第一条
user消息,前面加一句「以下是指令,对整个会话有效」的帧头; system消息只留一句中性说明:「遵循第一条 user 消息里的操作指令」;- 用户消息、工具声明、
assistant历史、以及请求的其它字段全部原样保留。
这样指令仍然是完整、逐字节的原文(字面量、代码样例、格式都不受影响),模型也照旧遵循——实测问它「读文件必须用哪个工具、禁止用哪个 shell 命令」,它答出「用 read 工具、避免 cat」,而这句只存在于被挪走的那段提示词里。
第二道防线留给「哪天网关开始读 user 消息」:真的被 400 拦下时,请求会重试一次,这一次把仍然留在被检查角色里的文本按可见分隔符(·)打断——注意是可见字符,不是零宽,因为零宽已经被证明会被抹掉。重试会改动文本,所以它永远只是重试,不会用在第一次请求上。
作用域仍然收窄:只处理 URL 含 /v2/chat/completions 的 POST;其它供应商、非对话请求、以及本来就没有指令文本的请求体按字节原样转发。
验证方式
本 README 里凡是写着「实测」的结论,都来自可以重跑的脚本,而不是推理:
| 结论 | 怎么得到的 |
|---|---|
| 400 是请求文本内容触发的 | 只改 system 内容、其余字节不变,逐个变体发最小请求对比状态码 |
| 2026-10-28 的触发是精确子串 | 二分到 111 字符边界;边界内改任意一个字符→200,前后追加整句→仍 400;连发 5 次结果稳定 |
| 2026-10-29 零宽空格失效 | 同一段文本:原样 400、每 24 字符加零宽 400、每 8 字符加零宽 400、改一个可见词 200 |
| 网关只读 system / assistant,不读 user 和 tools | 同一段触发文本分别放到四种位置,只有前两者 400 |
| 迁移后生产请求能过 | 真实提示词 + 108 个工具 + 多轮历史:原样 400,迁移后 200,模型仍正确遵循指令 |
rc.2 与 rc.3 只差版本号 |
npm pack 拉两个版本,解包后递归逐文件比对字节;六个包合计仅 package.json 有差异 |
最后一条的具体做法:把两个版本的包都解到本地目录,遍历所有文件(跳过 node_modules)比较内容,
并对差异文件再做一次行级 diff —— 结果只有 package.json 变化,且变化仅限 version 与它自己内部的
@deepseek-ai/* 依赖版本号。
冷却时间解析
上游消息形如:
您的使用量已超出频率限制,将在 2026-10-19 09:56:23 UTC+8 重置
正则提取时间后按 UTC+8 解释(上游固定用这个时区),避免在其它时区的机器上多冷却几小时:
Date.UTC(y, m - 1, d, h, min, s) - 8 * 3600 * 1000
数据文件
| 路径 | 内容 |
|---|---|
~/.dsh/workbuddy-accounts/<uid>.json |
账号凭据存档(明文) |
~/.dsh/workbuddy-accounts/<uid>.json.bak |
签到刷新令牌前的自动备份(失败时手工回滚用) |
~/.dsh/workbuddy-switcher.json |
当前选择 + 冷却表 + 签到记录 |
冷却表里一个账号有两种写法,取决于限制范围:```json { "cooldowns": { "alice.json": { "untilMs": 1792374983000, "reason": "…", "strikes": 1 }, "bob.json": { "models": { "claude-sonnet-4-5": { "untilMs": 1792374983000, "reason": "…" } } } } }
- `alice` 是**账号级**冷却(积分用尽),所有模型都不可用
- `bob` 只是**某个模型**被限流,其它模型照常参与轮换
旧的平铺格式仍然兼容:没有 `models` 字段就按账号级冷却处理。
选择和它的保护期在同一文件的 `selected` 里:
```json
{
"selected": {
"file": "128a8f9c-….json",
"source": "user",
"selectedAt": 1792327652403,
"holdUntilMs": 1792414052403
}
}
source——user(界面点的)/auto(轮换或回收切的)holdUntilMs—— 只有user才有,到期前被动回收不生效
删掉 holdUntilMs 或改小它,可以让某个手动选择立刻重新参与回收。
签到记录也在这个文件里,按存档文件名索引:
{
"checkin": {
"enabled": true,
"lastSweepAt": 1792329408764,
"accounts": {
"128a8f9c-….json": {
"result": "already",
"day": "2026-10-18",
"credit": 100,
"streakDays": 12,
"totalCredits": 1200,
"endTime": "2026-10-31 23:59:59",
"attempts": 1,
"atMs": 1792329408070,
"nextAttemptMs": 1792333008070
}
}
}
}
day是服务端返回的日期,不是本机的——改系统时间不会让它误判result是claimed/already/inactive/region/pending/auth/network/unknownnextAttemptMs只有network/unknown才有,到期前这一轮不再重试- 删掉整个
checkin.accounts等于「重置今天的签到记忆」,下一轮会重新查一遍状态
安全提示
存档里是明文的 accessToken / refreshToken。
- 不要提交到 git
- 不要放到共享目录
- 不要只备份到不加密的位置
签到会把 accessToken 发给
www.codebuddy.cn和copilot.tencent.com——和插件原有的余额读取是同一个域,没有新增第三方。
关于「凭证泄露后能否强制下线」:不能。WorkBuddy 服务端只提供账号级吊销(改密码),没有按会话/设备踢人的接口。已实测:客户端退出登录不会吊销已签发的 token,
sid不变,token 可无限滑动续期。所以存档文件的保管就是唯一防线。
配置
| 环境变量 | 作用 |
|---|---|
WORKBUDDY_SWITCHER_DIR |
覆盖账号库目录 |
WORKBUDDY_ELECTRON_BIN |
指定 WorkBuddy 可执行文件(解密凭据时需要) |
排障
切换后模型列表没变
模型提供者有 30 秒的凭据轮询周期。插件切换后会主动触发刷新,但如果失败,等 30 秒即可。也可以点「刷新模型」强制重取。
模型分组消失了
几乎一定是存档格式不对。检查文件顶层键名:应该是 auth / account。如果是 version / credential,说明是旧格式,重启 DSH 会自动迁移。
账号行显示成一串 uid,而不是手机号
那是本次改动之前导入的旧存档:它落盘时还没记号码,插件手上没有能替换的东西,就先退回 uid 片段(8bc5fd09…b058)。
不需要手工改文件,也不存在迁移脚本:WorkBuddy 只暴露当前登录账号的手机号,所以只要在客户端重新登录这个号一次,下一轮扫描(≤20 秒)就会把号码补进存档的 account.nickname,行标题随即变成 180****0331。补号码只写这一个字段,不会覆盖 token——插件自己刷新过的凭据比客户端那份新,不能被回滚。
在客户端登录了新账号,列表里没出现
正常情况下它会自己出现:Host 每 20 秒扫一次,最迟半分钟内导入并存档,界面上同时会有「客户端已登录新账号,尚未导入」的提示。
如果一直没有:
- 等过一次扫描(≤20 秒)再看。面板每 10 秒刷新一次,正常情况下列表里会多出这一行
- 仍然没有,说明插件读不到客户端——WorkBuddy 没装、身份文件不可读,或者机器上没有可用的解密路径。这时点「导入当前账号」手工导一次
- 扫描期间的读取失败只会写日志并重试,不会弹错误:WorkBuddy 自己轮换令牌的一瞬间身份文件是短暂不可读的,那不是需要你处理的问题
DSH 升级后提示「与当前 DSH 不兼容」
0.3.0 起不会再有这个问题:模型提供者已内置在本插件,peer 与 engines.dsh 都写成了范围
(>=0.2.1-rc.2 <0.3.0-0),同一条兼容闸门对未来 rc 版本直接放行。
0.2.x 时代之所以会被拦,是因为提供者当时是独立包,把 6 个 DSH 运行包精确锁死在发版时的版本,
DSH 一升级就被拦在组合阶段,WorkBuddy 模型整体消失。而逐包对比 0.2.1-rc.2 与 0.2.1-rc.3
可以看到,六个包只有 package.json 里的版本号不同,代码与类型声明逐字节一致
(见 验证方式)——是声明问题,不是真的不兼容。
按 从 0.2.x 升级 升到 0.3.0 即可。如果你暂时要留在 0.2.x,可以用 DSH 官方的 精确版本豁免放行,代价是DSH 每升级一次都要重新授权:
dsh plugin --profile desktop allow-version "<包名>@<版本>" --dsh-version "$(dsh --version)" --accept-risk
dsh plugin --profile desktop version-exemptions # 确认
豁免写进 <profile>/compatibility.json(包名@版本 → 允许的运行版本),重启不丢、重装插件不丢;
撤销用 revoke-version。
上游报「请求被拦截,请重新发送」(400 / code 11128)
这是网关的渠道指纹校验,与账号无关,换号不会好。插件在传输层处理它(见
渠道校验兜底),需要完全退出 DSH 再打开才生效——fetch 包裹和重试钩子只在启动时安装。
这条规则已经变过三次(10-22 是一个词、10-28 是一段长精确子串、10-29 开始抹掉不可见字符), 所以现在的做法不依赖猜指纹:把指令文本挪出网关会读的角色,重试时再用可见分隔符兜底。 重启后仍报同样的错,按这个顺序查:
- 设置页里确认插件版本是
0.3.6或更高(0.2.5–0.3.5 都是挡旧规则的写法) - 确认只装了一份:profile 顶层
node_modules/dsh-workbuddy-switcher里应有lib/channel-guard.js - 看设置页的
channelGuard状态:unrecovered增长说明网关又变了,lastDetail会写明是哪一步被拒 - 如果确实又变了规则,把报错和当时的系统提示词一起提 issue——判定方法见 验证方式,同样的二分法能定位到新的触发条件
自动轮换没生效
- 确认已重启 DSH(Host 侧代码只在启动时加载)
- 确认卡片里「额度用尽时自动切号」是勾选状态
- 确认当前是「已选择某个账号」而不是「跟随客户端」——跟随客户端时不轮换,因为 App 的凭据不在账号库里
卡在冷却但额度已恢复
点这条冷却标记右边紧挨着的「解除」。标记和按钮是同一个不可拆分的行内组,换行也不会把它们分到两行去。
如果标记写的是模型名(例如「claude-sonnet-4-5 冷却至 …」),这个「解除」只清掉该模型的限制, 不影响同一账号上其它模型的冷却记录;标记写的是「账号额度 冷却至 …」时,清的才是整个账号的冷却。
手动切换后又自己跳回去了
24 小时内不该发生。如果发生了,说明保护期没生效:
- 看设置页顶部有没有「手动选定保留至 …」——没有就是没被识别成手动选择
- 看状态文件里
selected.source是不是user、holdUntilMs是否在未来 - 如果切换是限流轮换触发的,那是预期行为(保护期不挡限流)
想把某个账号立刻交还给自动调度
把状态文件里 selected.holdUntilMs 删掉,或改成一个过去的时间。
想确认某个账号是不是整个都不能用了
设置页里看标记:
- 「账号额度 冷却至 …」 → 整个账号被限(积分用尽),换号才有用
- 「模型名 冷却至 …」 → 只有这个模型被限,同账号换用别的模型仍然可以
标记变淡表示这条冷却不影响当前路由的模型。
标记后面那个「解除」只处理它自己这一条:账号级标记后面清账号,模型级标记后面清那个模型。 两种标记同时存在时,卡片上就是两个小组,各点各的——这正是把按钮从操作栏挪到标记旁边的理由。
签到没领到
先看标签:
| 标签 | 处理 |
|---|---|
今日已签 |
正常,明天再来 |
签到失败 · 令牌失效 |
该账号需要重新登录一次(客户端登录后「导入当前账号」) |
签到失败 · 网络异常 |
等 10 分钟下一轮自动重试;也可以点「签到」立刻重试 |
签到季未开启 |
活动结束或还没开始,标签上有结束日期 |
| 什么都没显示 | 还没扫过。点一次「全部签到」即可,checkin.lastSweepAt 会更新 |
如果整轮都没动静,确认已重启 DSH(Host 侧定时器只在启动时注册)。
连点了很多下,会重复领或卡住吗
不会。浏览器和 Host 两端都会把连点合并成一轮(见「手动签到」)。积分按天去重, 重复领取只会返回「今天已签到」。
v0.2.0 及更早版本有一个真实缺陷:状态文件的所有写入共用同一个固定临时文件名 (
workbuddy-switcher.json.tmp),并发写时后一个rename会因ENOENT失败, 表现为点击报 500 甚至丢状态。v0.2.1 起所有写入走同一把串行锁、每次写用唯一临时名: 旧写法 50 个并发写实测 46 次失败、只剩 1 个键;新写法同样的 18 个并发请求全部成功。
签到把账号弄掉线了吗
不会。签到只做三件事:查状态、领取、以及在收到 401 时刷新一次令牌。
刷新会用新的 refreshToken 覆盖旧值,所以写回存档是关键一步;插件写回前会先比对
文件里的 accessToken 是否还是它刷新时用的那个,被别人改过就放弃写回。
写回前还会留一份 <uid>.json.bak,真出问题时可以把 .bak 改名回去。
已知限制
- 只在归档账号之间轮换。如果你选择「跟随客户端」,插件不会自动切走,因为那等于擅自改账号。
- 无法判断账号剩余额度。
429只说明「此刻被限」,不告诉你还剩多少。轮换排序按刷新令牌寿命,不按余额。 - 限流范围靠推断。上游对「某模型限流」和「积分用尽」可能回同一个 429, 插件按「消息里有没有积分措辞」区分:有 → 账号级,没有 → 模型级。 推断不出模型名时会退回账号级(保守)。
- 手动选择会放弃一点优化。保护期 24 小时内不会切回刷新令牌更长寿的账号, 这是有意的:你的选择优先于这个优化。
- 存档是明文。见上方安全提示。
- 签到是私有接口。它是从桌面端逆向出来的,腾讯改版就可能失效;失效时表现为
签到失败 · 异常,不影响其它功能。插件只领签到本身(100 积分/天),不碰 成长中心那些(旅行礼物、盲盒、连登兑换)。 - 签到的日期以服务端为准。本机时区不影响判断,但也意味着跨时区旅行时「今天」 跟的是 UTC+8 的日历。
许可
MIT。见 LICENSE。
相关项目
- DeepSeek Harness — 本插件运行的内核
第三方代码
模型提供者部分(lib/bridge/)来自 corrinehu 的 dsh-workbuddy-connect,MIT 授权,原样内置。
授权与改动说明见 THIRD_PARTY_NOTICES.txt。