← Back to home@chenzi666

dsh-account-switch

DSH 多账号插件:热切换、额度耗尽自动换号并原地重跑。官方新用户首登送 6 元额度,接码 0.2-0.3 元/号,账号池就是连续可用的额度池。

Stars
0
Language
JavaScript
Created
Oct 6, 2026
Updated
Oct 6, 2026

Introduction

dsh-account-switch

DeepSeek Harness 多账号插件 —— 一次登录多个账号,随时热切换,不重启、不重新登录。

这个插件解决的是最后一环。 DeepSeek Harness 官方活动:新用户首次登录送 6 元额度;接码平台一个手机号 0.2–0.3 元。账号多得起来、便宜得下来,但官方 UI 一次只认一个账号,退出还会注销 token —— 所以你需要一个东西把这些账号管住,让它们变成一个连续可用的额度池。那就是它。

切换后下一个模型请求立即用新账号。已登录的账号不会被顶掉——它们在凭证库里各自有一个存档槽位。

额度烧干时自动换到下一个还有钱的账号,并让失败的那一步原地重跑:会话不断、上下文不丢、工具状态不动。

切换前  default ──▶ 账号 A(已烧干)
切换后  default ──▶ 账号 B            ← 下一个请求直接生效,进程没重启过

功能

功能说明
多账号共存任意多个账号的 token 同时存在凭证库里,互不覆盖、互不顶号
热切换dsh-account use <账号> 立即生效,无需重启、无需重新登录
额度自动换号当前账号额度用尽时自动切到下一个可用账号,并让失败的那一步原地重跑
账号冷却刚被判没钱的账号进冷却(默认 5 分钟)不再被选中,避免来回横跳;充值后自动回到候选
切换留痕每次自动换号写一行 JSON 日志(切到谁、什么错、哪个会话的第几步)
指纹化存档账号 id 由 sha256(token) 前缀派生,同一 grant 永远映射同一槽位,归档天然幂等
昵称管理给账号起中文名,use 主号 比 use acct-af3bded5 好记
CLI + 插件 APIdsh-account 命令行,另有 ctx.accountSwitch 供其它插件调用
零内核改动全部走公开 seam,不 patch 官方包,Harness 升级不会把它冲掉
不额外存秘密registry.json 只有指纹;list --json 不输出 token

先说清它做不到什么

切换是全局的。

Harness 的账号是一个进程级的单例服务:ctx.deepseekAccount(实现为 PlatformAccount)持有唯一的账号状态,deepseek-account 这个模型 provider 每次请求都向它要 token。官方把活跃账号硬编码在 deepseek-account-platform/default 这一个槽位上,没有任何按会话区分的维度。

所以:

  • ✅ 多个账号的 token 可以共存,切换是瞬时的、热生效的
  • ❌ 不能让 A 会话用账号 1、B 会话用账号 2 同时跑——切了就是全切
  • ❌ 额度用尽时的自动换号切的是同一个全局槽位。A 会话先撞上额度打满而换号,B 会话的下一个请求也会跟着用新账号。这是全局单例的语义,不是本插件的取舍

要「同时并行」只能开第二个 Harness 实例,让它用另一个 DSH_HOME(于是另一份 .credentials.yaml)。但 Desktop 的 Electron 单实例锁是否会放行第二份实例,需要实测,--user-data-dir 未必够。本插件不尝试绕过这一层。

如果哪天需要真正的按会话绑定,那要动的是内核里 resolveToken() 的取用维度——那是另一件事,不是插件能做的。


为什么走「注册新用户」这条路

因为 DeepSeek Harness 官方活动:新用户首次登录送 6 元额度。

这不是钻空子,是官方自己掏的拉新预算。一个账号就是一个 6 元额度的容器,而拿到它只需要一个能收验证码的手机号。

于是整条路的成本结构简单到离谱:

项目金额
接码平台收一条验证码(一个手机号)0.2 – 0.3 元
新账号首次登录,官方赠送6 元额度
净得≈ 5.7 元额度
投入产出比约 20 – 30 倍

注册 10 个号:成本约 2.5 元,到手约 57 元额度。 注册 30 个号:成本约 7.5 元,到手约 171 元额度。

但账号越多,官方 UI 越不够用。

它一次只认一个账号,已登录状态下只给一个「退出登录」按钮——而退出还会向平台注销 token,那个账号就废了。想切回去?重新走一遍登录。

所以最后一环必须有人补上:

  1. 接码平台解决「账号从哪来」——0.2–0.3 元一个,成本可以忽略
  2. dsh-account-switch解决「账号怎么用」——一次登录全部存档,随时热切换,额度烧干自动换下一个并原地重跑

两者接起来,官方送的那 6 元额度才真正变成你可以连续使用的算力:额度用尽的那一刻,会话不会断,插件自己就换号了。

📱 接码:好助码 · 在线接码平台

项目说明
单价0.2 – 0.3 元 / 条(按号段和项目浮动,下单页实时显示)
覆盖国内外主流号段,注册/登录类短信验证码
到货网页实时收码,不用等、不用抢
注册入口https://h5.haozhuma.com/reg.html?action=weichen666

完整流程:

  1. 打开 https://h5.haozhuma.com/reg.html?action=weichen666 注册并充值(几十块够用很久)
  2. 选号 → 拿到手机号 → 在 Harness 官方 UI 里用它登录新账号 → 6 元额度到账
  3. 插件自动把新账号归档入库(autoCaptureOnSignIn: true,无需手工操作)
  4. dsh-account park 撤下它腾出位置,回到第 2 步换下一个号;重复 N 次
  5. dsh-account list 看总账,dsh-account use <昵称> 随时切到任意一个

插件本身与接码平台无技术耦合——它只负责管账号,不负责造账号。手机号从哪来由你决定,这里给的是最省事的一条路。


它怎么做到的

全部走公开 seam,不改内核、不 patch 官方包,所以 Harness 升级不会把它冲掉。

三层事实:

  1. 凭证服务是通用的。 ctx.credentials 按 <scope>/<id> 寻址,id 段只要求匹配 /^[a-z][a-z0-9-]*$/。官方只用了 default 和 device,剩下的 id 段没人占——account-<指纹> 就是我们的地盘。

  2. 目录变了会自动广播。 dsh-credentials-local 带 fs watcher:.credentials.yaml 一变就 reconcileFromDisk(),对每条变化的记录发 credentials/record-updated。而 PlatformAccount 自己就监听这个事件。所以写文件 = 通知到位。

  3. token 没有跨请求缓存。 PlatformAccount.resolveToken() 每个请求都重读 default。所以覆盖那一行,下一个请求就是新账号。

于是切换的全部动作就是:把目标槽位的内容写回 default。

存档槽位

version: 1
records:
  client-connection/browser-session: { ... }          # 官方持有,本插件不碰
  deepseek-account-platform/device:  { ... }          # 官方持有,本插件不碰
  deepseek-account-platform/default:                  # ← 活跃账号,官方唯一的读写口
    kind: grant
    payload: { version: 1, token: <当前账号>, issuer: https://platform.deepseek.com }
  deepseek-account-platform/account-acct-af3bded5:    # ← 本插件的存档槽位
    kind: grant
    payload: { version: 1, token: <账号 A>, issuer: ... }
  deepseek-account-platform/account-acct-f6b06859:    # ← 又一个存档槽位
    kind: grant
    payload: { version: 1, token: <账号 B>, issuer: ... }
refs:
  DEEPSEEK_API_KEY: sk-...                            # 官方持有,本插件不碰

账号 id 直接由 sha256(token) 的前缀派生,所以同一个 grant 永远映射到同一个 id——归档天然幂等,不需要一张需要维护的映射表。

昵称等元数据存在 $DSH_HOME/accounts/registry.json,只放指纹,不放 token 明文。之所以不塞进 .credentials.yaml:官方解析器对文档做严格白名单校验,顶层只允许 version/refs/records、每条记录只允许 kind/payload,多一个字段就让整篇解析失败——那是启动级故障。


安装

1. 把包放进 profile 的 node_modules

这个 profile 的 node_modules 是「解压出来的真实目录」结构——.pnpm 里只有一个 lock.yaml,dsh-proxy-boot、dsh-web-search-ddg 全是这么放的。所以不要跑 pnpm / npm:它们会按依赖树重建整个目录,把那些不在 lock 里的包清掉。

用脚本复制成真实目录:

powershell -NoProfile -File scripts/sync-install.ps1

必须是真实目录,不能用目录联接。 这是实测结论,不是偏好:同一个包以 junction 形态放进 node_modules 时,Harness 根本不会加载它——连插件自己的 apply() 都不会被调用,而且不报任何错。把完全相同的文件复制成真实目录后立刻正常。当时靠三个对照探针(真实目录 / junction / 完整插件)一次重启锁死了这个结论:真实目录那个写出了留痕,另外两个什么都没写。原因在 dsh-app-boot 的 linkedProfileRoots 那一层,没有继续深挖。

代价是改了源码要重跑脚本。脚本会连同 node_modules/yaml(唯一的运行时依赖)一起复制——Harness 的解析层不会替插件装依赖。

卸载:Remove-Item -Recurse 掉那个目录,再从 patch 里删掉条目。

2. 在 profile 的 patch 里挂上插件行

$DSH_HOME/profiles/desktop/cordis.patch.yml 末尾追加:

- insert:
    - id: dsh-account-switch
      name: dsh-account-switch
      config:
        enabled: true
        autoArchiveOnStart: true
        autoCaptureOnSignIn: true
        verbose: false

多个 - insert: 块都会被处理(dsh-app-boot 里是 for (const patch of patches) patch.insert?.forEach(visit)),所以它和 profile 里原有的 proxy-boot / web-search-ddg 那个 insert 块互不干扰。

3. 重启 Harness

实测改完 patch 后插件没有自动挂载——patchReload: live 不覆盖新增的插件行。需要重启一次。

关于 @deepseek-ai/*

插件里 import '@deepseek-ai/cordis' 和 '@deepseek-ai/schemastery' 由 Harness 运行时提供,部署时插件的 node_modules 里不该有 @deepseek-ai 目录。

这里踩过一个坑:npm 7+ 默认会自动安装 peerDependencies,而 @deepseek-ai/cordis 在公共 registry 上有包,npm install 就在插件下塞进了第二份 cordis。那意味着插件的 Service 基类来自另一个模块实例,跟宿主 ctx 不是同一个——挂载时才炸,而且只在运行时暴露。所以本仓库带了 .npmrc(legacy-peer-deps=true),插件目录里只应存在 yaml。

命令行

node bin/dsh-account.js list

安装时动过的东西:

路径备份
$DSH_HOME/.credentials.yaml同目录 .pre-account-switch.bak
$DSH_HOME/profiles/desktop/cordis.patch.yml同目录 .pre-account-switch.bak
$DSH_HOME/profiles/desktop/package.json未改动(不需要改)
$DSH_HOME/profiles/desktop/node_modules/dsh-account-switch新增真实目录

用法

加第二个账号

官方 UI 在已登录状态下只会给「退出登录」,而退出会向平台注销 token(revokeAccount),把存档一起废掉。所以用 park:

dsh-account list                       # 看看现在有什么
dsh-account park --label 主号          # 撤下当前账号,官方 UI 回到未登录态
                                       # token 未注销,存档完好
… 在 UI 里登录第二个账号 …             # 插件自动把它归档进账号库
dsh-account park --label 小号          # 想再加就继续
dsh-account list                       # 两个都在

park 之后插件也会在 default 变化时自动捕获新账号,不需要手工 capture。

切换

dsh-account use 主号          # 按昵称切(昵称必须唯一)
dsh-account use acct-af3bded5 # 按 id 切

切完下一个模型请求就用新账号。不需要重启任何东西。

额度用尽自动换号

不用手动盯着。当前账号额度烧干时,插件会自动切到下一个还有钱的账号,并让失败的那一步原地重跑——会话不断,上下文不丢,工具状态不动。

[account-switch] 账号额度用尽,已切到 acct-f6b06859(小号),本步将自动重跑

怎么做到的:官方 dsh-agent-loop 在模型请求失败后广播 agent/request-error,把「这次要不要重来」交给插件链决定——dsh-llm-retry 的退避重试就挂在这个点上。我们挂同一处,听到额度错误就换号,然后回一个 { kind: 'retry' },agent-loop 于是 continue 重跑这一步;而 resolveToken() 每次请求都重读 default,新账号的 token 自然生效。全程只用公开 seam,不改内核、不 patch 官方包。

认两个错误码,都由官方定义:ACCOUNT_QUOTA(账号侧专用)和 QUOTA(通用码,status === 402 或响应文本命中 insufficient balance / quota exceeded 一类措辞)。

冷却:按账号记时间戳。某个账号刚被判没钱,就会在 quotaChainResetMs(默认 5 分钟)内不再被选中——这同时解决两件事:所有账号都烧干时不会来回横跳(试完一轮就如实报错),以及充值之后过了冷却它又会自动回到候选里。

留痕在 $DSH_HOME/accounts/quota-switch.log,一行一条 JSON(切到谁、什么错、哪个会话的第几步)。换号是你看不见的副作用,不留痕的话事后查「账号怎么自己变了」会毫无头绪。

配置(cordis.patch.yml 的 dsh-account-switch 行里):

- id: dsh-account-switch
  config:
    autoSwitchOnQuota: true     # 关掉它就回到「额度烧干即中断」的官方行为
    quotaChainResetMs: 300000   # 单个账号的冷却时长

添加账号:弹窗,而不是一点就跳浏览器

点击「添加账号」弹出一个对话框,三个按钮:复制链接 / 打开浏览器 / 取消。

链接只能由宿主向平台申请(PKCE 挑战、state、本地回调地址都在宿主侧),没有第二条路凭空造一条链接,所以两个按钮都会调同一个 startSignIn;区别只在拿到链接之后——一个进剪贴板,一个显式 window.open 交给系统浏览器。

但 Desktop 外壳有一段常驻 watch,授权链接一出现就直接 shell.openExternal(app/lib/main.js 里对账号状态的订阅:无条件、无开关、随进程存活)。那段代码不在插件里,插件层拦不住它。所以这套交互要真正成立,得给外壳加一个 21 字节的补丁:

- if (attempt?.phase === "waiting-browser" && attempt.authorizeUrl !== void 0 && openedAttempt !== attempt.id) {
+ if (attempt?.phase === "waiting-browser" && attempt.authorizeUrl !== void 0 && openedAttempt !== attempt.id && !enteredWorkspace) {

enteredWorkspace 是同作用域里已有的变量(进工作区时置 true,退回 welcome 时复位),所以这个补丁不引入任何新状态、不需要新 import;而且首次引导的体验原样保留——还没进工作区时,登录依然会自动打开浏览器。

装法(必须先关掉 Harness:归档被运行中的进程独占,替换会报「另一个进程正在使用此文件」):

powershell -NoProfile -File scripts/swap-desktop-asar.ps1
powershell -NoProfile -File scripts/swap-desktop-asar.ps1 -Revert   # 回滚

脚本会校验 SHA256、保留 app.asar.pre-autoswitch.bak、失败自动回滚。归档本身只长了 24 字节(21 字节补丁 + 对齐),其余 11470 个条目逐字节未动。

官方更新会换掉 asar,补丁随之失效——重打一次即可。

其它

dsh-account status                       # 状态与存储位置
dsh-account rename acct-af3bded5 工作号  # 改昵称
dsh-account remove 工作号                # 移除(活跃账号需要 --force)
dsh-account identify                     # 补全平台身份并合并重复记录
dsh-account list --json                  # 给脚本用

插件内使用

宿主侧暴露了 ctx.accountSwitch,任何插件都能调:

await ctx.accountSwitch.overview()        // { accounts, activeId, warnings, ... }
await ctx.accountSwitch.use('主号')       // 切换
await ctx.accountSwitch.park()            // 撤下当前
await ctx.accountSwitch.capture()         // 归档当前

验证

node --test                    # 36 项单元测试,全部在隔离 tmp 目录里跑
node scripts/drill.mjs <隔离 home> --i-know-this-writes-files

scripts/drill.mjs 是拿一份真格式的凭证库副本跑完整流程(归档 → 幂等 → 模拟登录第二个 → 切换 → 校验官方持有的部分逐字未动)。它默认拒绝在 $DSH_HOME 上运行——这类工具最容易犯的错就是把真人正在用的凭证库当演练场。

另有一项只在装配时做的验证:用内核自己那份 cordis / schemastery 加载插件,并让内核的 schemastery 实例去解析插件声明的 Config。这能一次性证伪「插件自带第二份 cordis、Service 基类跟宿主不是同一个」这类只在运行时才暴露的问题——.npmrc 里的 legacy-peer-deps=true 就是为了防它复发。


踩过的坑:目录联接会让包整个不被加载

一度把插件以 junction(New-Item -ItemType Junction)挂进 profile 的 node_modules,图的是改代码立即生效、不用同步。结果是:Harness 完全不加载它,连插件自己的 apply() 都不会被调用,全程没有任何报错——从外部看和「插件根本没装」一模一样。

定位靠三个对照探针,一次重启:

探针形态结果
probe-min-dir真实目录写出了留痕
probe-min-link目录联接什么都没写
完整插件目录联接什么都没写

三个包除了形态之外没有别的差别。改成真实目录后立刻正常。

先前读代码时判断 junction 应该没问题(canonicalPath 走 realpathSync、linkedProfileRoots 会扫符号链接),是实验推翻了它。所以现在一律用 scripts/sync-install.ps1 复制真实目录,改完代码重跑一次。


踩过的坑:export default 会让插件静默失效

第一版装上去之后什么都没发生——没有产物、没有报错、凭证库纹丝不动。

原因在 cordis-plugin-loader 的 unwrapExports():

unwrapExports(exports) {
    if (isNullable(exports)) return exports;
    exports = exports.default ?? exports;   // ← default 优先于 apply
    if (!exports.__esModule) return exports;
    return exports.default ?? exports;
}

当时插件同时导出了 apply 和 export default AccountSwitch。加载器取走了那个类并把它当插件实例化:super(ctx, 'accountSwitch') 照常注册服务、不报任何错,而 apply() 从头到尾没被调用。对照 dsh-proxy-boot——它只有 name + apply,所以一切正常。

所以这个插件不导出 default,也不导出静态 inject(静态 inject 会让插件在依赖不就绪时静默挂起,连「我到底有没有被加载」都无从判断)。credentials 的等待改成 apply 里显式 ctx.inject()。

为了下次不用靠推理:apply() 的第一件事是写 $DSH_HOME/accounts/load-probe.json。它存在就等于 apply 真的被 cordis 调用了;不存在就说明插件压根没进加载列表——这两种失败在外部表现上一模一样,留痕是唯一能一刀切开它们的东西。


已知边界

情况结果
官方 UI 点「退出登录」default 被删,槽位不受影响;但那次 revoke 会让该账号的 token 在服务端失效,切回去会 401
token 过期或被平台拒绝官方 rejectToken() 只清 default,槽位里的存档仍在(内容已失效,需要重新登录)
切换时目标槽位为空拒绝切换并保持原状——清掉 default 等于当场把登录弄丢
default 里是坏记录拒绝归档,只告警;坏记录交给官方处理,插件不替它做决定
有人在 Harness 之外改了凭证库下次 list 会点名报出「槽位存在但注册表没有对应条目」
并发写CLI 与 Harness 抢的是同一把锁(<file>.lock、wx 创建、内容为 pid,与官方 @deepseek-ai/dsh-atomic-write 约定一致)
账号库里只有一个账号自动换号无事可做,额度错误照常报出——它不会凭空造一个账号出来
所有账号都烧干一轮试完即收手,把原始错误如实交给官方;冷却期内不会重复切换,不会来回横跳
充值之后过了 quotaChainResetMs 冷却,那个账号自动回到候选里,不需要重启也不需要手工清状态

安全

.credentials.yaml 里是 token 明文,跟官方行为一致(官方就是这么存的),所以保护它的手段就是文件权限。

装之前值得知道的一件事:这个文件的 ACL 里可能有 CodexSandboxUsers: ReadAndExecute。也就是说跑在 Harness 沙箱里的任何进程都能直接读走里面的 token 和 API key。要收紧就把那条继承来的 ACE 摘掉。

本插件自己不额外复制秘密:registry.json 只有指纹,list --json 也不输出 token。

本仓库的忽略规则:.credentials.yaml、accounts/、registry.json、quota-switch.log、key_pool.json、*.token 等一律在 .gitignore 里。克隆下来放到你自己的机器上,账号池是你自己的,不会跟着仓库走。


License

MIT