🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

训练仪表盘 moazzamak/dsh-train-dashboard

DeepSeek Harness
moazzamak25 天前有提交已提供中文文档扫描:未发现敏感能力

运行你的命令读取训练快照,逐条绘制指标曲线

29.9/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/7 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add moazzamak/dsh-train-dashboard
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · ui
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 25 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🟢 扫描:未发现敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描未发现敏感能力:最多只发网络请求、读取自身元数据或只读文件,未发现执行命令、文件写入或敏感信息读取。

发起外部网络请求
查看扫描证据(共 3 处,展示前 3 条)
  • 发起网络请求
    client.cjs:4117
    const response = await fetch(`${ROUTE}/state`)
  • 发起网络请求
    client.cjs:4124
    const body = await fetch(`${ROUTE}/series`)
  • 发起网络请求
    client.cjs:4154
    await fetch(`${ROUTE}/state`)
扫描于 2026/11/1(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 运行命令读取训练快照
  • 逐条绘制指标折线
  • 显示数据新鲜度

DeepSeek Harness 的右侧栏训练仪表盘:跑你给的一条命令,让它写出训练运行的 JSON 快照,再逐条折线画出来,数字的新鲜度始终显示在屏幕上。

README

由 DeepSeek 最新模型翻译生成
dsh-train-dashboard DeepSeek Harness(DSH)的训练仪表盘。它在右侧面板加一个 Training dashboard 标签页,和 Files、Terminal、Document 并排,把一次训练运行的 JSON 快照画成图:KPI 头部、一次只画一条序列并配自己的坐标轴、把让序列之间无法比较的接缝标在图上并写明各自原因、在曲线前方标出下一次预期切换。 插件不解析你的日志。它运行你自己的一条命令,那条命令写出下面描述的 snapshot JSON,标签页读它。所以只要你能把数字整理成这个形状,插件就能配合任何训练环境使用,也永远不会和你的解析器争夺「一次运行到底是什么样」的解释权。 - 没有构建步骤,没有运行时依赖,两个文件,纯 JavaScript - 除非标签页打开、且快照比 refreshSeconds 更旧,否则完全没有进程 - 自己从不写任何东西:快照由你的命令写出,也是插件唯一会导致被写出的文件 - 数字的年龄始终显示在屏幕上,所以过期的图永远不会被误当成实时的 - 断点标记显示的每一个字都来自你的快照本身:标签页只决定标记放在哪、怎么读,从不决定它说什么 包版本 0.3.0 —— 快照契约版本 1。
展开完整文档(README 剩余 1 节)
dsh-train-dashboard

DeepSeek Harness(DSH)的训练仪表盘。它在右侧面板加一个 Training dashboard 标签页,和 Files、Terminal、Document 并排,把一次训练运行的 JSON 快照画成图:KPI 头部、一次只画一条序列并配自己的坐标轴、把让序列之间无法比较的接缝标在图上并写明各自原因、在曲线前方标出下一次预期切换。

插件不解析你的日志。它运行你自己的一条命令,那条命令写出下面描述的 snapshot JSON,标签页读它。所以只要你能把数字整理成这个形状,插件就能配合任何训练环境使用,也永远不会和你的解析器争夺「一次运行到底是什么样」的解释权。

- 没有构建步骤,没有运行时依赖,两个文件,纯 JavaScript
- 除非标签页打开、且快照比 refreshSeconds 更旧,否则完全没有进程
- 自己从不写任何东西:快照由你的命令写出,也是插件唯一会导致被写出的文件
- 数字的年龄始终显示在屏幕上,所以过期的图永远不会被误当成实时的
- 断点标记显示的每一个字都来自你的快照本身:标签页只决定标记放在哪、怎么读,从不决定它说什么

包版本 0.3.0 —— 快照契约版本 1。

你会看到什么

| 区域 | 内容 |
| header | 运行名称、快照年龄加 fresh/stale 徽标、Refresh 按钮 |
| KPI row | 最新 step、留出集读数、step 墙钟时间、吞吐、内存峰值、评测准确率 |
| chips | 这个标签页主打的四个指标,然后是 N more series 折叠区,装着快照里其余全部内容 |
| chart | 一次只画一条序列并配自己的坐标轴,所有断点标记都画在上面,时间线可选 |
| register | 图表下方:每个断点标记的完整信息——属于哪一段、什么类型、原因、这种类型对曲线做了什么、从哪里读到的 |
| frontier | register 下方:下一次预期切换,写成预期而不是事实 |
| footer | 正在读取的快照路径 |

chips 故意做成短列表。 这次运行的快照带六十条序列,每条都给一个 chip,就是在只画一条曲线的图上堆一墙控件。标签页主打它受其牵引的四个——该段自己的段末读数、吞吐、VRAM 峰值和考试——另外三十六条点一下折叠区就到。什么都没丢:生产者自己的 tag 依然可达,用它的本名标注。

一条曲线,不是两条。 train/bpb_sealed 和 train/bpb_legval 是同一张图。在这次运行的快照上实测:曲线在 leg 1–689 上带 688 个读数,段末读数在 1–731 上带 730 个,两者共享的全部 688 个 leg 上数值完全相同。曲线停在阶段边界,也就是语料切换发生的地方,段末读数则继续再走 42 个 leg。
两者都由生产者写入,所以标签页会消解这一对:凡是存在 leg-end 读法的地方,截断副本根本不会提供;而生产者只写入曲线的地方,曲线就是留出的读法。KPI 表头会写明它读的是两者中的哪一个。

一次只画一条序列是刻意为之。 序列各自携带不同的单位——bits per byte、秒、GiB、准确率、计数——把它们画在同一根轴上,只会招来这根轴根本无法支撑的比较。

断点、种类与前沿

一段长运行会跨过一些并非学习造成的边界,跨过其中某一条时的上升并不代表模型变差。快照可以携带这些边界,标签页把每一条画成一条竖线,竖线带有一个形状和它的 leg 编号:形状说明这是哪一种接缝,编号说明下方寄存器里哪一行读出它,而文字留在寄存器里——那里有空间容纳它们,原因、含义和读法本来也都住在那里。种类既是形状也是颜色,因为只靠色相编码的种类,对分不清色相的读者来说就是隐形的,而这些种类对曲线做的事各不相同:

| 种类 | 形状 | 在图上意味着什么 |
| corpus | 圆形 | 数据变了,所以这里的上升是预期之中的,不是回归 |
| instrument | 方形 | 读法变了,所以这个接缝是用不同口径测出的水平位移:跨过它没有斜率 |
| arithmetic | 三角形 | 数字变了,所以序列跨过这个接缝根本不可比 |
| shape | 菱形 | leg 的几何变了而它的量保持不变,所以指标仍然可比,墙钟时间会移动 |
| restart | 三角形,尖端朝下 | 进程重启了,那个窗口的工作不在序列里 |
| break(由 break_leg 声明) | 条形 | 快照点名了某个 leg 却没有给种类,标签页不会替它猜 |
| 其他任何种类 | 加号 | 标签页从未见过的种类,画出来而不是藏起来 |

标签页从未见过的种类仍然会画出来,用加号形状,并在寄存器里写上生产者自己的名字;快照省略了种类的标记会得到 UNKNOWN 行,而不是被当成种类已知来展示。标签会互相碰撞的标记会被堆叠起来,避免任意两个互相覆盖,而且每个标记还会在图表下方的条带上得到一个暗色刻度,所以即使你缩放到别处,这些接缝依然可见。悬停某个标记会给出寄存器里携带的同一段文字,所以细节只差一个手势,而不必写在图上。

图表下方的寄存器是标记被完整读出的地方:原因、你的快照为那个种类携带的句子、标记读自的证据,以及快照附上的任何矛盾。落在屏幕窗口之外、或超出序列最后一个点的标记,仍然会出现在那里——图表只能画它的轴覆盖到的部分,而一个标记图中缺了一处标记,却只字未提,没人找得到它。

frontier 是下一个预期转变,单独画在一条泳道里,用虚线表示,标注为 next corpus change expected around leg N。它故意画得和上面的标记不一样:那些标记读自已经存在的产物,而这一条是预测。标签页在寄存器下方的区块里说明了这一点,也说明了为什么它的虚线可能还没出现在图上。

标记和 frontier 都来自你的快照,用你的话表述。 标签页不会自行判断预期会出现上升、某个接合点是水平位移、或者转变即将到来;它只画出你的生产者推导出的内容,并复述它的句子,这样图表和你自己的记录就不会对同一次运行讲出不同的故事。

选择时间线

图表打开时显示整个运行过程,坐标轴会随你选择的窗口重新缩放。这正是这个交互的意义所在:在一次长运行中,早期的一个尖峰可能把最后几千步——也就是你正在操控的那部分——压成一条直线。

| 手势 | 效果 |
| 在图表上拖动 | 缩放到该窗口 |
| 滚轮 | 以指针为中心缩放 |
| 拖动(或点击)图表下方的条带 | 在整个运行上选择;点击会把窗口移到那里,但不改变宽度 |
| + / − / Reset 按钮 | 放大、缩小、回到整个运行 |
| 图表获得焦点时按 + - 0 ← → | 同上,用键盘操作;Esc 也会重置 |
| 双击 | 回到整个运行 |

图表下方的条带始终以固定比例显示整个运行,并在上面画出当前窗口,这样你能看到自己在哪里,也能跳到任何地方。图表上方的读数用步数标出窗口——steps 120–480 · 360 of 1200 shown——图表的无障碍名称也带有同样的数字,因为一个只以拖动矩形形式存在的范围,是屏幕阅读器无法报告的范围。

有两种方式打开这个标签页:会话头部操作行里的图表图标(conversation.session.header.actions 座位,位于会话顶部随附的 jobs 和 subagent 控件旁边),以及窗格的「+」引导菜单。

它如何获取数据

your project plugin host half browser tab
──────────── ──────────────── ───────────
whatever your run writes
(metrics.jsonl, per-step logs,
evaluation reports, a database)
│
│ YOUR command, run by the plugin
│ at most once per refreshSeconds
▼
the snapshot JSON ────────────────▶ GET /dsh-train-dashboard/state ──▶ polled every 5 s
(the contract below) GET /dsh-train-dashboard/series ──▶ fetched only when
(runs nothing itself) the revision moves

两个读取方,一个解析器:你的。用 JavaScript 重新实现你的解析你可以自由地不同意它,然后标签页和你自己的记录会对同一次运行给出不同的说法,而屏幕上没有任何东西说明哪个是错的。

路由:

| 路由 | 用途 |
| GET /dsh-train-dashboard/state | 小 JSON:revision、age、staleness、是否有重建正在进行、这台主机到底能不能重建,以及它会运行的确切命令 |
| GET /dsh-train-dashboard/series | 整个快照正文,外加 ok: true;还没有任何内容时返回 503 |
| 两者 | 只接受 GET;其他任何方法都返回 405,并带 allow: GET |

/state 是标签页每 5 秒轮询的东西。/series 只在 revision 变化时才拉取,所以一个开着的标签页每几秒只花一次小文件读取,完全不占进程:只有快照比 refreshSeconds 更旧时才会 spawn,而且同时只能有一个 spawn 在进行。

主机这一半也能单独使用,不需要浏览器标签页,任何能向 harness 发 HTTP 请求的东西都可以。

快照契约

你的命令写一个 JSON 文件。这个文件就是你的项目与这个插件之间的全部接口。下面是一个完整示例:

{
"snapshot_version": 1,
"generated_at": "2026-01-31T09:15:00Z",
"revision": "1738314900",
"arm": "run-7",
"break_leg": 500,
"series": [
{ "tag": "train/loss", "source": "metrics.jsonl", "points": [[1, 3.204], [2, 3.011], [3, 2.876]] },
{ "tag": "train/wall_seconds", "source": "step logs", "points": [[1, 41.2], [2, 39.8], [3, 40.5]] },
{ "tag": "memory/vram_peak_gib", "source": "step logs", "points": [[1, 11.4], [2, 11.6], [3, 11.5]] }
],
"annotations": [],
"breaks": [
{
"leg": 380,
"kind": "corpus",
"label": "corpus_a to corpus_b",
"reason": "the corpus changed, so a rise in bits per byte here is expected.",
"support": "the legs' own log names",
"derived": true,
"contradiction": "",
"meaning": "the DATA changed, so a rise in bpb here is EXPECTED"
}
],
"frontier": {
"phase_now": "corpus_b",
"next_phase": "corpus_c",
"boundary_leg": 380,
"next_leg": 460,
"legs_in_phase": 80,
"legs_remaining": 61,
"expected_to_raise_bpb": false,
"imminent": false,
"sentence": "FRONTIER: corpus_b is being trained; the next corpus transition is corpus_b to corpus_c at about leg 460."
},
"counts": { "points": 9 },
"sources": "metrics.jsonl + step logs"
}

| 字段 | 是否必需 | 作用 |
| snapshot_version | 是 | 必须是数字 1。其他任何值都会被当作不可用拒绝,标签页会说明这一点;它不会提供一个帮不上忙的重建。 |
| generated_at | 强烈建议 | 数字被读取时的 ISO-8601 时间戳。驱动 age 徽章和过期横幅。没有它,标签页会把快照报告为过期,因为它无法知道 age。 |
| revision | 推荐 | 内容变化时跟着变的任意字符串:时间戳、文件 mtime、哈希。只有它变了,标签页才会重新拉取 series 主体。省略时,用 generated_at 当 revision。 |
| arm | 可选 | 本次运行的名称,在页头显示为 run <arm>。 |
| break_leg | 可选 | 要标记的一个 x 值,给那种只有一个关节、又没给它定 kind 的生产者用。画成虚线标记,标注 BREAK,标签页会在登记表里说明:它没有为这个关节声明任何 kind。 |
| breaks | 可选 | 标记数组,每一项都来自你自己的产物。见下文。 |
| frontier | 可选 | 下一个预期转变。见下文。 |
| series | 是(可以为空) | 要绘图的 series 数组。 |
| series[].tag | 是 | series 名称。带斜杠表示分组:train/loss、memory/vram_peak_gib。 |
| series[].points | 是 | [x, y] 数对数组,两个数都必须是有限数。x 是步数轴。不是两个有限数的数对会被丢掉;某个 series 被丢到什么都不剩,就干脆不提供。 |
| series[].source | 可选 | 短字符串,说明这些数字来自哪里;显示在 chip tooltip 和图例里。 |
| annotations | 可选 | 自由格式数组,原样转发给浏览器。 |
| counts | 可选 | 自由格式对象,例如 {"points": 4123};由 /state 回显。 |
| sources | 可选 | 字符串,说明生产者的输入;由 /state 以 snapshotSources 回显。 |

breaks[]:标记

| field | required | what it does |
| leg | 是 | 标记所在的 x 值。读不出 leg 的标记没法放到轴上:会计数并在图表下方报告,而不是悄悄丢掉。 |
| kind | 强烈推荐 | corpus、instrument、arithmetic、shape、restart 之一——也可以是别的词,那就照原样画出来、照原样命名。省略时,标记标注为 UNKNOWN。 |
| label | 推荐 | 几个词,说明改了什么(corpus_a to corpus_b、512 to 256 steps a leg)。画在图上,放不下就在词边界处截断。 |
| reason | 推荐 | 一句话,说明发生了什么、是否预期会跨过它。在登记表里完整显示。 |
| meaning | 推荐 | 这类变化对曲线有什么影响。在登记表里显示在 WHAT THIS <KIND> CHANGE DOES TO THE CURVE: 下面。 |
| support | 可选 | 标记是从哪里读出来的,用一句读者能核实的话。显示为 READ FROM:。 |
| derived | 可选 | false 表示这个 leg 是你代码里的常量,不是读出来的值;登记表会照此说明。不写就表示两种说法都不主张。 |
| contradiction | 可选 | 当你自己的证据与标记所指的变化矛盾时设置。会醒目显示,因为藏起来就等于让图表声称一个各 leg 并不支持的变化。 |

标记按从旧到新绘制。同一个 leg 上有多个标记没问题:它们的标签会堆叠,所以不会重叠。

frontier:下一个预期切换

| 字段 | 是否必填 | 作用 |
| phase_now | 推荐 | 当前正在训练的语料。 |
| next_phase | 推荐 | 预期接下来使用的语料。留空表示没有排期,标签页会照实说明,而不是写出某个阶段名。 |
| next_leg | 推荐 | 预期发生切换的 leg —— 由你自己的预算推算得出,所以标签页会读作 expected around leg N。 |
| boundary_leg | 可选 | 当前阶段的第一个 leg。配合 imminent: true 使用时,这就是标签页画出的 leg,因为已经没有什么可预测了。 |
| legs_in_phase、legs_remaining | 可选 | frontier 的上下文;当你的生产者写了 sentence 时,会显示在其中。 |
| expected_to_raise_bpb | 可选 | 切换时预期 bits per byte 上升,就填 true。标签页会在预期 leg 旁边说明这一点。 |
| imminent | 可选 | 下一个训练的 leg 已经是新语料,就填 true。措辞会从 expected around leg N 变成 changes AT LEG N。 |
| sentence | 推荐 | 你自己关于 frontier 的完整陈述。原样显示,上方带有标签页的说明:这是预期,不是实测。 |

标签页总会给 frontier 加上它自己的一条提醒 —— leg 是由你的预算推算出来的,上升是方向而非幅度 —— 并说明为什么超出序列末端的虚线不在图上。屏幕上其余内容都是你的文本。

标签页已经带标签和单位的 tag(图个方便,不是硬性要求 —— 其他 tag 照样会画进图里,用它们自己的 tag 作标签):

| tag | 标签 | 单位 |
| train/bpb_sealed | sealed bpb | bpb |
| train/bpb_legval | leg-val bpb | bpb |
| train/wall_seconds | leg wall time | s |
| train/units_per_second | units/s | u/s |
| train/gnorm | grad norm | — |
| train/vocabulary_size | vocab size | — |
| memory/vram_peak_gib | VRAM peak | GiB |
| memory/vram_headroom_gib | VRAM headroom | GiB |
| eval/exam_accuracy | exam accuracy | — |

形状像逐步轨迹的 tag(vram_trace/…、anything/trace/…、something_trace)完全不会进入 chip 列表:每一步一个 chip 会把你真正想看的那条序列埋掉。想让这类序列被提供出来,就给它换个不带这种形状的 tag。

编写生产者

任何语言、任何程序,只要能写出这个文件就行。两条规则能让它用起来舒服:

1. 原子写入(临时文件加 rename)。你写文件时标签页会轮询它;读到写了一半的内容也能处理 —— 标签页会保留上一次的好数据 —— 但别故意让它发生。
2. 让 revision 只在数字变化时变化。 文件 mtime、行数、哈希都行。如果它一直不变,标签页就永远不会重新拉取序列。

整个约定,用一个 Python 函数表示:

snapshot_writer.py — 把下面的读取逻辑换成你自己的。
import json, os, tempfile, time

def write_snapshot(path, leg, loss):
body = {
"snapshot_version": 1,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"revision": str(os.stat("metrics.jsonl").st_mtime), # changes when the data does
"arm": "run-7",
"series": [
{"tag": "train/loss", "source": "metrics.jsonl", "points": [[leg, loss]]},
],
}
directory = os.path.dirname(os.path.abspath(path)) or "."
handle, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
with os.fdopen(handle, "w", encoding="utf-8") as stream:
json.dump(body, stream)
os.replace(temporary, path) # atomic: a reader never sees half a file

然后让插件指向它:

command: [python3, snapshot_writer.py, --out, "{snapshot}"]

配置

插件只有一行组合项。它的 config 会被更高层的补丁整体替换,从不做深度合并,所以覆盖这一行的配置档必须把它关心的每个键都重新写一遍。

- id: train-dashboard
name: dsh-train-dashboard
config:
workspace: /absolute/path/to/your/project
command: [python3, -m, your_project.snapshot_tool, --out, "{snapshot}"]
snapshotPath: train-dashboard.snapshot.json
refreshSeconds: 30
timeoutSeconds: 120

| 键 | 默认值 | 含义 |
| workspace | '' — 必须设置 | 绝对路径。既是命令的工作目录,也是 snapshotPath 及任何相对路径的解析基准。宿主进程可能从配置档目录启动,在那里相对路径解析不出任何东西,所以要写清楚。 |
| command | [] — 必须设置 | 你的生产者的 argv,列表里每一项对应一个词。不是 shell 字符串:不经过 shell,所以引号和 && 都不起作用。 |
| snapshotPath | train-dashboard.snapshot.json | 你的命令写入、标签页读取的位置。相对 workspace,也可以写绝对路径。 |
| arm | '' | 可选标签,快照自身没有标明运行时,显示在页眉里。 |
| refreshSeconds | 30 | 两次重新生成之间的最小间隔,也是过期阈值的一半(快照超过这个值的两倍就报过期)。同一台机器上跑训练任务时,只能调大,不要调小。 |
| timeoutSeconds | 120 | 超过这个时间就放弃本次重新生成并上报,而不是让转圈一直转下去。 |
| 其他任意键 | — | 原样保留,可在 command 里当作 {placeholder} 使用。 |

command 里的占位符。 {snapshot} 会替换成 snapshotPath,{workspace} 会替换成 workspace,其他任何 {name} 会替换成同名的配置键。所以生产者自带参数时,把这些参数声明成键即可:

command: [my-tool, --logs, "{logsDir}", --run, "{arm}", --out, "{snapshot}"]
logsDir: logs/train
arm: run-7
未知占位符会原样保留,而不是被清空,所以拼写错误会出现在标签页打印的命令里,不会悄悄变成一个空参数。

插件对命令做了什么。 它把 cwd 设为 workspace 来运行命令,分别捕获 stdout 和 stderr 最多 256 KB(退出码非零时显示最后几行 stderr),并要求它在 timeoutSeconds 后停止,留一小段宽限期。成功与否由快照判断,而不是退出码:命令退出码为零但没写出可读快照,就不算回答了标签页的问题;写出了合格快照,就算。

安装

用插件管理器把 bundle 装进配置档:

dsh plugin --profile <profile> add github:moazzamak/dsh-train-dashboard

指向你实际启动的配置档,然后重启 harness。这一行会带着占位值出现,所以先设置 workspace 和 command,再指望看到图表;在那之前,标签页会提示尚未配置命令。

之后想看到标签页,预计要重新加载页面或重启 DSH。 随附的 dsh-client-hmr 包会把普通的插件启用/禁用变更通过 /plugins/events 送到已打开的页面,无需重新加载页面,但它也明说了自己的限制:「仅限 Web 传输——Electron 安装和后端重启处理不走这条 SSE 路径。」这里不需要 pnpm run dev:web:没有构建步骤,也就没有我们的 bundle 需要重建。

从本地检出安装

改为让配置档指向这个目录。在配置档目录(~/.dsh/profiles/<profile>)里:

1. 把这个包加为依赖,用 file: 说明符指向这个目录;
2. 在同一个 package.json 里把它的名字追加到 dsh.profile.bundles,这样这个包里的 bundle 补丁才会生效;
3. 运行 pnpm install --no-frozen-lockfile,并设置 CI=true:pnpm 在没有终端时拒绝删除 modules 目录,而在 CI 下它默认使用冻结的 lockfile,新依赖会让这个 lockfile 失效;
4. 重启 harness,确认启动审计没有列出待处理条目。

先备份 package.json。如果安装失败,就恢复它,否则配置档会声明一个并不存在的依赖,可能启动失败。

代价是什么

- 没打开标签页,没有代价。 不轮询任何东西,也不启动任何进程。
- 打开标签页,快照是新的。 每 5 秒读一次小 JSON,完全不启动进程。
- 打开标签页,快照过期。 同样的轮询,外加每 refreshSeconds 运行一次你的命令,单飞。插件本身只增加进程启动加一次文件读取的代价;其余取决于你的生产者做什么,所以让它便宜点,或者调大 refreshSeconds。

出问题的时候

快照会在标签页读取它的同时被写入,所以缺失、过期或写了一半的快照都是正常的,不是异常。这些状态都会显示它手上最新的数字及其真实年龄,并说明正在做什么:
| 状态 | 标签页显示什么 |
| 没有快照,宿主可以重建 | 「No snapshot yet」、按下 Refresh 会做什么,以及它会运行的确切命令 |
| 没有快照,也没有配置命令 | 没有配置任何命令,以及该设置哪个配置键 |
| 没有快照,宿主无法重建(没有 subprocess 服务商) | 同上,但会说明这一点——这样就不会有人干等一场永远不会到来的重建 |
| 快照存在但读不了(版本不匹配、不是 JSON) | 宿主自己的原话,以及重建不会修好它 |
| 快照比间隔的两倍还旧 | 图表照画,上方横幅说明这些是真实数字,但不是最新的 |
| 快照里没有可读的 generated_at | 数字照画,徽标显示「age unknown」,横幅说明无法判断年龄 |
| 一次读取在成功之后失败 | 保留上一次的好数字,连同它们的年龄——绝不显示空图表 |
| 宿主不可达 | 在通知里显示传输错误,面板其余部分保持完整 |

文件

| 文件 | 作用 |
| package.json | 清单:dsh.bundle.patch、dsh.client,以及 ./client 导出 |
| cordis.patch.yml | 一行 insert,带占位默认值待替换 |
| index.mjs | 宿主侧:两条路由、快照读取、有界的 single-flight spawn |
| client.cjs | 浏览器侧:右窗格标签页、头部操作行触发器,以及手绘 SVG 图表 |
| test/host.test.mjs | 28 个测试——延迟注册、读取路径、spawn 边界、透传 |
| test/client.test.mjs | 54 个测试——插槽接线、纯辅助函数、新鲜度规则、断点标记与前沿 |
| test/live-smoke.mjs | 可选加入的端到端检查,针对真实项目和真实命令 |

npm test # node --test test/host.test.mjs test/client.test.mjs
npm run check # node --check index.mjs && node --check client.cjs
npm run test:live # needs DSH_TRAIN_DASHBOARD_WORKSPACE and ..._COMMAND; see the file

live smoke 测试会打印它缺什么,未配置时以 0 退出,所以新克隆的仓库永远不会带着一个没人能满足的红色测试。

为什么这里没有图表库

零运行时依赖、无构建步骤、纯 JavaScript,图表是手写的 SVG。这不是为了极简而极简:

- DSH 客户端禁止导入 Harness Client 包(@deepseek-ai/dsh-client-ui-primitives 之类),因为它们会不打招呼就变,而一个抛错的组件会让插槽条目变空白;
- 加一个图表库会多出第二个模块,客户端加载器得去解析它,而加载器只解析平台种子表、boot-graph 行和已注册的工厂——其他任何东西都会抛错;
- 一条折线、一根轴和一个断点标记,用不上 d3。

缩放是 DOMAIN 的变化,不是 SVG transform。缩放 viewBox 也会连带缩放描边和刻度标签,所以放大后的图表会是一个图表更模糊;重新计算投影后,每一笔都保持一个像素,刻度也能用屏幕上窗口对应的标准漂亮值。

遵循的设计规则,以及它们背后的缺陷

下面每一条,都是这个 harness 里某个插件已经踩过一次的坑:

- 补丁里只有一行。 启动审计只要发现任何条目还处于 pending 就会失败,所以如果再加一行去等浏览器载体,就会弄坏 headless、SDK 和 ACP 配置档——它们根本没有浏览器载体。
- 不用 ctx.shell。 桌面配置档可能不挂载 shell-executor 行,而 Cordis 对缺失的注入服务解析是宽松的:调用能正常注册,只在第一次使用时才失败。这一半用 ctx.fs 和 ctx.subprocess。
- 浏览器载体在 ctx.inject 里读,绝不在 apply 里读。 在 apply 期间读会看到 undefined,然后插件照样加载、标签页照样打开,而每次 fetch 都 404。
- 不导出 Config 类。 Cordis 会把导出的 Config 当作 schema,并无条件调用 Config['~standard'].validate,于是 fiber 在启动时就死掉,没有任何路由,浏览器看到的 404 看起来就像快照缺失。这里的函数叫 normalizeConfig。
- 标签页主体注册在标签定义的 id 下,不是 kind 下。 面板分发时用 entryKey: definition.id ?? tab.kind,所以按 kind 注册的主体,会在一个标签标题正确的标签页上渲染出面板自己的「Nothing here can view this kind of content yet.」。
- 每个槽位注册都要写明槽位名。 注册时不带 name 会在 apply 运行期间抛错,桌面应用会把它当作启动失败。
- 空命令是一种状态,不是崩溃。 没配置 command 时,宿主什么都不启动,报告 canRefresh: false,标签页会告诉你该设置哪个键——而不是跑一个空 argv 然后报个莫名其妙的错。
- 插件绝不往你的运行目录里写东西。 它唯一导致被写入的文件是快照,由你的命令写入;读取时如果撞上不完整的数据,标签页会保留上一次的好数字。
- 坐标轴按窗口内的采样缩放,不是按画出来的全部内容。 画线时每条边外各多画一个采样,这样线能接到边框上,而不是从半空中开始;把坐标轴按这些边缘采样缩放,等于把缩放权交回给窗口外那一个采样,于是你放大想躲开的尖峰,还是会继续压平窗口。测试把两半都钉住了:pointsToDraw 管几何,pointsInRange 管坐标轴。
- 窗口会被钳制,而且有下限。 缩放被钳制在运行范围内,并停在它的 MIN_WINDOW_FRACTION 处,因为一个只有一两个采样的窗口,是一张什么都说不出来的图——而拖过边缘会滑回范围内,而不是把窗口压扁。
- 缩放控件是按钮,不是手势。 拖动是快路径,但只能靠拖动到达的范围,键盘选不了,也被屏幕阅读器读取,所以图表会获得焦点(用 +、-、0 和方向键操作),每个控件都带有标签。
- 插件通过 data-plugin 拥有自己的样式表。 客户端加载器会把所有缺少该属性的 style 标签,归给下一个出现的插件;当某个条目被删除时,凡是 data-plugin 等于该 id 的标签也会一并删除。

replaced or pruned — so a privately tagged sheet is deleted with another plugin, which strips fill: none from the SVG paths and they fill black.
- A break marker says what KIND of break it is, or says that it does not know.
The kinds do different things to a curve — a corpus change explains a rise, an instrument change makes the joint a level shift, an arithmetic change makes the series incomparable across it — so a marker drawn as a bare line invites exactly the comparison it should prevent. The kind is a word on the line and a colour, an unknown kind keeps its own name, and a marker served without a kind is labelled UNKNOWN rather than coloured like something it may not be.
- A marker's words are the snapshot's, never this plugin's. The label, the
reason, the sentence for the kind and the evidence are carried through
verbatim; the tab chooses where a marker sits, how much of it fits on the
chart and how it is stacked, and never what it says. A paraphrase here would
be a second statement of one fact, free to disagree with the record.
- A marker that is not drawn is still reported. Markers past the end of the
series or outside the window still read in the register, a marker with no
readable leg is counted and reported rather than dropped, and the frontier
says why its dashed line is not on the chart yet. A silent absence in a chart
is read as "nothing happened here".
- The frontier is drawn as an expectation. It is a projection from the
producer's own budget, in its own dashed style and its own lane, labelled
expected around leg N — and when the producer says the transition is imminent the wording moves to AT LEG N, because there is nothing left to predict. The tab adds one caveat of its own: the leg is a direction and not a magnitude, and the producer may end a phase early.
- snapshot_version stayed at 1 while the new keys arrived. The contract
version is a claim that a reader can read the body, and breaks and
frontier are additive: an older reader serves them and simply draws less,
while a bumped version would make every deployed reader refuse the snapshot
outright. The host is a pass-through and a test pins that it does not reshape
or drop a key it does not know.
- An action goes in an action row, never in shell.overlay. That seat is a
frame-wide floating layer for badges, toasts and status pills, and it is
click-through on purpose, so a BUTTON registered there is in the wrong place
(the window's top-left, beside the application menus and the sidebar's reopen
control) and unclickable unless it opts back into pointer events. The trigger
belongs to conversation.session.header.actions, the title-adjacent row the
shipped jobs, subagent, agent-preset and agent-team controls use, and it copies
their metrics — borderless, transparent, at least 28px, tertiary label colour at rest and secondary on hover — so the row reads as one set of controls. The test asserts the trigger is in that row and that nothing of this plugin is registered in the overlay.
- The selected series belongs to the reader, not to the payload. The
snapshot is rebuilt from logs on disk on every refresh, so a series can drop
out of one revision and come back in the next: the live snapshot rotates its
per-leg traces, and one refresh replaced guard_trace/…_leg_00722_… with …_leg_00725_…. The panel resolved the selection with
offered.find(tag) ?? offered[0], so a series missing from a revision silently
moved the reader onto the default curve, which looks exactly like a redraw and
is actually the panel discarding a choice. resolveSelection reports the choice
as MISSING instead, its chip stays marked with a dashed edge and its own words,
and nothing else is drawn in its place. Those trace tags are filtered out of the
chips too, by the same shape rule that already excluded vram_trace/.
- The choice and the window outlive the component. The pane unmounts and
mounts a tab body again on events the reader did not cause, and React state does
not survive that, so both are remembered outside the component: the series per
session, the zoomed window per series tag. Reset forgets the window, which is
what keeps "reset" and "never zoomed" one state.
- Every control is pressed by a check, not merely rendered by one. The zoom
buttons were wired the wrong way round, + widening the window and -
narrowing it, through a passing unit suite: the tests asserted that the labelled
controls existed rather than what they did. The end-to-end check clicks each
control and reads the window back, which is how the inversion was found.
- The chart carries identifiers; the register carries the words. A marker was
drawn with its leg, its KIND and the first words of its reason, which repeated
what the register row below already said in full and cost the reader the curve:
six joints inside fifty legs stacked into six lanes of prose across the plot.
The plot now carries a shape and the leg number, and the register carries the
prose. The shape is not decoration: a kind encoded by hue alone is invisible to
a reader who cannot separate the hues, and these kinds do different things to
the curve, so an unreadable kind invites the comparison the register exists to
prevent. The register draws the same shape in its key, and a test reads the
stylesheet and compares it against the shape table, because one mapping written
twice is free to disagree with itself.
- An expectation is not drawn like a fact. The frontier is the one unfilled
swatch, in the label colour, on a dashed line, and its sentence sits in the
register under a heading that calls it an expectation. A predicted leg drawn
like a joint read from an artifact would be a guess wearing a measurement's
clothes.
- A default list is a claim about what matters, and it goes stale. The chips
offered nine metrics in a fixed order chosen when the tab was written, and the
snapshot had since grown to sixty series: the row became a wall of controls and
the one at the front was a truncated duplicate of the one behind it. The list is
now four, it is one constant with the reason for each entry beside it, and
everything else is behind a disclosure rather than gone. A default nobody
revisits is a default that decides for the reader.
- When two series are one chart, the tab resolves them. train/bpb_sealed and
train/bpb_legval are identical on all 688 legs they share; the first stops at
the phase boundary and the second continues 42 legs past it. So one entry names
the other as its fallbackFor: where both exist the truncated one is not
offered at all, and where only the curve exists it is the reading. A reader
should never be handed two chips for one picture and left to work out which is
which.

Licence

MIT. It matches the sibling plugin dsh-knowledge-dag, whose licence this one
follows, and DeepSeek Harness itself, which is also MIT.
预览分享卡片长什么样 ↓moazzamak/dsh-train-dashboard
分享 · DeepSeek Harness Hub
moazzamak/dsh-train-dashboard

运行你的命令读取训练快照,逐条绘制指标曲线

回到顶部