dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
English · 简体中文
不用装 App。手机上把 DSH 的待批准一次点掉。
左:待批准一次点掉 · 中:抽屉里挑会话 · 右:字号与明暗
形态就是一个网页:手机浏览器打开即可。同一个 Wi-Fi 下走局域网直连,人在外面走经中继的公网入口,两条路在页面右上角随时切。
dsh plugin --profile web add dsh-in-handdsh web)—— 插件行来自 profile 的 bundle 图,不重启不生效。http://<电脑的内网 IP>:3099/taskwatch,人在外面用中继的 HTTPS 域名;首次进配对页输一次 6 位码。它起初只是一份只读的任务监控页——让你在手机上瞄一眼 DSH 有几个任务在跑、有没有事在等你;后来接上了 DSH 自己的会话运行时,长成了手机浏览器里的 DSH 客户端:左上角会话列表、右上角新建对话、输入框上方是当前会话的实时指标(轮次 / 步数 / token/s / Token 消耗 / 缓存命中率 / 上下文占用),可以直接对话、逐字跟随输出、随时打断;再往后,手机上还能一次性批准 / 拒绝一次工具调用、回答选项式提问。
没有 App 可装:不需要原生代码、不需要打包、不需要上架。手机上进一次「添加到主屏幕」,之后它就是一个图标;页面按请求读盘、壳由 Service Worker 换版,所以代码更新后下一次打开就会跟上。
以前打开是一段对话——你得先翻一眼才知道自己为什么被叫来。现在打开是概览:最上面一行是虎鲸标 + 「掌上 DSH」(v1.8.20),紧接着就是「在等你 N 件」,下面第一张卡能直接点「批准一次」;再往下是「在跑」的三条(跑到第几步、跑了多久),一行后台任务 / 今日轮次,最后是「继续上次会话」和「全部会话」。对话退到第二层,点进去才看正文,左上角有明确的「返回首页」,系统返回手势也回得去(地址是 #session/<id>,刷新不丢)。
两件事刻意没做:没有新写一套审批逻辑(待办卡复用 v1.8.0 的按钮、二次确认与倒计时,只是多了一个放置位置),断网时不清空首页(继续显示上次读到的状态,底下补一条说明,而不是假装"什么都没有在等你")。
首页「在跑」只有三条,开四五个会话就得一页页翻,失败的那条尤其容易被埋掉。v1.8.10 加了整屏的并行视图(首页「在跑」右上角「全部」,或抽屉里的「并行视图」),排序写死为一句话:
需要你(待批准 / 待回答) → 失败 → 运行中 → 已完成最近
同组按最后活动时间倒序。这条排序不是"红的最显眼",而是分组本身:即使把颜色全去掉,失败仍然在运行中与空闲之上,滑不到它才奇怪。每行只留四件事——标题(最多两行)、状态徽标、一句最新动作(工具名 · 第几步)、已运行 / 结束时长;失败行多一句 ≤60 字的原因和「查看」。有子代理的会话下面折一行「子代理 N」,展开就能看到它们各自的状态,不另开一层页面。
更新沿用现有的 5 秒轮询与 SSE:只按会话 id 复用行、改变化了的那几个字段,不整屏重建;页面不可见时时长计时与轮询一起暂停。390 宽首屏能看到 5 行,无横向溢出,标题两行截断。
以前手机只能看「有 N 件事在等你」,真正批准还得回桌面。现在 approval/request 是 waterfall,插件可以认领它:手机页在线时,待批准卡片会多出「批准一次」和「拒绝」两个按钮。批准会弹一次「确认允许一次?」,拒绝不弹;倒计时结束或页面切走就自动退回桌面。
四条失败语义是这次的核心,功能本身反而简单:
next()。判据只看手机页最近 15 秒有没有轮询或审批 SSE;桌面 GUI 也打 /taskwatch/data,但不带 view=phone,故意不算在线,免得桌面开着面板却让审批空等。next()。approvalTimeoutMs 默认 120000,允许 10000–600000;越界取边界值并在启动时 warn。req.signal abort → 立刻交还,因为桌面端已经先处理了。allowed-once / rejected,不做 always-allow;手机决定只成功一次,重复提交 409,过期或已交还 410。这张卡只在「手机在线且这次请求可认领」时出现;离线、超时、桌面先处理都会回到原来的「请在桌面端批准」。
P0 的第一类是「放不放行」,第二类是「选哪一个」。v1.8.3 把 user-questions/request
也接上了:它和 approval/request 一样是 waterfall,手机页在线时插件认领它,把题目
变成一张可点的卡。
label 一行、description 第二行小字),点一下即选中,
但仍要再点「提交回答」—— 防的是误触直接交卷。multiSelect:true 时按钮可以多选,底部同一个「提交回答」。intent.kind === 'plan-review' 时标题带「计划审批」徽标,intent.approve
对应的那个选项用主色强调(它不是"更推荐",而是这次提问里唯一的放行项)。custom 字段发给宿主。mm:ss 倒计时;到 0 显示「已退回桌面」。提交成功后
卡片收起显示「已提交」;宿主回 409 / 410 时显示「已由桌面处理」;网络失败则保留
已选项与自定义草稿、放开按钮让用户重试。失败语义逐条照抄审批,没有第二套机制:没有手机在线 → 立刻 next();超时(同一个
approvalTimeoutMs,默认 120000)→ next() 并审计 by:'timeout';request.signal
abort → 立刻交还;任何内部异常 → try/catch → next()。审计同样只记
{at, session, kind:'question', title, outcome, by, selectedCount, custom} ——
不记答案原文。
这张卡在三个地方都能答,且是同一份结构:首页待办区、任务详情抽屉、 会话页输入框上方的问答条(只显示当前会话的提问)。
A phone client for DeepSeek Harness with no app to install: it reuses the desktop GUI's design tokens and adds a live metrics row above the composer.
抽屉里多了一个「设备」:列出最近用过这个入口的手机与电脑,每行只有摘要名(系统 · 浏览器)、 最近活跃、从局域网还是公网进来,以及一个「撤销」。当前这台带「这台设备」标记;列表里的 id 是 12 位摘要,不是设备 cookie 原值。
撤销当前这台要过一道二次确认,说清后果;确认后本端立即翻到「此设备已被撤销,请重新
配对」,下一次请求由宿主回 401:
手机能批准之后,最危险的那一类不该和下一条普通改文案走同一个动作。v1.8.8 把判定放在
宿主侧:approval/request 到达时,assessRisk({ toolName, reason, detail }) 用
highRiskPatterns 扫一遍宿主手里那几段可见说明,命中返回 'high',页面只收到
pending[].risk。页面里没有任何按工具名或命令猜风险的正则 —— 它只负责画红色
「高危」徽标与第二步。
点「批准一次」不会直接发请求,而是在卡片内部展开确认层:列出工具与原因,写明
「确认执行?此操作不可撤销」,只有「确认执行」(实心危险色)才真的
POST /taskwatch/approve。展开期间卡片其余按钮禁用;「取消」、Esc、点遮罩都只收层、
不发请求。拒绝路径不加确认,直接发。
匹配对象是 toolName + reason + displayReason —— 不是完整命令:我们本来就不存命令
全文(审计也不记),这是一道按可见信息加的门槛,不是命令级沙箱。默认模式 19 条:
| 类别 | 默认模式 |
|---|---|
| 命令类 | rm -rf、Remove-Item、del /f、format、mkfs、shutdown、Stop-Process、taskkill /F、DROP TABLE、TRUNCATE、git push --force、npm publish、git tag -d、git reset --hard |
| 路径类 | .ssh、.dsh、C:\Windows、/etc、.git(作为被操作对象时;.gitignore 不算 .git) |
highRiskPatterns 可以增删或整组替换(给了就替换默认值;最多 64 条、每条 ≤120 字符,
越界会被裁剪并在启动时 warn)。riskConfirm:false 只保留徽标、点击直接提交 ——
关掉就失去这层保护。
手机页要你主动看;会议里、路上、睡前,你不看就不知道有东西卡在那儿。v1.8.15 起宿主在 有事情等你时往外推一条:飞书群机器人,或者任何收 JSON / 表单的 webhook。v1.8.16 把 个人微信接成主通道:用 Server酱 把同一句摘要推到微信。配置全在手机上完成。
推到微信(三步,v1.8.16)
1. 微信扫码登录 sct.ftqq.com。 浏览器打开 https://sct.ftqq.com,用微信扫码登录 ——
这一步就是把「Server酱」这个公众号接到你的微信上。
2. 复制 SendKey。 登录后在页面上找到 SendKey(SCT 开头的一串字母数字),点复制。
3. 在手机上粘贴,点测试。 抽屉 →「通知」→ 类型选「Server酱(推到微信)」→ 粘 SendKey →「保存通道」→「发一条测试消息」。微信里收到公众号消息就算通了;失败会写 HTTP 码或原因,不会回显 SendKey。
推到飞书(同样四步)
1. 在飞书里拿到机器人地址。 建一个群(只有自己也行)→ 群设置 → 群机器人 → 添加
自定义机器人 → 复制它给的 webhook 地址(形如
https://open.feishu.cn/open-apis/bot/v2/hook/…)。
2. 在手机上粘进设置页。 抽屉 →「通知」→ 类型选「飞书机器人」→ 粘地址 →「保存通道」。
3. 点「发一条测试消息」。 走的是真实发送,成功会写「成功 · HTTP 200 · 0.4 秒」; 失败写 HTTP 码或原因,不回显地址。发不出去时不用猜,这一步就是体检。
4. 然后它只在真的有事时响。 批准请求与选项式提问出现时推一条;同一时间多件合并成
一条(3 件事等你:…),事情没处理完不会反复推。
飞书地址与 SendKey 都只存在这台机器上:~/.dsh/taskwatch-notify.json,写完立刻收紧成
仅当前用户可读(POSIX 0600;Windows 用 icacls 去掉继承、只授权当前用户,是
best-effort)。插件配置里不收这些凭据,仓库、日志、页面响应里都没有完整值 —— 页面只
显示掩码:飞书是 https://open.feishu.cn/…/000…(已保存),Server酱 是 SCT…abcd。文件里
也只存 SendKey,不存拼好的 https://sctapi.ftqq.com/<key>.send。
正文口径是刻意的:1 件事等你:pwsh · 删除 build/cache 下的… —— 工具名 + 一句说明的
前 30 字,多条只列前 3 条并写「还有 N 件」。不会有完整命令、对话正文、目录路径。推送
本身也可能失败,所以发送超时 8 秒、最多 3 次(退避 1s / 3s),失败只在审计里留一行
notify failed,不影响手机页。
地址校验两条:只接受 https://;http:// 仅放行 127.0.0.1 与 10. / 192.168. /
172.16–31. 这类内网地址(方便自己起个转发)。所有通道都关掉时这个模块完全静默:
不排队、不发请求、不写日志。
微信这条路要说清楚: 个人微信没有官方 API —— 这条走的是 Server酱 的公众号消息, 摘要(工具名 + 说明前 30 字)会经过它。如果要完全私有,另一条路是企业微信的应用 消息(自建应用 / 群机器人,落点自己控制),这里不展开。
飞书与微信都要经过别人的服务器。手机自己就能做的事,不必绕:v1.8.17 用 Web Push —— 浏览器把订阅交给它自己的推送服务(Chrome 的 FCM、Safari 的 APNs),由系统把通知递到 锁屏。不需要任何第三方账号,不产生费用,运行时依赖依然为零。
三步开起来(安卓 / 桌面)
1. 打开开关。 抽屉 →「通知」→「系统通知」→「开启系统通知」,浏览器会问一次通知权限。
2. 点「发一条测试通知」。 它会走真实的加密与签名,锁屏上出现「测试通知」就算通了;
失败会写 HTTP 码(410 表示订阅已失效,会自动删掉,重新开启即可)。
3. 之后只在真有事情时响。 标题是 N 件事等你,正文就是上面那条摘要;同一时间多件
合并成一条,tag 固定,通知栏里不会叠成一列。点通知回到这个入口的首页。
iPhone 要先「添加到主屏幕」:iOS 16.4 起才支持网页推送,而且只对从主屏幕打开的 站点生效。Safari 底部「分享」→「添加到主屏幕」→ 从主屏幕打开掌上 DSH,再回到这一页就是 「开启系统通知」。用 Safari 直接打开时会看到这三步说明,而不是一个点了没反应的按钮。 更早的 iOS 收不到,页面里的待办照常。
三条边界是刻意的
~/.dsh/taskwatch-push.json(仅当前用户可读),每条记
endpoint 与 keys.p256dh / auth;页面与接口只看到 https://fcm.googleapis.com/…/AbCdEf
这种掩码(域名 + 尾部 6 字符)。完整 endpoint 与密钥不进响应、不进审计、不进日志。twd cookie 派生),
不是 cookie 原值;撤销的设备不再推送。上限 20 条、单设备 5 条 ——「关闭此设备」删掉
这台手机的全部订阅,别的设备不受影响。aes128gcm 加密(ECDH P-256 +
HKDF-SHA256 + AES-128-GCM,只用 node:crypto),scripts/check-webpush.mjs 拿
RFC 附录 A 的测试向量逐字节比对,再用一条独立实现解密器跑往返(含中文与 4096 字节
边界);假推送服务会检查 Authorization: vapid …、TTL: 300、Content-Encoding: aes128gcm 并把正文解回标题与摘要。VAPID 密钥在 ~/.dsh/taskwatch-vapid.json(仅当前用户可读,第一次需要时才生成),
浏览器订阅绑的就是它的公钥;删掉这个文件等于让所有设备重新开启一次。
要说清楚的两件事:通知经过浏览器的推送服务(Chrome → FCM / Safari → APNs), 所以「不过第三方」指的是不经过额外的机器人或中转账号,不是不经过系统推送;通知的正文 只有摘要(工具名 + 说明前 30 字),不含命令、路径与对话内容。
拍屏幕、白板、错误码,比打字快一个数量级。相机键背后就是
<input type="file" accept="image/*" capture="environment">:手机上进系统相机,
桌面上退化成选图;没有 getUserMedia,也就没有权限弹窗和前后摄像头的状态机。
选中后立刻显示缩略图、大小和取消(×)。压缩在客户端做完再上传:长边压到 1600、
JPEG 质量 0.82;仍超过 2.5 MB 就降到 0.7 再试一次,还超就明说「图片太大」,不悄悄
上传一张 8 MB 的图去等超时。方向靠 createImageBitmap(..., { imageOrientation:'from-image' }),
竖拍的照片不会横躺(EXIF Orientation=6 在 scripts/check-camera.mjs 里有断言)。
上传走的是现有附件那条路:同一个 /taskwatch/chat/upload、同一套进度与失败重试,
没有新增接口、存储或后端目录。照片不进云端 —— 它先落在本机的收件箱里,发给哪个会话
由你在这一页决定。附件与相机两个键都是 44px,390 宽实测输入框仍有 210px,页面不横向溢出。
真机未验证的部分写在这里:iOS Safari 上
capture的入口形态、HEIC 照片的输入, 以及弱网下 2.5 MB 图的等待体感,都只在 Chrome 里模拟过。
真实会话里,连续的工具调用会折叠成一行「N 个步骤」,失败的那一步永不隐藏;助手回复支持轻量 markdown(标题、列表、行内代码、代码块),过宽的代码块提示可以横滑。
会话抽屉、状态卡片、输入框等全部走客户端同一套 design token,亮暗两套都是真值。抽屉里工作台 / 操作在前(开新会话、切入口、看设备都不用先滑过会话列表),搜索与字号三档紧贴在最下面的会话列表上方:
演示页由
docs/demo.html渲染——那个文件是scripts/make-demo.mjs从lib/page.html生成的(npm run demo重新生成),你可以直接用浏览器打开它,点着看,不需要装任何东西,也不会发出网络请求。契约测试会校验它与当前页面构建结果一致,防止它悄悄停在旧界面上。
抽屉「操作」区的最后一行是 「给掌上 DSH 点个星」。点它不会直接跳走,而是在抽屉里打开 GitHub 设备码授权面板:宿主申请一个 8 位码,你复制到 GitHub、用自己的账号确认,宿主才代你 PUT /user/starred。页面本身不接触 token,也不出现任何 GitHub API 域名;token 只在宿主内存里停留几秒,点完即弃。
确认成功后面板变成「已点星 ✓」,只留一个关闭按钮:
首次「允许一次」提交成功后,页面还会在那张卡下面出现一次轻量提示(8 秒淡出);点过星或选择「不用了」之后就不再出现。
上面首页与审批这些图是 v1.8.2 发布时用 npm run shots 重拍的当前界面(真 Chrome + CDP、DPR2、390 宽;另有一张 360 宽),数据来自演示会话,图上保留「演示数据 · 非真实状态」的标记。首页 4 张(待办 / 深色 / 无待办 / 360)与「返回首页」1 张是那一版新加的;审批 4 张的审批状态是注入的,未在真实手机上点过。设备 2 张由 scripts/shoot-devices.mjs 在 v1.8.7 重拍;高危 2 张由 npm run shots:risk 在 v1.8.8 重拍(高清原图在 _ui-review/round16/,仓库里放的是同一张图)。相机 3 张由 npm run shots:camera 在 v1.8.9 重拍,数据是注入的演示会话与一张现画的报错屏(高清原图在 _ui-review/round17/)。会话抽屉由 npm run shots:sheet 在 v1.8.18 重拍(高清原图在 _ui-review/round23/)。首页品牌行(v1.8.20)由 npm run shots:home-brand 重拍,高清原图在 _ui-review/round24/;上面那组首页图仍是最初 v1.8.2 的界面。历史版本截图仍留在 docs/screenshots/ 里,清单与用途见 docs/screenshots/README.md。
dsh-ui 围栏(只做子集)DSH 桌面端装了 dsh-genui 之后,助手回复里可以夹一个 dsh-ui 围栏(JSON 规格),桌面浏览器会把它画成可交互组件。手机端现在也能渲染其中与「确认」有关的那部分——不用回电脑点表单。
v1.8.25 起,本插件认领 dsh-ui 围栏,支持 6 种组件:
| 组件 | 手机上的样子 |
|---|---|
text(size: display / h1-h3 / body / muted / caption) | 标题与说明文字 |
radio | 单选项,整行可点 |
checkbox | 多选项,整行可点 |
input | 单行输入框 |
textarea | 多行输入框(Ctrl/Cmd+Enter 也能提交) |
submit | 汇总按钮,显示「已填 2/3」进度 |
radio.group / checkbox.group 只本地记录,不逐次往返;点 submit 时汇总成一条消息发回模型,格式与 dsh-genui 完全一致([genui-action] … + Component data: {answers, fields, total, answered}),所以模型侧不需要为手机端做任何适配。submit 没写 action 就渲染成禁用态并给一行提示;groups 里的成员没填完时也禁用,不让「看着能点、点了没反应」。JSON 解析失败、未知 type、超深嵌套都安全降级——能画的照画,画不了的丢弃,整段 JSON 一字不损地留在代码块里,并在上面标一行「dsh-ui 围栏解析失败,按代码块显示」。不做什么:判卷(answer / explanation / quiz)、图表、mermaid、table、tabs、accordion 一律不渲染(节点直接丢弃)。手机是窄屏、页面是零依赖单文件,这几块不划算——桌面端继续用 dsh-genui 全量渲染。也不做 eval:模型给的字符串只走 textContent,拼不进 HTML。
浏览器地址栏里的那一页,加到主屏幕之后就变成一个有图标、独立全屏的入口:点开不再有地址栏,切换手势、状态栏样式都按 App 走。技术上它是 PWA,不是原生 App,也不经过任何应用商店。
apple-touch-icon,名字来自 apple-mobile-web-app-title(现在是「掌上 DSH」)。display: standalone、start_url / scope 都是 /taskwatch,装完是独立窗口,不是带地址栏的快捷方式。图标复用页面同一个 /taskwatch/icon-192.png。两条硬前提,先说清楚免得白试:
http://<电脑的内网 IP>:3099/taskwatch 页面照常能用,但装不成桌面图标、也没有离线壳 —— 想装就走公网中继那条 HTTPS 入口。反代与隧道属于部署细节,本仓库只负责 DSH 这一侧:域名 + nginx +
ssh -R可以, Tailscale、Cloudflare Tunnel、ngrok 之类同样可以(它们都能免费给出 HTTPS)。 自己的专属入口链接里带访问令牌,等于完整控制权,绝不要提交到本仓库。
家里一台、公司一台、会议室那台测试机——以前每换一台就要重新输地址、在手机里再配一次。现在手机页里有一份入口清单:抽屉里的「入口 · 当前名」点开就是切换器,每行一个名字 + 地址,点「打开」就在新页面跳到那台机器的 /taskwatch。
tw / twd),浏览器只会把 cookie 发给发下它的那个地址。所以切过去那台机器自己就认你,不需要在手机里再存一份令牌。{label, url} 两项,写在 localStorage['tw:entries'];不存 token、cookie、配对码。拿到这份清单最多知道几个内网地址。?、# 丢掉,javascript: / data: / file: 一律拒绝。DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 danger-full-access。把整个 GUI
暴露到公网,等于把一台能执行任意命令的机器的入口挂出去。
这个插件的做法是不暴露 GUI,另开一组窄接口:
/taskwatch/chat/* 把 DSH 自己的会话运行时接到手机上 —— 直接对话、
逐字收回复、随时停止、切换会话与模型、调整排队顺序。写入口只有 6 条,全部列在下面。采集面完全通过 ctx.get(...) 软读取服务,任何一个服务不存在都只会让对应的那一块
留空,不会让插件挂载失败。对话那一组同理:拿不到 sessionController 时只让这一个
功能返回明确错误,不影响只读页面。
| 区块 | 内容 |
|---|---|
| 需要你介入 | 等待中的 approval/request 与 user-questions/request |
| 会话 | 运行/空闲、最后活动时间、该会话在跑的后台任务数、preset、是否仅日志 |
| 会话详情 | 点开后:目标、待介入事项、属于它的后台任务与子代理、最近对话 |
| 目标 | phase、轮次 started/max、activation、阻塞原因 |
| 后台任务 | id、label(多行命令已折成单行)、kind、status、生命周期时长、属主会话 |
| 子代理树 | 层级、深度、父会话、activity、mode |
| 工作流 | 当前 phase、日志条数、已运行时长 |
| 采集诊断 | 采集过程中单项失败的原因(不静默吞掉) |
读(GET):
| 路径 | 类型 | 说明 |
|---|---|---|
/taskwatch | HTML | 手机页:以对话为主,顶部状态条点开是任务详情 |
/taskwatch/data | JSON | 全部状态的快照,带 ETag,未变化回 304。pending[] 里两种待办靠 kind 区分:审批(approval)与选项式提问(question,带 questions[] / options[] / multiSelect / intent) |
/taskwatch/session?id=&limit= | JSON | 单个会话的最近对话(limit 上限 60,默认 20) |
/taskwatch/file?id= · ?path= · ?sessionId= · ?info=1 | 二进制 / JSON | 交付文件:附件走 id;绝对路径走白名单根,带 sessionId 时改走「本会话引用过的绝对路径」(单个文件 ≤50 MB);info=1 只回元数据 |
/taskwatch/chat/sessions | JSON | 可对话的会话列表(不含子代理会话) |
/taskwatch/chat/workspaces | JSON | 新建对话可选的工作区(现有会话的 cwd + 固定候选 + 环境变量追加;只列真实存在的目录) |
/taskwatch/chat/models | JSON | 可选模型目录 |
/taskwatch/chat/titles?ids= | JSON | 批量补会话标题(最多 20 个,宿主缓存 10 分钟) |
/taskwatch/chat/page?... | JSON | 往更早翻会话历史 |
/taskwatch/chat/stream?sessionId= | SSE | 跟随一个会话:开场快照 + 增量事件 + 逐字输出 |
/taskwatch/chat/queue?sessionId= | JSON | 该会话未领取的消息:items(下一轮队列,可重排)与 steering(已插到本回合下一步) |
/taskwatch/decisions | JSON | 当前手机端可决定的审批清单(内存里的活动 decision;approveFromPhone:false 时为空) |
/taskwatch/devices | JSON | 设备账本(v1.8.7):每台设备只有可读摘要名、首次/最近活跃、入口、配对次数、是否已撤销,外加 current 标记;不回设备 cookie 原值、IP 或完整 UA |
/taskwatch/notify | JSON | 通知通道(v1.8.15):每个通道只有 masked 掩码地址、类型 / 格式 / 开关,外加 anyEnabled 与上限。完整地址既不在这个响应里,也不在页面里 |
/taskwatch/push/state | JSON | 系统通知(v1.8.17):vapid 公钥(页面 subscribe() 用)、devices / total / mine 台数、上限,以及每条订阅的掩码(https://fcm.googleapis.com/…/AbCdEf)、uaShort 与最近成功时间。完整 endpoint 与 keys 不进任何响应 |
/taskwatch/approvals/stream | SSE | 审批事件流:approval 事件推送 pending / settled;页面可见时保持,隐藏时关闭 |
/taskwatch/entries | JSON | 双入口清单:{entries:[{kind:'remote'|'lan',label,url,current}]};没配 publicUrl 时只有局域网一条 |
/taskwatch/manifest.webmanifest | JSON | PWA manifest |
/taskwatch/sw.js | JS | service worker |
/taskwatch/icon-192.png · /taskwatch/icon-512.png | PNG | 图标 |
写(POST)—— 对话入口、一条换路票据、审批 / 回答各一条,外加一条只改内存账本的设备撤销:
| 路径 | 请求体 | 说明 |
|---|---|---|
/taskwatch/chat/upload | 原始二进制(sessionId/kind/name/mime 查询参数) | 导入文件或图片;图片由宿主校验,文件按会话签发 receipt |
/taskwatch/chat/send | {sessionId,text,attachments?,mode?,now?,interrupt?,timeZone?} | 给会话发文字和已上传附件;now:true 走宿主原生 steer 插队(见下) |
/taskwatch/chat/cancel | {sessionId} | 停止当前这一轮 |
/taskwatch/chat/create | {} 或 {workspace} | 新建会话;workspace 必须命中 /taskwatch/chat/workspaces 列出的路径 |
/taskwatch/chat/model | {sessionId,provider,model} | 切换该会话使用的模型 |
/taskwatch/chat/queue/reorder | {sessionId,ids:[...]} | 重排下一轮队列;ids 必须是当前队列的完整 id 列表 |
/taskwatch/ticket | {to:'remote'|'lan'} | 签发 60 秒的 v2 HMAC 换路票据(v2.<exp>.<nonce>.<hmac>),宿主与中继用同一密钥互认;只在内存里记已用的 nonce,不碰会话 / 文件 / 配置。没配 publicUrl 时远端请求回 409 no-public-entry |
/taskwatch/approve | {id, decision:'allow'|'reject'} | 手机端一次性审批:只把一次已认领的 approval/request resolve 成 allowed-once / rejected。成功 200;参数错 400;未知 id 404;重复提交 409;过期 / 已交还 410。不做 always-allow |
/taskwatch/answer | {id, answers:[{id, selected:string[], custom?}]} | 手机端回答选项式提问:把一次已认领的 user-questions/request resolve 成 {answers}。必须覆盖请求里所有题目、selected 只能取该题 options[].label、单选≤1 项、custom≤2000 字;不合规 400 并带 field(如 answers[1].selected)。未知 id 404;重复提交 409;过期 / 已交还 410 |
/taskwatch/devices/revoke | {deviceId} | 撤销一台设备(v1.8.7):把账本记录标成 revoked 并清空它的配对记录;被撤销设备的下一次请求 401({ok:false, code:'device-revoked', revoked:true})。deviceId 是列表里的 12 位摘要,不是 cookie 原值。撤销自己同样 200(self:true),随后本端也 401 |
/taskwatch/notify/add | {type:'feishu'|'webhook', url, format?:'json'|'form'} | 保存一个通知通道(v1.8.15):写本机 ~/.dsh/taskwatch-notify.json(权限收到仅当前用户),回显只有掩码。https:// 以外仅放行本机 / 内网 http://,公网 http:// 400 且错误文案不回显地址 |
/taskwatch/notify/toggle | {id, enabled} | 开关一个通道;没有启用通道时发送模块完全静默 |
/taskwatch/notify/remove | {id} | 删除一个通道(地址丢了只能回飞书重新拿,所以页面用两步确认) |
/taskwatch/notify/test | {id} | 真发一条测试消息,回 {ok,status,attempts,error,tookMs}(失败也是 200 的结果汇报)。目标地址只从本机文件按 id 取,请求方不能指定 URL,所以这条路由不是通用代理 |
/taskwatch/push/subscribe | {endpoint, keys:{p256dh,auth}, origin} | 保存这台设备的一条系统通知订阅(v1.8.17):写本机 ~/.dsh/taskwatch-push.json(权限收到仅当前用户),响应只回掩码与台数。endpoint 只收 https://(本机 / 内网 http:// 仅用于自测),keys.p256dh 必须 65 字节、auth 16 字节,否则 400。deviceId 由 twd cookie 派生,请求体指定不了 |
/taskwatch/push/unsubscribe | {id?} | 关掉系统通知:带 id 只删那一条,不带就删这台设备的全部订阅(别的设备不受影响) |
/taskwatch/push/test | {} | 真发一条测试通知:走真实加密 + 真实 VAPID 签名,只发给这台设备的订阅,回 {ok,sent,failed,results:[{id,masked,ok,status,error}],tookMs}。目标只能来自本机文件,请求方不能指定 URL |
全部注册在 ctx.webServer 上,不代理 DSH 自身的 /api,也不转发任意路径。
写入口就只有上面这些对话入口 + 1 条换路票据 + 1 条一次性审批 + 1 条回答 + 1 条设备撤销
notify/test 与 push/test 会主动发起外部请求,
两条的目标都不可由请求方指定) —— 契约测试会正面清点路由表,
新增路由必须在只读 / 对话 / 非对话写三张白名单里显式归类,漏一个就让构建失败
(早先那条断言写的是"源码里不出现 method === 'POST'"。加了 5 个写路由之后它
照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。宿主本来就有两条投递通道:mode:'queue'(进 nextTurn,等下一轮)与
mode:'steer'(进 nextStep,在本回合下一个步骤边界被领取)。手机端:
queue;GET /taskwatch/chat/queue 读 nextTurn 的顺序,
POST /taskwatch/chat/queue/reorder 用同一批 Message 整体重新 splice,
不伪造消息、不改内容。send 带 now:true,即宿主原生 steer。它进入 steering lane,
排在本轮所有 nextTurn 排队消息之前,但不会中止正在执行的工具调用或模型
请求 —— 消息在下一个 step 边界才被模型看到;会话空闲时会立刻唤醒并起一轮。
同一 lane 里更早的 steering 消息仍按先来后到排在它前面。interrupt:true:先 cancel({keepInbox:true})(队列保留),
等 agent 真正 idle(最多 2 秒)再 steer;成了就先于保留的 nextTurn 队列执行
(cancel 前已有的 steering 仍按原顺序先跑)。2 秒内停不下来返回
409 interrupt-timeout,消息不发出、队列不动。steering 消息重新排进 nextTurn。「在手机上新开一个对话」得能指定在哪个目录开工,否则只能落在部署默认值上。但
sessionController.create() 收的是绝对路径(cwd / workspaceId)—— 直接透给公网
客户端,等于把宿主的磁盘交出去。所以拆成两件事:
GET /taskwatch/chat/workspaces 回候选清单,页面只负责挑;POST /taskwatch/chat/create 拿客户端发来的字符串去清单里
逐字比对,命中才写进 cwd,否则直接报错。清单三个来源:现有会话的 cwd(你最近在哪儿干活,天然是你自己认过的目录)、
环境变量 DSH_TASKWATCH_WORKSPACES(; 分隔,默认 D:\Projects,装到别处改它)、
固定候选。不存在的目录用 statSync 过滤掉 —— 手机上少一个点进去才报错的入口。
手机端要的「像 ChatGPT 一样对话」,DSH 内部本来就有 —— sessionController
提供 prompt / follow / cancel / list / create / page / modelCatalog /
selectModel。所以这个插件一行 LLM 调用、一行流式解析都没写,只是把这几个方法
转成 HTTP。三个实测出来的坑记在这里:
follow() 返回的本来就是为传输设计的 wire 帧(SessionWireEvent.data 是
JsonValue),可以安全地逐帧 JSON.stringify;不要去碰会话活对象。x-accel-buffering: no,否则 nginx 会把流式缓冲成一次性吐出。tool/result 帧的 callId 不在顶层,在 data.message.source.callId。
按顶层读会一直拿到 undefined,表现是每来一个工具结果就多插一张错卡。session/title 帧里取 —— SessionSummary 里没有
title 字段。分页语义也是实测的:throughSeq 超过会话 cursor 会报
past cursor N,而 N 正是需要的值,于是用错误信息一步重试。手机端要能直接看到交付的图和 PDF(而不是只收到一个文件名),所以加了一条只读的
/taskwatch/file。它的安全边界抽在 lib/files.js 里,配 75 项边界测试:目录穿越、
同前缀兄弟目录、白名单外路径、realpath 复查(防符号链接逃逸)、.svg/.html/.js
一律拒绝。
v1.8.1 起,带 sessionId 的路径请求可以读本会话消息正文里引用过的绝对路径
(这是经用户 2026-10-29 授权的放宽,边界见下面「安全说明」);不带 sessionId 的
旧调用行为完全不变,仍然只认那两个根目录。
一个真实验收抓到的教训值得写下来:宿主附件库是内容寻址的,模型产出的图落在
~/.dsh/attachments/v1/objects/<hash> —— 名字就是哈希、根本没有扩展名。
最初只按扩展名判类型,于是最常用的那条路("把刚生成的那张图发我")被判成
unsupported-type 直接 403。而演示页一切正常,因为它用的是 data URI 和有扩展名的
路径 —— 这类「演示绿、真机红」的差异,只有端到端跑一次才会露出来。
现在:有扩展名走快路;没有扩展名就读前 16 字节按魔数识别(PNG / JPEG / GIF / WebP / PDF / ZIP / MP4)。这是按内容识别,不是"按内容放行" —— html / svg / js 没有魔数, 仍然走不通,白名单没有被放宽。
第 10 节「纸墨微动」:静态是一张纸,动起来像墨。位移 ≤3px、时长 120–320ms、 曲线统一"起步快、末段收",每个动效都携带信息 —— 在跑 = 一圈墨环在转、 完成 = 勾自己画出来、失败 = 顿一下、图 = 骨架纸纹后显影、新消息 = 浮标弹一下。 不放光、不放烟花、不为动而动。
两条硬约束写进了契约测试(不靠肉眼):