dsh-task-timer
Manifest validDeepSeek Harness plugin: a clock-led per-session working-time pill that subtracts the time spent waiting for your answers, whose dialog shows the span from session creation to its last activity (cappe
dsh-task-timer
DeepSeek Harness 插件:在对话最下方的仪表盘(composer dock)里,新增一个工作时长 pill,
展示本会话 agent 的工作时长;点开后给出创建对话至今;同时在左侧边栏、用户名那一行的
上方,显示全部对话的合计使用时长(1小时29分 · 对话总时长),紧接其下是同一批对话的
合计 token(3.3M · 对话总token)。侧边栏「工作区」里每条对话
右侧的时间补成 55分钟前,不再让人读成「这条对话跑了 55 分钟」。
它统计什么
工作时长 = 模型时间(llmMs) + 工具时间(toolMs) - 等待回答(awaitMs)。前两项直接取仪表盘
已在用的 sessionStats 投影,不再自己折叠一遍事件日志——仪表盘上「N轮N步」那个 pill
已经把模型时间和工具调用时间分开列出。
第三项是本插件唯一要自己折叠的东西。sessionStats 的工具时间是按 tool/call 到
tool/result 的真实跨度记账的,而 ask_user_question(以及 exit_plan_mode 的计划评审)
这类工具要等你回答才返回——你挂机两小时,它就诚实地记两小时工具时间。所以宿主侧另外
配对这几个调用,把它们的跨度单独累计成 awaitMs,再从读数里减掉。
因此天然不计入的还有:用户输入时间(user/message 与下一次 step/start 之间的空隙)、
轮次之间的挂机/空闲(tool/result 与下一次 tool/call、step/start 之间的空隙)——这些
时间从来不在 llmMs/toolMs 里,不需要减;跨轮挂机不需要额外处理,只有问句未答的那段
需要一个负数项,就是 awaitMs。
早于 1.4.0 的版本把「问了等你回答」的那段时间当成了工作时长:一次实测中,5 次提问的等待 合计 420.8 秒,占该会话读数 4.4%。口径修正见下,两侧(pill 与侧边栏合计)同步生效。
awaitMs 的配对规则与 sessionStats 逐字对齐,否则减掉的和记上的不是同一段:
- 只有
ask_user_question/request_user_input/exit_plan_mode这三种调用开等待; 带 MCP 前缀的副本(mcp__<server>__ask_user_question)按最后一段识别; - 等待在
tool/call打开、在tool/result结算;未结算的等待不扣——那时仪表盘也还 没把它记成工具时间,提前扣会把读数扣成负数; turn/end时未结算的等待直接丢弃(取消/失败的轮次),sessionStats同样是丢弃而不是 计时,所以这段本来就不在读数里。
计划评审(exit_plan_mode)走的是同一类等待,一并扣除。工具的审批弹窗没有对应事件,
日志里分辨不出来,因此仍按工具时间计——这是已知边界。
弹窗里的创建对话至今是另一个口径:最后一次对话活动 - 对话内容起点,是对话本身的墙钟
跨度,包含挂机和用户思考,但两端都钉在事件上:
- 起点是第一条承载对话内容的事件(首条消息/首个 turn),不是会话 header 的
createdAt——桌面壳会在打开时就建好 Session 记录,第一条消息可能几小时后才来,按记录 起算会让几分钟大的对话读出 3 小时(实测一条 19:55 建记录、23:41 才首用的会话读出 3h46m)。日志里还没有内容事件时退回createdAt;fork 继承的祖先日志时间都早于本 header,同样不会把起点拉长; - 终点是日志里最新一条推进对话的事件(消息、turn/step 边界、工具调用对),不是
Date.now()。因此一条对话没人再动、也没有新消息时,这个数停住不动,不会随着你挂着 窗口而一秒一秒往上涨;一旦有了新消息,终点前移到那一刻,跨度自然把中间的间隔算进去。
这是"只统计到本对话最后一条消息生成结束"的口径,与上面那个"纯 agent 工作秒数"互补:一个是 对话活了多久,一个是 agent 干了多久。
工作原理
- 宿主侧
lib/index.js:通过ctx.sessionProjections.register(...)注册一个极简的taskTimer投影,发布四个端点——createdAt(建会话那一刻)、startedAt(第一条对话 内容事件的时间,0 表示还没有)、lastActivityAt(日志里最新一条推进对话的事件时间)、awaitMs(被问到答之间的累计跨度)。createdAt没有任何事件能改变;startedAt由第一条白名单活动事件钉住一次、之后不动;lastActivityAt只接受白名单事件(user/developer/system/message、turn/start|end、step/start|end、assistant/attempt|message、tool/call|result),改名、改设置、归档这些元数据事件不算 活动,否则给会话改个名就能把空闲的时钟重新拨起来。取值点仍是init(header, ...):这是 框架唯一把 header 交给投影的地方。- 游标单调:
!(at > lastActivityAt)直接丢弃回放里乱序/缺时钟的事件;游标初值就是createdAt,所以 fork 继承来的那段日志(时间都早于本 header)不会把跨度拖成负数, 也不会抢走startedAt(起点只接受不早于createdAt的事件)。 - 等待表
awaitCalls只在内存/检查点里维护({callId: 打开时刻}),不进 wire: 客户端需要的只是累计结果awaitMs。
- 游标单调:
- 浏览器侧
lib/client.js:向conversation.composer.dock(对话底部仪表盘)注入task-timerpill,排在官方activity(TPS)和usage(Token)之后。- 主标签沿用仪表盘的值在前、名称在后格式:
51分24秒 · 工作时长; - 时长按需进位:不足 1 分钟显示
45.2秒,不足 1 小时显示51分24秒,满 1 小时后 显示1小时29分并不再显示秒;分钟数不会溢出,不会出现60分、89分这类写法 (整小时显示1小时); - 点击弹窗只给出仪表盘没有的两项:
创建对话至今,以及被扣掉的已扣除等待回答(后者 只在真的扣过数字时才出现)。模型时间/工具时间不在这里重复——「N轮N步」那个 pill 已经 分开列出,弹窗再列一遍是冗余;而扣减项必须写出来,否则 pill 的读数会比旁边的活动数字 略小,却没人解释差在哪。 - 弹窗里的这一行打开时立刻刷新一次,打开期间每秒走一格;收起后在后台每分钟
轮询一次(
OPEN_TICK_MS = 1e3/IDLE_TICK_MS = 6e4),不常驻秒级定时器。 渲染值取min(now, lastActivityAt) - (startedAt || createdAt):一旦追上终点(对话已空闲), 连定时器都不再启动,等新事件改写lastActivityAt再自然恢复。 - pill 与弹窗标题前都带 primitives 的时钟图标(
IconClockOutlineRegular,14px、与文字 间隔6px),和旁边的activity(仪表盘)、usage(数据库)两个 pill 一致;侧边栏 合计行仍为纯文字,不画图标。 - 弹窗不自己复刻样式:运行时从 ui-chat 挂载的
stat-dialog.module.css里读回它那套 带哈希的类名(.xxxx_panel/_title/_titleRule/_details),直接应用到本弹窗, 因此圆角、内边距、分割线、栅格、「N轮N步」弹窗与本弹窗用的是同一份 CSS 规则, 不存在两套近似值;拿不到那份样式表时(不带 ui-chat 的装配)才退回内置的等价内联样式。 唯一不复用的一条是底色:内置弹窗用specific-menu半透明菜单膜 + 背景模糊,本卡片 更宽更高,那层膜会把背后的壁纸/皮肤/输入框渐隐透成一片渐变,所以这里固定用仪表盘卡片 同款的bg-layer-2实心底色,并显式backdrop-filter: none。
- 主标签沿用仪表盘的值在前、名称在后格式:
- 侧边栏合计:向
sidebar.footer.action注入两行——1小时29分 · 对话总时长,下面紧跟3.3M · 对话总token——位置就在 Settings/用户名行正上方。那个 slot 是display:flex的横向 条,所以两行由插件自己的一个纵向容器摞起来,不是并排。两行都设了user-select: none, 鼠标划过不会把数字选进选区。 - 会话行的时间:侧边栏「工作区」里每条对话右侧原本只是一个距离(
55分钟/55min), 贴在时钟旁边很容易被读成「这条对话跑了 55 分钟」。插件把它补成55分钟前,与悬停卡 (本来就写55分钟前)读法一致。文案不是硬编码的:后缀走 ui-workspace 自己的time.ago模板({t}前/{t} ago),中英切换自动跟随。刚刚,以及同一格里可能出现 的状态词(待回答/待审批),都不动。理由与做法见下一节。
会话行的时间为什么是「前」
那格子是 ui-workspace 的 timeLabel 渲染的,没有对外 slot;而 locale 注册表对同一
命名空间的同一语言只允许注册一次(第二个 register("workspace", { zh, en }) 会直接抛
locale namespace "workspace" already has locale "zh"),所以也改不了它的词典。留给第三方
插件的只剩渲染结果本身:
| 决定 | 原因 |
|---|---|
用 MutationObserver 盯 DOM | 没有 slot、没有可覆盖的词典,这是唯一不改宿主代码的位置 |
只匹配 _sessionRow 里的 _time | 两边的类名都带构建哈希(wnFoaW_sessionRow/wnFoaW_time),所以按后缀匹配;聊天消息与提问卡也有 _time,限定在会话行内就不会误伤 |
| 先比对模板再改 | 模板用探针值 7 从 time.minutes 等键读回(7分钟),把探针两侧转义成锚定正则;状态词、刚刚 都对不上,自然跳过 |
只改文本节点的 nodeValue | 替换子节点会让 React 手里的节点脱钩,之后每次刷新都往已脱离文档的节点写值,行上的时间就永远停住 |
| 幂等 | 改写本身会再触发一次观察,但此时文本已带「前」、不再匹配裸模板,于是停手,不会出现「前前」 |
宿主没有 MutationObserver 就整个不挂 | 非浏览器装配(例如 SSR 冒烟)不该因为装饰器而报错 |
侧边栏使用时长怎么算
不额外读盘、不新开 RPC:直接复用侧边栏已经拿到的会话列表快照
(sessions.list 的 ids / byId / projectionsBySession),把每行的
sessionStats.llmMs + sessionStats.toolMs - taskTimer.awaitMs 加起来。
| 约定 | 说明 |
|---|---|
| 数据来源 | 会话行的 sessionStats 与 taskTimer 投影:已连接会话用实时值,冷会话用投影检查点缓存(session_projcache),因此不会为了这一个总数去解压历史日志 |
| 实时性 | 当前会话推进时,projectionsBySession 的实时值优先于列表里的旧缓存,该数值随 agent 工作增长 |
| 扣减 | awaitMs 逐行先减再求和:等待属于某一条对话,先求和再减一个会话级总数在算术上不成立 |
| 排除项 | origin === "subagent" 的子会话行不计入——派发它的对话已经把这段时间算成工具时间,两边都算会翻倍 |
| 收起态 | 侧边栏折叠成 56px 轨道时整行隐藏(轨道里放不下说明文字) |
| 为 0 时 | 一条会话都没记录过工作时长时整行不渲染,不留空行 |
侧边栏 token 合计怎么算
同一份列表快照、同一批会话,读的是 tokenUsage 投影:每行四个互不重叠的桶
(uncachedInputTokens / cacheReadTokens / cacheWriteTokens / outputTokens),
合计为提示侧三项加输出。
| 约定 | 说明 |
|---|---|
| 口径 | 提示侧三项 + outputTokens,与 ui-chat UsagePill 的「整份日志 token 总量」完全一致,两处不会各说一个数 |
| 数据来源 | 会话行的 tokenUsage 投影:已连接会话读实时值,冷会话读投影检查点缓存 |
| 实时性 | 与时长一样,projectionsBySession 的实时值优先于列表缓存 |
| 排除项 | origin === "subagent" 的子会话同样不计——父会话已经把这些 token 记在账上 |
| 数字格式 | 紧凑写法,与内置 usage pill 同一套闸口:517 / 12.2K / 517K / 1.2M。不写全量数字,否则侧边栏宽度会把「对话总token」这几个字省略掉 |
| 为 0 时 | 没有任何 token 记录时这一行不渲染;只有 token 没有时长时,token 行照常单独显示 |
目录结构
dsh-task-timer/
├── package.json # 插件清单(dsh.bundle + dsh.client)
├── cordis.patch.yml # bundle 补丁:insert 一条 task-timer 条目
├── install.ps1 # 本机 link: 安装脚本(不随 npm 包发布)
├── LICENSE # MIT
├── lib/
│ ├── index.js # 宿主侧:注册 taskTimer 投影(createdAt + lastActivityAt + awaitMs)
│ └── client.js # 浏览器侧:工作时长 pill + 创建对话至今行 + 侧边栏时长/token 两行 + 会话行「N前」
├── locale/
│ ├── zh.json # 插件页显示元数据(中文),必须是 {"meta":{"title","description"}}
│ └── en.json # 插件页显示元数据(英文),同上结构
├── market/
│ ├── index.json # format 1 索引(托管到 http(s) 后供「发现」读取,不随包发布)
│ ├── PUBLISH.zh.md # 上架 / 自建索引源说明
│ └── PUBLISH-GITHUB.zh.md # 推到 GitHub 并进 dshmarketplace.dev 的流程
└── test/
├── projection.test.mjs # 投影契约测试(init 取 header、游标单调、state/view 引用稳定)
└── render.test.mjs # 客户端 pill 渲染冒烟测试(SSR,断言样式、结构与创建对话至今)
npm publish 只发布 package.json 里 files 白名单列出的文件,加上 npm 强制包含的
package.json / README.md / LICENSE,一共 8 个:lib/index.js、lib/client.js、
cordis.patch.yml、locale/{zh,en}.json、package.json、README.md、LICENSE。install.ps1、
market/、test/、node_modules/ 都不进包。
locale/*.json 的顶层必须是 meta 对象:插件页(设置 → 插件、插件管理器、发现)的标题与说明由
宿主 readPluginMeta() 读取 locale/<语言>.json 里的 meta.title / meta.description,缺这两项就
退回 package.json 的 name / description(于是中文界面显示英文说明)。文件名必须是语言 id
(en、zh),en.json 必须存在,否则整个 locale/ 目录都不会被扫描。
安装
从公开仓库安装(<dsh> 指 DSH 安装目录下 resources/runtime/cli/bin/dsh.cmd,若已加进 PATH
则直接写 dsh):
& "<dsh>" plugin --profile desktop add github:ShuaihuiMou/dsh-task-timer
GitHub 与 Gitee 是同一份代码,直连 Gitee 等价:
& "<dsh>" plugin --profile desktop add "git+https://gitee.com/sunxiaochuan2588000/dsh-task-timer.git"
想锁死版本就在 # 后跟完整 40 位提交 SHA——插件市场的 git 源、本仓库 market/index.json 都是这么锁的,
下面这条锁的是 1.4.1:
& "<dsh>" plugin --profile desktop add "github:ShuaihuiMou/dsh-task-timer#d9d7fc0e2c5f49f71e671927e8d416e24390d3a8"
npm 上还没有这个包(尚未发布),等发布后 add dsh-task-timer 也等价。
调试本地源码时用 link: 装法,改动即时可见,不必重装;file:(不加 link: 前缀)则会把包
拷贝进 profile。在插件目录双击/运行 install.ps1,或手动执行:
& "<dsh>" plugin --profile desktop add "link:<插件目录绝对路径>"
install.ps1 会按 -DshCli <路径> → $env:DSH_CLI → PATH 上的 dsh 顺序查找 CLI。
安装完成后必须重启 DeepSeek Harness(新 bundle 补丁要重载才会注册投影与 pill)。
验证
宿主侧投影契约已用真实 zod 跑通(test/projection.test.mjs),输出如下:
projection: createdAt + startedAt + lastActivityAt + awaitMs published, cursor monotonic, waits paired, view references stable
它断言的是:init(header) 原样取 header.createdAt 并把游标起在同一个点(fork 的
inheritedEventCount 不参与)、起点由第一条内容事件钉住且祖先日志抢不走、只有对话类事件
推进 lastActivityAt 且乱序/缺时钟不会回退、
session/title 等元数据事件不动游标、wire.view 对同一 cell 复用同一个 view 引用(注册表靠
这个引用比较抑制推送)、view 里不再出现 llmMs/toolMs/workingMs。
等待的配对另有一组断言:普通工具(含 mcp__dsh__pwsh 这种带前缀的)不开等待、
ask_user_question 与 mcp__dsh__ask_user_question 开、exit_plan_mode 也开;未结算的等待
awaitMs 保持为 0(此时仪表盘也还没记账);结算后恰好等于 call→result 跨度;turn/end
丢弃未结算的等待且之后迟到的 tool/result 再也减不出数字;多次等待累加;awaitCalls 不出现在
wire 里。
复跑测试(需要 zod,用任意 Node 20+ 即可;DSH 自带的那份在
%USERPROFILE%\.dsh\dsh-runtimes\<runtime>\dependencies\node\bin\node.exe):
node test\projection.test.mjs
客户端渲染用 react/react-dom 做 SSR 冒烟(--no-save 装即可,不进 package.json):
npm install --no-save --package-lock=false react@19 react-dom@19
node test\render.test.mjs # render smoke: closed-state and open-state assertions passed
弹窗断言把时钟钉在固定值上:createdAt = now - 30 分钟、且 lastActivityAt = now 时,弹窗必须
出现 30分0秒——证明这个数是从当前时间现算的;随后把 lastActivityAt 换到
createdAt + 10 分钟(即对话已空闲二十分钟),同一份渲染必须变成 10分0秒 且不再出现
30分0秒/20分0秒——这就是"没人动就不再累加"的回归测试。
扣减的回归测试同样在 test/render.test.mjs 里:llmMs 2分 + toolMs 7分、awaitMs 7分 时,
pill 必须读 2分0秒 · 工作时长 且不得出现 9分0秒;弹窗多出一行 已扣除等待回答 7分0秒;
awaitMs 大到把读数压成负数时整颗 pill 不渲染(而不是显示负值);宿主还是旧 view(没有
awaitMs 字段)时退回旧读数而不是 NaN。
侧边栏合计另有断言:某一行的 taskTimer.awaitMs 必须先减该行再求和
(1小时30分 → 1小时20分),且实时投影里的等待覆盖检查点缓存里的值。
浏览器侧 pill 的注入结构与官方 dsh-client-ui-chat 的 ActivityPill/UsagePill 完全同构
(同样的 conversation.composer.dock slot、同样的 useProjection keyed hook)。
侧边栏那一行用同一份 test/render.test.mjs 覆盖:wide=false 时不渲染、总量为 0 时不渲染、
子会话行被排除、实时投影覆盖列表缓存、1小时30分 · 对话总时长 的文案、user-select: none
(含 -webkit- 前缀)、行内不画图标,以及 12px/18px 的排版。同一份测试还断言了第二行:
3.3M · 对话总token 排在时长之下、子会话的 token 不进合计、实时 tokenUsage 覆盖缓存值。
另一份断言还覆盖两条弹窗皮肤路径:
样式表缺席时用内置内联样式,样式表挂上后弹窗立刻改用 .xxxx_panel 等内置类名、并且不再输出
自己的背景声明。
会话行「N前」用一份极简假 DOM(matches/closest/querySelectorAll + 可回放的
MutationObserver)覆盖:已渲染的 55分钟 装载即变 55分钟前;后挂载的行在挂载的同一次提交
里被改写;刚刚、待回答 不动;聊天消息上同名的 _time 不受影响;改写回流到观察者时不追加
第二个「前」;切到英文词典后新行读 55min ago,已渲染的中文行保持 55分钟前;宿主没有
MutationObserver 时不抛错。
样式对齐(与仪表盘内置 pill 逐项一致)
字体/字号肉眼可见的差异只可能来自"自己发明一套尺寸"。这里不发明:所有数值都抄自
dsh-client-ui-chat 的 StatsPills.module.css 与 stat-dialog.module.css。
| 项目 | 内置 ActivityPill/UsagePill | 本插件 |
|---|---|---|
| 字号/行高 | 12px / 20px | 同 |
| 高度 / 内边距 | 22px(1px 8px 内边距 + 20px 行盒撑开,不写死 height)/ 1px 8px | 同(gap 6px 留给前面的时钟图标) |
| 圆角 | var(--dsw-radius-sm)(8px) | 同 |
| 前景 / 底色 | label-tertiary / 无底色(background: 0 0) | 同(只有 hover / 展开态才上 interactive-bg-hover) |
| 悬停 / 展开态 | hover 与 [aria-expanded=true] 同为 interactive-bg-hover,文字转为 label-secondary | 同(并排选择器一次写死,展开不额外上一个"选中"底色) |
| 图标 | 每个 pill 一位 primitives 图标(activity 是仪表盘、usage 是数据库),尺寸由 .pill svg { 14px } 定 | 时钟 IconClockOutlineRegular,pill 与弹窗标题各一位;侧边栏合计行仍纯文字 |
| 弹窗 | pad 16、specific-menu 面、12/18、6/16 明细栅格 | 布局与栅格同一个类名:运行时读回 ui-chat 的 stat-dialog.module.css 哈希类名后直接套用 |
| 弹窗底色 | specific-menu(半透明菜单膜 + menu-backdrop-filter 背景模糊) | 不同:改用 bg-layer-2 实心底色并关掉背景模糊。这张卡片比 token 那张更宽更高,半透明膜会把主题壁纸/海洋皮肤/输入框渐隐透成一片渐变,实心底才是一个基础色 |
| 弹窗定位 | useAnchoredPosition,side top、gap 8、margin 12 | 同(复用同一 primitive) |
| 标签文案顺序 | 数值在前、名称在后,· 分隔 | 同(51分24秒 · 工作时长) |
因此 lib/client.js 不再手写坐标钳制、不手绘 SVG、也不用 font: inherit(那会把继承来的
字号带进来,正是之前看起来"字体不一致"的原因)。
底色这条曾经抄错:早期版本给它上了
bg-layer-2实心底。默认亮色主题下 dock 本身也是白底, 所以看不出来;换上海洋色这类半透明皮肤后,dock 透了、这颗 pill 仍然自带底色,就成了整排 仪表盘里唯一一块不透明的色块。现在与内置 pill 一样background: 0 0,只在 hover 时才有底色。
改动生效范围(排查用)
| 文件 | 生效方式 |
|---|---|
lib/client.js、locale/*.json | 渲染进程侧,重开窗口即读到新代码 |
lib/index.js | 主进程侧,只有整个 DSH 进程重启才会重新加载 |
判断宿主侧有没有真的换上新代码,看投影检查点缓存
%USERPROFILE%\.dsh\storages\session_projcache\sessions\<session-id>.json 里的
record.rows.taskTimer:换上新代码后应是 ver: 4 与
{"createdAt": ..., "lastActivityAt": ..., "awaitMs": ...}。
还有一种更直接的判据:ver 低于 4 的旧行会被注册表的版本闸门挡下、按 stateVersion: 4
自动重放日志重算,不需要手动清缓存;而主进程没重启时投影根本不会重算——此时 wire 里
没有 awaitMs,客户端只能按旧的 llmMs + toolMs 记账(问句的等待会继续被算成工作)。
所以看到读数还没扣掉等待,先确认托盘里的 DSH 进程是不是整只重启过。
Comments
Loading…
Similar plugins
by msongw-design
A DeepSeek Harness plugin: rewrite the sidebar's relative session times as WeChat-style absolute times
★ 0
MIT
JavaScript
Oct 31, 2026
dsh plugin --profile web add dsh-plugin-session-clockby 2huy4n
A DeepSeek Harness plugin that gives a roleplay character a clock: sleep/wake, greetings, idle actions, proactive small talk, and outing photos.
★ 31
AGPL-3.0
JavaScript
Nov 2, 2026
dsh plugin --profile web add project-karenby weibaohui
DeepSeek Harness plugin: cron-driven scheduled items with a full-page management UI. Each item carries a title, a prompt, and a croner expression; executions spawn a fresh agent session in a bound wor
★ 0
JavaScript
Aug 19, 2026
dsh plugin --profile web add dsh-plugin-scheduled-itemsDeepSeek Harness plugin: show a running / just-finished indicator on sidebar workspace rows
★ 0
MIT
TypeScript
dsh plugin --profile web add dsh-workspace-activityby zhu168
Save-money plugin for DSH (DeepSeek Harness) — define your own "pause / resume" time windows; at pause time running long tasks are paused (not stopped) automatically, and they resume when the window e
★ 37
MIT
JavaScript
Aug 22, 2026
dsh plugin --profile agent add dsh-save-moneyby cute-baobao
DeepSeek Harness usage recorder plugin: per-model hourly token usage (input/output/cache hits) with a stacked bar chart dashboard in the Web GUI.
★ 3
MIT
TypeScript
Aug 14, 2026
dsh plugin --profile web add @dsh-usage-meter/usage