dingchenhui0618-arch/dsh-in-hand

dsh-taskwatch

掌上 DSH(dsh-in-hand):DeepSeek Harness 的手机端,不用装 App(PWA)。公网与局域网两个入口可随时切换;在手机上读会话、子代理、目标轮次、工作流、后台任务与待审批,和任意会话实时对话(流式回复、工具调用进度、随时停止、切换会话与模型),消息可排队并「立即发送」,支持上传与交付文件。另含 GUI 侧边栏面板;原名 dsh-taskwatch,npm 包已改为 dsh-in-hand。

待分类deepseekdshmonitor

掌上 DSH(dsh-in-hand)

English · 简体中文

掌上 DSH:左边是手机上的待批准卡片,右边是「不用装 App」的三行说明

不用装 App。手机上把 DSH 的待批准一次点掉。

首页的「待批准」卡片:点「批准一次」,在二次确认里点「允许一次」,卡片变成「已提交 · 允许一次」 点左上角 ☰ 打开抽屉:工作台与操作在前,往下滑到会话列表,点一条进对话 在抽屉里把字号调到「大」,再点「切换到暗色」,回到暗色首页

左:待批准一次点掉 · 中:抽屉里挑会话 · 右:字号与明暗

形态就是一个网页:手机浏览器打开即可。同一个 Wi-Fi 下走局域网直连,人在外面走经中继的公网入口,两条路在页面右上角随时切。

30 秒上手

  1. 装插件(在跑 DSH 的那台电脑上):dsh plugin --profile web add dsh-in-hand
  2. 重启 DSH:桌面客户端关掉再打开(命令行装的用 dsh web)—— 插件行来自 profile 的 bundle 图,不重启不生效。
  3. 手机打开:同一 Wi-Fi 用中继的局域网地址 http://<电脑的内网 IP>:3099/taskwatch,人在外面用中继的 HTTPS 域名;首次进配对页输一次 6 位码。

为什么用它

  • 不用公网 IP:电脑在 NAT 后面也能连上,路由器不用做端口映射。
  • 能接着聊:还是桌面上那批会话,手机上回一句就继续,逐字看着它跑。
  • 能批一次:手机在线时,待批准卡片直接给「批准一次 / 拒绝」;超时或手机切走就自动退回桌面(v1.8.0)。
  • 拍一张直接发:输入框旁的相机键唤起系统相机,拍完就在输入区看到缩略图、大小和取消;发送前在手机上压到长边 1600、保留拍摄方向,原图不出手机(仍走本机收件箱,不进云端,v1.8.9)。
  • 等待和交付物都在这一页:哪件事在等你、进度到哪,展开就能看;生成好的图和 PDF 直接打开。

从只读监控长成手机客户端

它起初只是一份只读的任务监控页——让你在手机上瞄一眼 DSH 有几个任务在跑、有没有事在等你;后来接上了 DSH 自己的会话运行时,长成了手机浏览器里的 DSH 客户端:左上角会话列表、右上角新建对话、输入框上方是当前会话的实时指标(轮次 / 步数 / token/s / Token 消耗 / 缓存命中率 / 上下文占用),可以直接对话、逐字跟随输出、随时打断;再往后,手机上还能一次性批准 / 拒绝一次工具调用、回答选项式提问。

没有 App 可装:不需要原生代码、不需要打包、不需要上架。手机上进一次「添加到主屏幕」,之后它就是一个图标;页面按请求读盘、壳由 Service Worker 换版,所以代码更新后下一次打开就会跟上。

打开就是概览(v1.8.2)

以前打开是一段对话——你得先翻一眼才知道自己为什么被叫来。现在打开是概览:最上面一行是虎鲸标 + 「掌上 DSH」(v1.8.20),紧接着就是「在等你 N 件」,下面第一张卡能直接点「批准一次」;再往下是「在跑」的三条(跑到第几步、跑了多久),一行后台任务 / 今日轮次,最后是「继续上次会话」和「全部会话」。对话退到第二层,点进去才看正文,左上角有明确的「返回首页」,系统返回手势也回得去(地址是 #session/<id>,刷新不丢)。

手机端首页:在等你 2 件、第一张待办卡可批、在跑区与继续上次会话

两件事刻意没做:没有新写一套审批逻辑(待办卡复用 v1.8.0 的按钮、二次确认与倒计时,只是多了一个放置位置),断网时不清空首页(继续显示上次读到的状态,底下补一条说明,而不是假装"什么都没有在等你")。

并行视图:一屏看清、失败永远在最上面(v1.8.10)

首页「在跑」只有三条,开四五个会话就得一页页翻,失败的那条尤其容易被埋掉。v1.8.10 加了整屏的并行视图(首页「在跑」右上角「全部」,或抽屉里的「并行视图」),排序写死为一句话:

需要你(待批准 / 待回答) → 失败 → 运行中 → 已完成最近

同组按最后活动时间倒序。这条排序不是"红的最显眼",而是分组本身:即使把颜色全去掉,失败仍然在运行中与空闲之上,滑不到它才奇怪。每行只留四件事——标题(最多两行)、状态徽标、一句最新动作(工具名 · 第几步)、已运行 / 结束时长;失败行多一句 ≤60 字的原因和「查看」。有子代理的会话下面折一行「子代理 N」,展开就能看到它们各自的状态,不另开一层页面。

并行视图:需要你、失败、两条运行中、一条空闲,失败行带原因与查看

更新沿用现有的 5 秒轮询与 SSE:只按会话 id 复用行、改变化了的那几个字段,不整屏重建;页面不可见时时长计时与轮询一起暂停。390 宽首屏能看到 5 行,无横向溢出,标题两行截断。

手机端一次性审批(v1.8.0)

以前手机只能看「有 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。

手机端待批准卡片:批准一次 / 拒绝 + 倒计时

这张卡只在「手机在线且这次请求可认领」时出现;离线、超时、桌面先处理都会回到原来的「请在桌面端批准」。

手机端回答选项式提问与计划审批(v1.8.3)

P0 的第一类是「放不放行」,第二类是「选哪一个」。v1.8.3 把 user-questions/request 也接上了:它和 approval/request 一样是 waterfall,手机页在线时插件认领它,把题目 变成一张可点的卡。

  • 单题:选项渲染成整行按钮(label 一行、description 第二行小字),点一下即选中, 但仍要再点「提交回答」—— 防的是误触直接交卷。
  • 多选:multiSelect:true 时按钮可以多选,底部同一个「提交回答」。
  • 多题:逐题纵排,答过的题不再显示「待回答」,没答完提交键保持禁用。
  • 计划审批:intent.kind === 'plan-review' 时标题带「计划审批」徽标,intent.approve 对应的那个选项用主色强调(它不是"更推荐",而是这次提问里唯一的放行项)。
  • 自定义回答:每题都有一个默认收起的输入框,展开后可以直接写一句话(≤2000 字)。 提交时作为 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.

设备列表与撤销(v1.8.7)

抽屉里多了一个「设备」:列出最近用过这个入口的手机与电脑,每行只有摘要名(系统 · 浏览器)、 最近活跃、从局域网还是公网进来,以及一个「撤销」。当前这台带「这台设备」标记;列表里的 id 是 12 位摘要,不是设备 cookie 原值。

设备列表:当前这台有标记、逐台撤销、长名字一行省略

撤销当前这台要过一道二次确认,说清后果;确认后本端立即翻到「此设备已被撤销,请重新 配对」,下一次请求由宿主回 401:

撤销当前设备的二次确认:撤销后这台手机需要重新配对

  • 宿主给每个浏览器发一个 HttpOnly 的设备 cookie,页面读不到;不记录 IP、不记录完整 UA, 最多 50 台、30 天不活跃自动清。
  • 撤销别的设备不需要确认(一次点击即可),撤销自己才需要 —— 防的是手滑把自己踢下线。
  • 这不是账号体系:本机仍然不鉴权,它解决的是「手机丢了 / 借人了,把这一台踢掉」。

高危动作二次确认(v1.8.8)

手机能批准之后,最危险的那一类不该和下一条普通改文案走同一个动作。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 / v1.8.16)

手机页要你主动看;会议里、路上、睡前,你不看就不知道有东西卡在那儿。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)、开关、发一条测试消息,以及「会收到什么」说明

飞书地址与 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 字)会经过它。如果要完全私有,另一条路是企业微信的应用 消息(自建应用 / 群机器人,落点自己控制),这里不展开。

手机系统通知(Web Push,v1.8.17)

飞书与微信都要经过别人的服务器。手机自己就能做的事,不必绕:v1.8.17 用 Web Push —— 浏览器把订阅交给它自己的推送服务(Chrome 的 FCM、Safari 的 APNs),由系统把通知递到 锁屏。不需要任何第三方账号,不产生费用,运行时依赖依然为零。

三步开起来(安卓 / 桌面)

1. 打开开关。 抽屉 →「通知」→「系统通知」→「开启系统通知」,浏览器会问一次通知权限。

2. 点「发一条测试通知」。 它会走真实的加密与签名,锁屏上出现「测试通知」就算通了; 失败会写 HTTP 码(410 表示订阅已失效,会自动删掉,重新开启即可)。

3. 之后只在真有事情时响。 标题是 N 件事等你,正文就是上面那条摘要;同一时间多件 合并成一条,tag 固定,通知栏里不会叠成一列。点通知回到这个入口的首页。

iPhone 要先「添加到主屏幕」:iOS 16.4 起才支持网页推送,而且只对从主屏幕打开的 站点生效。Safari 底部「分享」→「添加到主屏幕」→ 从主屏幕打开掌上 DSH,再回到这一页就是 「开启系统通知」。用 Safari 直接打开时会看到这三步说明,而不是一个点了没反应的按钮。 更早的 iOS 收不到,页面里的待办照常。

系统通知:已开启 2 台设备、发一条测试通知、关闭此设备

三条边界是刻意的

  • 只出掩码。 订阅文件是 ~/.dsh/taskwatch-push.json(仅当前用户可读),每条记 endpoint 与 keys.p256dh / auth;页面与接口只看到 https://fcm.googleapis.com/…/AbCdEf 这种掩码(域名 + 尾部 6 字符)。完整 endpoint 与密钥不进响应、不进审计、不进日志。
  • 绑在当前设备上。 每条订阅记的是设备账本里的 12 位摘要(twd cookie 派生), 不是 cookie 原值;撤销的设备不再推送。上限 20 条、单设备 5 条 ——「关闭此设备」删掉 这台手机的全部订阅,别的设备不受影响。
  • 算错不报错的东西要能证明。 正文用 RFC 8291 的 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 字),不含命令、路径与对话内容。

拍一张直接发(v1.8.9)

拍屏幕、白板、错误码,比打字快一个数量级。相机键背后就是 <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 里有断言)。

相机直发:缩略图 + 大小 + 取消,与输入框同屏;附件、相机两个 44px 键

上传走的是现有附件那条路:同一个 /taskwatch/chat/upload、同一套进度与失败重试, 没有新增接口、存储或后端目录。照片不进云端 —— 它先落在本机的收件箱里,发给哪个会话 由你在这一页决定。附件与相机两个键都是 44px,390 宽实测输入框仍有 210px,页面不横向溢出。

暗色下的同一张缩略图(令牌自动换,无第二套样式)

真机未验证的部分写在这里:iOS Safari 上 capture 的入口形态、HEIC 照片的输入, 以及弱网下 2.5 MB 图的等待体感,都只在 Chrome 里模拟过。

界面细节

真实会话里,连续的工具调用会折叠成一行「N 个步骤」,失败的那一步永不隐藏;助手回复支持轻量 markdown(标题、列表、行内代码、代码块),过宽的代码块提示可以横滑。

空状态:虎鲸标、三句快捷提问,上方仍有「子代理 2」入口

会话抽屉、状态卡片、输入框等全部走客户端同一套 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 位码 ABCD-1234、复制、打开 GitHub 授权页、取消与等待状态

暗色下的同一面板:底部抽屉、令牌自动换,没有第二套样式

确认成功后面板变成「已点星 ✓」,只留一个关闭按钮:

点星成功:已点星 ✓ 与「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 围栏解析失败,按代码块显示」。

手机上渲染的 dsh-ui 确认表单

不做什么:判卷(answer / explanation / quiz)、图表、mermaid、table、tabs、accordion 一律不渲染(节点直接丢弃)。手机是窄屏、页面是零依赖单文件,这几块不划算——桌面端继续用 dsh-genui 全量渲染。也不做 eval:模型给的字符串只走 textContent,拼不进 HTML。

手机上怎么打开(不用装 App)

  1. 在跑 DSH 的电脑上启用本插件,让它监听一个本地端口(见下文各章节)。
  2. 电脑侧起一个反向隧道 / 中继,把那个端口映射到一个 HTTPS 地址。 PWA 的 Service Worker 只在 HTTPS(或 localhost) 下工作——纯 HTTP 能看页面, 但装不到手机桌面,也没有离线壳。
  3. 手机上打开那个 HTTPS 地址,首次用一次性配对码换一个长期令牌,之后自动续期。

加成一个伪 App(主屏幕图标)

浏览器地址栏里的那一页,加到主屏幕之后就变成一个有图标、独立全屏的入口:点开不再有地址栏,切换手势、状态栏样式都按 App 走。技术上它是 PWA,不是原生 App,也不经过任何应用商店。

  • iOS Safari:分享 ⤴ → 添加到主屏幕 → 添加。图标来自页面的 apple-touch-icon,名字来自 apple-mobile-web-app-title(现在是「掌上 DSH」)。
  • Android Chrome:右上角 ⋮ → 安装应用(旧版菜单叫「添加到主屏幕」)。v1.7.9 起站点有了 web app manifest,display: standalone、start_url / scope 都是 /taskwatch,装完是独立窗口,不是带地址栏的快捷方式。图标复用页面同一个 /taskwatch/icon-192.png。

两条硬前提,先说清楚免得白试:

  • 必须是 HTTPS(或 localhost)。局域网那条 http://<电脑的内网 IP>:3099/taskwatch 页面照常能用,但装不成桌面图标、也没有离线壳 —— 想装就走公网中继那条 HTTPS 入口。
  • iOS 的 PWA 没有后台推送,也没有真正的后台刷新。退出后轮询就停了,重新点开才会继续;这一点和原生 App 不一样。

反代与隧道属于部署细节,本仓库只负责 DSH 这一侧:域名 + nginx + ssh -R 可以, Tailscale、Cloudflare Tunnel、ngrok 之类同样可以(它们都能免费给出 HTTPS)。 自己的专属入口链接里带访问令牌,等于完整控制权,绝不要提交到本仓库。

多台机器 / 多个入口(v1.8.12)

家里一台、公司一台、会议室那台测试机——以前每换一台就要重新输地址、在手机里再配一次。现在手机页里有一份入口清单:抽屉里的「入口 · 当前名」点开就是切换器,每行一个名字 + 地址,点「打开」就在新页面跳到那台机器的 /taskwatch。

  • 切换只换地址:认证靠每个源各自的 cookie(tw / twd),浏览器只会把 cookie 发给发下它的那个地址。所以切过去那台机器自己就认你,不需要在手机里再存一份令牌。
  • 手机上不存令牌:清单只有 {label, url} 两项,写在 localStorage['tw:entries'];不存 token、cookie、配对码。拿到这份清单最多知道几个内网地址。
  • 最多 8 条,同一个源只留一条;地址会被规范化成「协议 + host[:port]」——路径、?、# 丢掉,javascript: / data: / file: 一律拒绝。
  • 首次进入自动加一条「本机」(当前地址);已经删空的清单不会又被自动加回来。
  • 当前项只做一条细线 + 极淡底色,不抢眼;390 宽、明暗两套、命中区 44px。切换不清 cookie,也不做跨源取数(浏览器同源策略不允许,也不该绕)。

入口切换器:本机当前、家里 / 公司 / 会议室、添加入口与校验

为什么需要它

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、日志条数、已运行时长
采集诊断采集过程中单项失败的原因(不静默吞掉)

HTTP 路由

读(GET):

路径类型说明
/taskwatchHTML手机页:以对话为主,顶部状态条点开是任务详情
/taskwatch/dataJSON全部状态的快照,带 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/sessionsJSON可对话的会话列表(不含子代理会话)
/taskwatch/chat/workspacesJSON新建对话可选的工作区(现有会话的 cwd + 固定候选 + 环境变量追加;只列真实存在的目录)
/taskwatch/chat/modelsJSON可选模型目录
/taskwatch/chat/titles?ids=JSON批量补会话标题(最多 20 个,宿主缓存 10 分钟)
/taskwatch/chat/page?...JSON往更早翻会话历史
/taskwatch/chat/stream?sessionId=SSE跟随一个会话:开场快照 + 增量事件 + 逐字输出
/taskwatch/chat/queue?sessionId=JSON该会话未领取的消息:items(下一轮队列,可重排)与 steering(已插到本回合下一步)
/taskwatch/decisionsJSON当前手机端可决定的审批清单(内存里的活动 decision;approveFromPhone:false 时为空)
/taskwatch/devicesJSON设备账本(v1.8.7):每台设备只有可读摘要名、首次/最近活跃、入口、配对次数、是否已撤销,外加 current 标记;不回设备 cookie 原值、IP 或完整 UA
/taskwatch/notifyJSON通知通道(v1.8.15):每个通道只有 masked 掩码地址、类型 / 格式 / 开关,外加 anyEnabled 与上限。完整地址既不在这个响应里,也不在页面里
/taskwatch/push/stateJSON系统通知(v1.8.17):vapid 公钥(页面 subscribe() 用)、devices / total / mine 台数、上限,以及每条订阅的掩码(https://fcm.googleapis.com/…/AbCdEf)、uaShort 与最近成功时间。完整 endpoint 与 keys 不进任何响应
/taskwatch/approvals/streamSSE审批事件流:approval 事件推送 pending / settled;页面可见时保持,隐藏时关闭
/taskwatch/entriesJSON双入口清单:{entries:[{kind:'remote'|'lan',label,url,current}]};没配 publicUrl 时只有局域网一条
/taskwatch/manifest.webmanifestJSONPWA manifest
/taskwatch/sw.jsJSservice worker
/taskwatch/icon-192.png · /taskwatch/icon-512.pngPNG图标

写(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 条设备撤销

  • 4 条通知设置 + 3 条系统通知(只有 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;不要去碰会话活对象。
  • SSE 响应必须显式带 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、 曲线统一"起步快、末段收",每个动效都携带信息 —— 在跑 = 一圈墨环在转、 完成 = 勾自己画出来、失败 = 顿一下、图 = 骨架纸纹后显影、新消息 = 浮标弹一下。 不放光、不放烟花、不为动而动。

两条硬约束写进了契约测试(不靠肉眼):