Repository navigation
Releases
dsh-in-hand v1.8.25 — 手机端支持 dsh-ui 确认表单围栏
dsh-in-hand v1.8.25
来自 issue #1:桌面端装了 dsh-genui
之后,助手回复里的 dsh-ui 围栏会渲染成可交互表单,但手机上一直原样显示成代码块——因为
page.html 的围栏解析对所有语言一视同仁,全部交给 codeNode(lang, body),dsh-ui 这个词在
源码里出现 0 次。那条 issue 把根因指到了具体行号,判断是对的。
只做确认表单子集,理由有两条:手机是窄屏,页面是零依赖单文件(一个 page.html 跑完整个
界面),把 dsh-genui 那套 React 渲染器搬进来不现实;而「确认」恰好是手机端最值得做的动作。
- 新增
dsh-ui围栏分支:text/radio/checkbox/input/textarea/submit六种组件 - 组聚合照 dsh-genui 的语义:
radio.group/checkbox.group只本地记录,submit汇总成一条
消息,格式逐字照抄它的action.inline模板([genui-action] … Component data: {answers, fields, total, answered}),模型侧不用为手机端适配 - 「诚实交互」:
submit缺action渲染成禁用态;groups成员没填完也禁用,并显示「已填 N/M」 - 去重:按「会话 + 围栏正文指纹」记在
localStorage(最多 200 条 LRU),提交后置灰「已提交」,
刷新不重复发 - 坏规格安全降级:JSON 解析失败 / 未知
type/ 超 200 节点 / 超 8 层嵌套 → 能画的照画,画不了
的丢弃,原文一字不损留在代码块里,上面标一行「围栏解析失败,按代码块显示」 - 不做判卷(
answer/explanation/quiz)、图表、mermaid、table、tabs——类型表里没有
就直接丢弃,桌面端继续用 dsh-genui - 安全:模型文本只走
textContent/setAttribute,不拼innerHTML,无eval/new Function
新增 scripts/check-genui.mjs(36 条断言,已接进 npm run check)与 npm run shots:genui。
演示页新增一条带表单的演示消息(scripts/make-demo.mjs)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.24 — 点星授权改用 public_repo
点星授权改用 public_repo
v1.8.23 把点星做成 GitHub 设备码授权,但设计成「空 scope」。真机上点星一直失败,用真实账号跑完整链路后定位到根因:
空 scope 的 OAuth token 不能 PUT /user/starred,而且 GitHub 回的是 404 Not Found 而不是 403 —— 错误信息看着像「仓库不存在」,非常容易误判成仓库名写错了。
DEVICE_SCOPES:''→'public_repo'。同一条流程实测返回 204,仓库星数 +1。- README 与授权面板的说明改成事实:GitHub 只提供
public_repo这一档,没有「只能点星」的更窄范围。 scripts/check-github-star.mjs的 scope 断言改为必须带scope=public_repo,防止以后有人改回去。
token 仍然只在宿主内存里停留几秒(换到就立刻点星,然后置 null 并删除流程),不写日志、不写文件、不进任何响应。
dsh-in-hand v1.8.22
dsh-in-hand v1.8.22
为什么只做外链(2026-11-02)
用户原话是「加一个产品内部自动给 GitHub 点星的按钮」。自动点星不能做:GitHub
的使用条款明确禁止自动 / 批量 star,脚本代点属于操纵互动指标;一旦被判定,受影响
的不只是这个仓库,同一个账号下的仓库与 npm 发布权都可能被连带处理。这个插件
本身就是靠 npm 分发的,冒这个风险不值得。
所以这一版只做用户自己点的外链引导:页面不请求 GitHub 授权、不存任何 token、
不调用一次 GitHub API,只记两个本地布尔标记(taskwatch.starTapped /
taskwatch.starPromptDone),不夹带身份信息。
改了什么
- 会话抽屉「操作」区最后(「切换到亮色 / 暗色」之后)新增一行
「给掌上 DSH 点个星」:线性描边的星形 SVG(16px、currentColor、24 viewBox,
与同区其它pico图标同一套),行高、间距、data-act机制都与相邻项一致。
点它是真链接(<a target="_blank" rel="noopener noreferrer">)而不是
window.open—— iOS 的独立窗口里window.open常被当成非用户手势的弹窗拦掉,
真链接的跳转不会。点完顺手收起抽屉并写下starTapped。 - 首次「允许一次」提交成功后,在那张卡下面插一行轻量提示:
「如果它替你省过一次等待,点个星就行。」+「去点星」/「不用了」,8 秒后自动淡出。- 插在刚处理的那张卡后面,不做底部浮层:浮层会压住列表最后一行的按钮
(自测截图里就压住了「提交回答」);成功状态本来就把「批准 / 拒绝」那一排收
了起来,这一行只占回其中一部分高度,下面的内容几乎不动。 - 只出现一次:本次页面加载里的第二次「允许一次」不再出现;
starTapped或
starPromptDone任一存在时永久不再出现(点过星 / 说过不用了都不再打扰)。 - 文案里没有感叹号,也没有「求」字;明暗两套只用现有令牌,不写死颜色。
- 插在刚处理的那张卡后面,不做底部浮层:浮层会压住列表最后一行的按钮
lib/sw.js的CACHE升到taskwatch-v35(入口与提示都在外壳里,不升号已安装
的 PWA 看不到),docs/demo.html随npm run demo重新生成。- 新增
scripts/check-star.mjs并接进npm run check:静态断言入口文案、链接目标、
页面里没有api.github.com//user/starred与任何 GitHub API 调用、提示文案不含
「求」与两种感叹号;再用真 Chrome + CDP 在演示页里断言入口与「主题」行同高、
首次允许成功后提示出现且不压住按钮 / 输入框、第二次不出现、两个本地标记各自都
能让提示不出现、8 秒后自动消失。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.21

不用装 App。手机上把 DSH 的待批准一次点掉。
为什么改(2026-11-02)
原话:「14 天只有 26 个独立访客,不是来了不点星,是没被看见;首屏没有抓人的东西」。
拆开是两件事:
- 仓库首屏原来是一段加粗小标题、一张两列截图,然后直接进正文。访客要往下翻才知道
这个插件在手机上长什么样、点一下会发生什么 —— 没有一张图能在一屏之内说服人。 - 正文第一段之后紧跟着一句「用着还行的话,点个 ⭐ 就够了」。在还没让人看见产品之前
先要 star,读者读到的是索取而不是价值。
改了什么
- 新增
docs/assets/hero-zh-light.webp/hero-zh-dark.webp(各 1280×560,34 KB / 34 KB)。
左半是真实的手机首页截图(docs/screenshots/current/home-pending.png,露到「还有 1 件」
与三条在跑的会话),右半是排版:主标题「掌上 DSH」、副标题「不用装 App。手机上把
DSH 的待批准一次点掉。」、三条要点(首页就是待办 / 一次点击批准 / 没人的时候自动退回桌面)。
配色只用产品自己的中性灰与#4176e6,不加渐变、发光、3D 或悬浮卡片。
生成脚本scripts/make-hero.mjs:起临时 HTTP、真 Chrome + CDP、DPR2 抓帧后
经clip.scale降采样到 1280×560,并自检画布尺寸、副标题必须一行、三条要点各 ≤14 字。 - 新增三张录屏 GIF(真 Chrome + CDP 逐帧抓,非合成动画),都在
docs/assets/:
flow-approve.gif(4.1 s,240 KB)、flow-sessions.gif(6.1 s,495 KB)、
flow-theme.gif(5.7 s,566 KB),宽 720,128 色。录制脚本scripts/make-gifs.mjs
起在 390×640、DPR2 的真页面上用 CDP 真实鼠标事件(Input.dispatchMouseEvent)点击,
合成脚本scripts/build-gif.py用每帧真实时间戳当 GIF 延时。
演示数据条「演示数据 · 非真实状态」在每张片子的开头和结尾都在,脚本里断言过。 README.md首屏按新顺序重排:居中<picture>(明暗两版主图)→ 居中一行副标题 →
flat-square 徽章行(CI / npm / license)→ 三张 GIF(width="240")与一行说明
(左:待批准一次点掉 · 中:抽屉里挑会话 · 右:字号与明暗)→ 原有的「形态就是一个网页」
那段 →## 30 秒上手(步骤一字未改)。删掉了那句要 star 的引用块,以及原来的两列截图
(首页截图已被主图与 GIF 覆盖,且那张是没有品牌行的旧版)。README.en.md镜像同一结构,配英文文案。lib/page.html、lib/index.js、中继与scripts/check-*.mjs均未改动,因此
lib/sw.js的CACHE仍是taskwatch-v34。
验收
npm run check退出码 0。- 五个新资源都 ≤600 KB(上限 5 MB/文件):主图 34 KB ×2,GIF 240 / 495 / 566 KB。
- 真 Chrome 渲染自查见
_ui-review/round25/:hero-zh-light.png、hero-zh-dark.png、
flow-*-frame-first.png、flow-*-frame-mid.png。暗色主图逐像素扫过手机截图四周,
没有近白像素(>= 0xE8)—— 深色底上不出现白边;副标题对比度 8.85:1,主标题 18.08:1。 - 录屏自查抓到并修掉的问题:连接胶囊一直挂在黄色「连接中 / 重连中」(demo 页把
tick()
覆盖成空函数、也没有事件流端点,脚本按真实环境的「稳定」分支摆正两个状态位);
首页品牌行是一张破图(本地服务器没喂icon-192.png);点「切换到暗色」时抽屉被
scrollIntoView一把拽走(改成先用滚轮滑到字号那一排)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.20
dsh-in-hand v1.8.20
为什么改(2026-11-02)
原话:「首页现在的样子是没有Harness的图标的 保留图标 子代理的内容隐藏首页」。
两件事:
- 首页缺一个一眼能认出「这是谁的手机端」的标识。对话页空态有一张 64px 的虎鲸图,
首页却从头到尾只有文字。 - 首页计数行把「子代理 N」与「后台任务 / 今日轮次」并排。首页要回答的是
「有没有事在等我、有什么在跑」;子代理数量属于并行视图与抽屉里的入口,
放在概览的同一行里只会多一个不需要在这里决策的数字。
首页从来没有过品牌图:git 历史里没有任何 home-brand / homeLogo 提交,
对话页空态那张 64px 图也只存在于 renderEmpty()。所以这一条是新增,不是把删掉的
东西加回来;只是放在「恢复」这个标题下更贴近用户的原话。
改了什么
lib/page.html:<section id="home">最上方新增一行.home-brand—— 28px 的
/taskwatch/icon-192.png+ 「掌上 DSH」。整行约 40px,左对齐,文字用--fs-2+
--dim;与#homeTodo之间只隔#home原有的gap(--sp-2),不新增分隔线,
也不带动效。图标沿用对话页.empty .brand的圆角做法(同比例收到--dsw-radius-sm),
明暗两套都不加白底或描边。alt留空:旁边的文字已经说了名字,读屏再念一遍图标只是重复。lib/page.html:renderHomeCounts()删除
if (t.subagents) bits.push('子代理 ' + t.subagents)这一行。totals.subagents仍由
宿主返回,数据层不动;抽屉里的「子代理 N」入口、子代理二级页、并行视图照旧显示。lib/sw.js:CACHE升到taskwatch-v34(首页在外壳里,不升号已安装的 PWA 会继续
打开没有品牌行的旧首页)。scripts/check-home.mjs:新增四条断言 —— 品牌图存在且src指向
/taskwatch/icon-192.png;切到#session/<id>后品牌行不可见、也没有被复制进
对话页 / 子代理页;totals.subagents=2时计数行仍不含「子代理」;计数行仍含
「后台任务」与「今日」。新增scripts/shoot-home-brand.mjs(npm run shots:home-brand)。
验收
npm run check退出码 0。scripts/check-home.mjs16 条断言全过(原有 12 条 + 新增 4 条)。- 截图(真 Chrome + CDP、DPR2、390 宽):
_ui-review/round24/home-brand.png、
home-brand-dark.png、home-idle.png。自查:品牌行在标题下方 12px、高 40px,
与第一张待办卡隔 8px(--sp-2);暗色下图标为干净的圆角,没有白边 / 脏边;
390 与 360 宽都没有横向溢出,首屏仍能看到「批准一次」。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.19
v1.8.19 — VAPID 私钥定长修复
为什么改(2026-11-02)
v1.8.18 的第一次 CI 红在 check-webpush 的「私钥应是 32 字节」,重跑即绿。当时当作测试偶然性记下了,深查后不是:
ecdh.getPrivateKey() 在标量的最高字节为 0 时会省掉前导零字节,返回 31 字节(P-256 的 d 落在 [1, 2^248) 区间时,概率约 1/256)。后果有两层:
- 落盘的私钥是 31 字节,
checkVapidKeys()判它长度不对; - 更坏的一层是 ES256 签名要求定长 32 字节标量。真机撞到这一档,表现为「点了开启系统通知,订阅建不起来或者推送发不出去」,而且只在第一次订阅时发生,事后极难复现。
改了什么
lib/webpush.js:新增padScalar32()。generateVapidKeys()输出恒为 32 字节;vapidPublicFromPrivate()与checkVapidKeys()读入时同样补零 —— 已经落盘的 31 字节旧密钥不报废,补零后与原标量等价。scripts/check-webpush.mjs:新增回归用例。连跑 400 次生成,每次断言 32 字节;标量 1 的 31 字节形态补零后必须推出 P-256 生成点 G(对照 RFC 6090 A.1.1 的常数),并与 32 字节形态推出同一把钥匙。
不受影响
已生成的 ~/.dsh/taskwatch-vapid.json 继续可用,订阅关系不变,页面与 Service Worker 均未改动。
dsh-in-hand v1.8.18
dsh-in-hand v1.8.18
为什么改(2026-11-02)
原话:「将左上角三横线点击以后 工作台和操作页面往上提 关闭实验功能德州的内容剔除掉」。
上一版的会话抽屉从上往下是:标题 → 搜索 → 字号 → 会话列表 → 工作台 → 操作 → 主题。
列表占了上半屏,想开个新会话、切入口、看设备,每次都要先滑过整段会话列表 ——
这些入口是"打开抽屉就要做的事",却被放在最难够到的位置。
新的自上而下
- 标题「会话」(带会话数)
- 工作台:工作区 / 插件 / 自动化
- 操作:子代理 / 开一个新会话 / 设备 / 入口 · 当前入口 / 通知 / 并行视图 / 主题
- 搜索框
- 字号三档 + 「只看对话」
- 会话列表(运行中 / 最近)
只挪了字符串拼接顺序:data-act 的含义、事件的委托方式、所有 CSS 类名都没动。
搜索框与字号仍紧贴列表(它们作用于列表)。整块「工作台 / 操作」用一层 3% 前景色的
分组底色 + 上下发丝线和下面的「筛选 + 列表」分开 —— 前置之后动作区有 10 个入口,
给它一个明确的区块边界,读起来是一组入口而不是压住列表的一堵墙;分组标题仍是原来的
16px 上 / 8px 下节奏,明暗两套下都成立(暗色下用同一份 color-mix 规则,不另写一套)。
移除「实验功能:德州扑克」
- 抽屉里那一行
<button data-act="poker">整行删除,[data-act="poker"]在
面板 DOM 里已经不存在(scripts/check-sheet.mjs按选择器数量断言)。 act === 'poker'的分支保留(已不可达),#poker这个牌桌节点和pk*那段
旧 JS 也保留:页面加载时会无条件执行$('poker').innerHTML = …,删掉节点会
在启动时直接抛错。真要清干净,得连牌桌 DOM、样式与pk*一起删,那是另一件事。
验收
npm run check全绿(退出码 0)。新增scripts/check-sheet.mjs(已并入
npm run check):真 Chrome + CDP、DPR2、390/360 两档下断言
「工作台」「操作」两个标题都排在#sessionList之前、面板里不存在
[data-act="poker"]、搜索框仍在列表之前、字号在搜索框之后且列表之前、
点「通知」仍进通知页。lib/sw.js的CACHE升到taskwatch-v33(抽屉结构在外壳里,不升号已装的
PWA 会继续跑旧顺序)。- 截图:
_ui-review/round23/sheet-new.png、sheet-new-dark.png、sheet-390.png
(npm run shots:sheet)。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.17
dsh-in-hand v1.8.17
为什么改(2026-10-30)
v1.8.15 起「有事情等你」能推到飞书,v1.8.16 能推到微信,但两条路都要经过别人的服务器:
群里多一条、微信里多一条,还都要一个 webhook 或 SendKey。手机自己就能做的事,没必要绕。
这一版用 Web Push:浏览器把订阅交给它自己的推送服务(Chrome 的 FCM、Safari 的 APNs),
由系统把通知递到锁屏。插件只存订阅、签 VAPID、加密正文 —— 不新增任何第三方账号,
不产生费用,运行时依赖仍然为零(dependencies 依旧缺省)。
加密与签名
lib/webpush.js(新文件):RFC 8291 的aes128gcm正文加密 + RFC 8292 的 VAPID,
只用node:crypto(ECDH P-256 + HKDF-SHA256 + AES-128-GCM)。记录头为
salt(16) | rs(4)=4096 | idlen(1)=65 | keyid(65),正文超过 4079 字节自动拆成多条记录。- 与 RFC 8291 附录 A 的测试向量逐字节一致:
ecdh_secret/PRK_key/IKM/
PRK/CEK/NONCE与整条正文全部比对,所以「能解开」不是自己解自己。 - VAPID 密钥在
~/.dsh/taskwatch-vapid.json(仅当前用户可读,首次需要时才生成,
没开过系统通知的机器上不会出现这个文件)。JWT 用 ES256(dsaEncoding:'ieee-p1363'),
claims 是{aud: endpoint 的 origin, exp: now+12h, sub: 仓库地址};请求头
Authorization: vapid t=<jwt>,k=<公钥>,TTL: 300。 - 订阅存
~/.dsh/taskwatch-push.json(仅当前用户可读):{id, deviceId, endpoint, keys:{p256dh,auth}, origin, uaShort, createdAt, lastOkAt}。deviceId是设备账本里的
12 位摘要(twdcookie 派生),不是 cookie 原值;上限 20 条、单设备 5 条。 - endpoint 只收
https://(本机 / 内网的http://留给自测);推送服务回 404 / 410
就删掉这条订阅,其余失败只留一行push failed(不含地址)。
路由
POST /taskwatch/push/subscribe、POST /taskwatch/push/unsubscribe(带 id 删一条,
不带就删这台设备的全部)、POST /taskwatch/push/test、GET /taskwatch/push/state。
四条都在设备闸门内,响应只出 maskPushEndpoint() 的掩码(域名 + 尾部 6 字符);
完整 endpoint 与 keys 不进任何响应,也不进审计和日志。
通知与设置页
- 标题
N 件事等你,正文沿用既有摘要口径(工具名 + 说明前 30 字,多条列 3 条),
tag固定taskwatch-pending(同一批不叠成一列),点通知回订阅时的origin + #home。 - SW 新增
push→showNotification、notificationclick→ 已有窗口 focus 并导航、
没有才openWindow;CACHE升到taskwatch-v32—— 装到桌面的 PWA 用的是旧 SW,
不升号它根本不会响应 push。 - 设置页新增「系统通知」区块:状态(不支持 / 未开启 / 已开启 N 台设备)、
「开启系统通知」「发一条测试通知」「关闭此设备」。iOS 非主屏幕打开时只给
「分享 → 添加到主屏幕 → 从主屏幕打开」三步,不显示开启按钮(iOS 16.4+ 才支持);
权限被拒时给恢复路径,不再调requestPermission。
测试
scripts/check-webpush.mjs(新,13 组):RFC 向量、记录头结构、独立解密器往返
(空 / 短 / 中文 / 4096 / 4097 字节)、本地假推送服务断言 Authorization / TTL /
Content-Encoding 并解密正文、endpoint 校验、410 删订阅、文件权限与 20 / 5 上限、
三件合并成一条、失败文案不含地址、页面与 SW 静态契约、不新增依赖。npm run check 全绿。
未验证:真实手机(安卓 / iOS)收到通知,以及真实 FCM / APNs 的互通。这两条只能在
真机上点一次「发一条测试通知」才算走通。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.16
dsh-in-hand v1.8.16
为什么改(2026-10-30)
v1.8.15 的通知主通道是飞书,而「有事情等你」最常发生在手机上。个人微信没有官方 API,
所以这一版把 Server酱 的公众号消息接成一条正经通道:用户只需要一个 SendKey。飞书
通道原样保留,行为不改(这次只加一种通道类型)。
通道与请求形状
- 新类型
serverchan:用户只粘贴 SendKey(^[A-Za-z0-9_-]{10,64}$,不假设 32 位固定
长度),内部拼https://sctapi.ftqq.com/<SENDKEY>.send,POST
application/x-www-form-urlencoded,只有title与desp两个字段。 title与飞书标题同口径(1 件事等你/3 件事等你,测试消息取首行冒号前那段),
超过 32 字自己截断并补…—— 微信通知栏会截标题,不能交给它。desp仍是摘要:工具名 + 原因前 30 字,多条只列前 3 条 + 「还有 N 件」;不含完整
命令、路径与会话标题以外的内容。- 超时 8 秒、最多 3 次(退避 1s / 3s);失败审计只写一行
notify failed,不含 key。 - 只存 SendKey,不存完整 URL;对外只出
SCT…abcd(首 3 + 末 4)。配置里若出现
type:'serverchan'+url,一律忽略 URL、只认key;只有 URL 没有 key 的条目直接丢弃。
设置页
- 类型从两项变三项:
飞书机器人/Server酱(推到微信)/通用 webhook。 - 选 Server酱 时输入框 label 改「SendKey」,下方一行小字给扫码入口
(在 sct.ftqq.com 微信扫码登录后可复制 SendKey)与「摘要会经过 Server酱」的克制说明,
不弹窗、不用红字。 - 列表徽标「Server酱」、掩码 SendKey、启用 / 关闭 / 测试 / 删除,与飞书一致;
「会收到什么」补一行「Server酱 会在微信里以公众号消息形式出现」。 - 页脚写清:个人微信没有官方 API,这条走 Server酱 的公众号消息;要完全私有可走企业微信
应用消息(一句提及,不展开)。
测试
scripts/check-notify.mjs 追加 5 组:请求路径 /<key>.send + form + 只有 title/desp +
标题 32 字截断;SendKey 校验(abc / SCT123 被拒,SCT + 12 位通过);页面与接口都不
出现完整 SendKey;配置里忽略 url 只认 key;关闭后零请求。npm run check 全绿。
未验证:真实 Server酱 发送(仓库里没有真实 SendKey)。用户自己粘贴 SendKey 后点
「发一条测试消息」才算这条路真正走通。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
dsh-in-hand v1.8.15
dsh-in-hand v1.8.15
为什么改(2026-10-30)
手机页要你主动看。开会、走路、睡前,你不打开就不知道有东西卡在那儿 —— 而
approval/request 与 user-questions/request 是有超时的,等看到时可能已经退回桌面。
这一版让宿主在有事情等你时往外推一条:飞书群机器人优先,另外收任意 JSON / 表单
webhook(Server 酱这一类)。推送是提醒,不是第二套审批入口:点开还是回到手机页处理。
触发与去重
- 两个闸的
onPending各多一次notifyPoke();onRelease也 poke 一次,用来在
「等待清空」时重新武装。 - 1.2 秒合并窗口(
notify.debounceMs,200–10000 钳制):窗口内多次 poke 只发一条,
同时出现三件就是3 件事等你,不会连发三条。 - 一次等待期间只推一条:
armed=false后不再排定时器,直到待办清空才复位。切换开关 /
删除通道只 poke 不重新武装(避免反复刷屏);新增或启用通道会refresh()重新武装,
让已经在等的事能送到刚配好的通道。 - 没有启用通道时完全静默:不排定时器、不发请求、不写日志、不写审计。
正文口径
- 单条:
1 件事等你:<工具名> · <说明前 30 字>…;提问写成提问 · <标题>。 - 多条:标题
N 件事等你,只列前 3 条,末尾还有 N 件。 - 不含完整命令、对话正文、目录路径、会话 id —— 用一条带
rm -rf的 reason 正面断言
它不会被带出去(check-notify.mjs第一组)。
通道与发送
channels只存在于~/.dsh/taskwatch-notify.json({version:1, channels:[…]}),
最多 8 条;插件配置里的notify.channels会被明确忽略并 warn —— 地址只能由用户
在手机上粘贴,这一条优先于「配置里也能写 channels」的字面要求。- 写盘后立刻收紧权限:POSIX
0600;Windowsicacls <file> /inheritance:r /grant:r <user>:F(best-effort,失败只 warn,不阻断保存)。 - 发送:超时 8 秒,最多 3 次,退避 1s / 3s;只有 2xx 算成功。失败只写一行审计
{kind:'notify', outcome:'failed', error:'notify failed', status},不含地址。 - 载荷:飞书
{"msg_type":"text","content":{"text":…}};通用通道{title, content}
JSON,或format:'form'时的title+desp表单编码。
设置页(#notify)
- 抽屉新增「通知」入口;整屏页依次是:现在会不会推(肯定句横幅,含「当前不会推送」)→
通道列表(掩码地址、开关、发一条测试消息、两步确认删除)→ 会收到什么 → 添加通道。 - 页面没有任何能展开完整地址的入口:列表只画宿主算好的
masked,响应里也没有url
字段;唯一的完整地址出现在添加时的输入框,保存成功即清空。 - 「发一条测试消息」是真发,结果写「成功 · HTTP 200 · 0.4 秒」或失败原因 / HTTP 码;
失败也是一次200的结果汇报,不是服务器错误。 - 地址只收
https://;http://仅127.0.0.1/10./192.168./172.16–31.;
带 userinfo 的地址直接拒。错误文案固定,不回显地址。
测试与截图
- 新增
scripts/check-notify.mjs(挂进npm run check),七组:真实假 webhook 服务器
收一条审批并断言形状与口径;三件合并成一条;全关时零请求;页面与响应的掩码断言;
公网http://被拒;500 时不抛错、审计写notify failed、回执无 URL;通知文件权限
(Windows 断言icacls调用,POSIX 断言0600)。另含正文 / 校验 / 载荷 / 重试 /
静默 / 去重的纯函数用例,以及「日志里不出现地址」的总闸。 scripts/check-bundle.mjs:/taskwatch/notify归入只读、四条写路由归入非对话写,
对话路由仍是 14 条。scripts/make-demo.mjs:演示页带一个已配置的假飞书通道(只给掩码)和一个关闭的通用
通道,format:'form';npm run demo已重生成。lib/sw.js缓存号taskwatch-v29→v30;不升号时已安装的 PWA 会继续跑没有通知
入口的旧外壳。- 新截图
docs/screenshots/current/notify.png(另有添加表单 / 测试成功 / 暗色三张在
本地评审目录)。
兼容
- 未改
lib/client.js、审批 / 提问失败的语义、票据与设备逻辑;DSH_TASKWATCH_ROOTS
不变;没有新增依赖。 notify.enabled:false关掉整块功能;默认true,但没有通道时行为与之前完全一致。package.json版本 1.8.15,新增shots:notify。
未验证
- 真实飞书群机器人的消息外观(包括手机端换行与「还有 N 件」的显示)—— 没有可用凭据,
只用假服务器校验了请求体形状。 - 真实 Server 酱(form)转发到微信的效果;
title/desp字段按公开文档实现。 - Windows 上
icacls收紧后的实际 ACL:只断言了命令参数,没有在另一台机器上复核。
Install: dsh plugin --profile web add dsh-in-hand
Full changelog: https://github.com/dingchenhui0618-arch/dsh-in-hand/blob/main/CHANGELOG.md
