English · 简体中文
不用装 App。手机上把 DSH 的待批准一次点掉。
左:待批准一次点掉 · 中:抽屉里挑会话 · 右:字号与明暗
形态就是一个网页:手机浏览器打开即可。同一个 Wi-Fi 下走局域网直连,人在外面走经中继的公网入口,两条路在页面右上角随时切。
- 装插件(在跑 DSH 的那台电脑上):
dsh plugin --profile web add dsh-in-hand - 重启 DSH:桌面客户端关掉再打开(命令行装的用
dsh web)—— 插件行来自 profile 的 bundle 图,不重启不生效。 - 手机打开:同一 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 换版,所以代码更新后下一次打开就会跟上。
以前打开是一段对话——你得先翻一眼才知道自己为什么被叫来。现在打开是概览:最上面一行是虎鲸标 + 「掌上 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.signalabort → 立刻交还,因为桌面端已经先处理了。- 只暴露
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对应的那个选项用主色强调(它不是"更推荐",而是这次提问里唯一的放行项)。 - 自定义回答:每题都有一个默认收起的输入框,展开后可以直接写一句话(≤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.
抽屉里多了一个「设备」:列出最近用过这个入口的手机与电脑,每行只有摘要名(系统 · 浏览器)、 最近活跃、从局域网还是公网进来,以及一个「撤销」。当前这台带「这台设备」标记;列表里的 id 是 12 位摘要,不是设备 cookie 原值。
撤销当前这台要过一道二次确认,说清后果;确认后本端立即翻到「此设备已被撤销,请重新
配对」,下一次请求由宿主回 401:
- 宿主给每个浏览器发一个 HttpOnly 的设备 cookie,页面读不到;不记录 IP、不记录完整 UA, 最多 50 台、30 天不活跃自动清。
- 撤销别的设备不需要确认(一次点击即可),撤销自己才需要 —— 防的是手滑把自己踢下线。
- 这不是账号体系:本机仍然不鉴权,它解决的是「手机丢了 / 借人了,把这一台踢掉」。
手机能批准之后,最危险的那一类不该和下一条普通改文案走同一个动作。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 与密钥不进响应、不进审计、不进日志。 - 绑在当前设备上。 每条订阅记的是设备账本里的 12 位摘要(
twdcookie 派生), 不是 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 字),不含命令、路径与对话内容。
拍屏幕、白板、错误码,比打字快一个数量级。相机键背后就是
<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 桌面端装了 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里的成员没填完时也禁用,不让「看着能点、点了没反应」。 - 不重复提交:按「会话 + 围栏正文指纹」记账,提交后卡片置灰显示「已提交」,刷新也不会再发一次。
- 两种回退分得清(v1.8.26 起):围栏
JSON本身坏掉 → 「dsh-ui 围栏解析失败,按代码块显示」;语法合法、只是手机端没有这个组件 → 「手机端不支持 table 这类组件,按原文显示」。不让用户为我们的边界道歉——这两种情况的错源不同,措辞也不该一样。 - 丢弃会说出来:一张卡里只有部分节点不支持时,能画的照画,卡片底部加一行「已隐藏 1 个手机端不支持的组件:table」。静默丢东西对用户不诚实。
- 浮层不抢点击:「回到最新」按钮遇到可点的表单控件会自动淡出(
.gu-clear),不会盖住「确认并发送」。 - 坏规格不白屏:
JSON解析失败、未知type、超深嵌套都安全降级——能画的照画,画不了的丢弃,整段 JSON 一字不损地留在代码块里。
两类回退长这样(左:手机端不支持 table;右:同一张卡里只有表格节点不支持,正文照常显示):
不做什么:判卷(answer / explanation / quiz)、图表、mermaid、table、tabs、accordion 一律不渲染(节点直接丢弃)。手机是窄屏、页面是零依赖单文件,这几块不划算——桌面端继续用 dsh-genui 全量渲染。也不做 eval:模型给的字符串只走 textContent,拼不进 HTML。
- 在跑 DSH 的电脑上启用本插件,让它监听一个本地端口(见下文各章节)。
- 电脑侧起一个反向隧道 / 中继,把那个端口映射到一个 HTTPS 地址。 PWA 的 Service Worker 只在 HTTPS(或 localhost) 下工作——纯 HTTP 能看页面, 但装不到手机桌面,也没有离线壳。
- 手机上打开那个 HTTPS 地址,首次用一次性配对码换一个长期令牌,之后自动续期。
浏览器地址栏里的那一页,加到主屏幕之后就变成一个有图标、独立全屏的入口:点开不再有地址栏,切换手势、状态栏样式都按 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)。 自己的专属入口链接里带访问令牌,等于完整控制权,绝不要提交到本仓库。
家里一台、公司一台、会议室那台测试机——以前每换一台就要重新输地址、在手机里再配一次。现在手机页里有一份入口清单:抽屉里的「入口 · 当前名」点开就是切换器,每行一个名字 + 地址,点「打开」就在新页面跳到那台机器的 /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、日志条数、已运行时长 |
| 采集诊断 | 采集过程中单项失败的原因(不静默吞掉) |
读(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(v1.8.27 起页面会同时带 &ref=<JSON>,宿主要用它校验元数据;只送 id 会 404);绝对路径走白名单根,带 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 条设备撤销
- 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、 曲线统一"起步快、末段收",每个动效都携带信息 —— 在跑 = 一圈墨环在转、 完成 = 勾自己画出来、失败 = 顿一下、图 = 骨架纸纹后显影、新消息 = 浮标弹一下。 不放光、不放烟花、不为动而动。
两条硬约束写进了契约测试(不靠肉眼):
- 入场动画必须由 JS 按"状态刚刚变化"加的类门控。挂在会被整段重渲染的节点上, 每 3 秒轮询就重放一次,观感从"灵动"变"抽搐"。
- "隐藏"只能挂在 JS 之后加的类上。先藏内容、等 JS 显示是这类动效最常见的自伤: JS 一旦不跑,图片就永远看不见。
缩小动效时也不是简单全关:墨环变成静态描边、骨架图直接可见 —— 动效可以没有, 状态语义不能丢。
要解决的是「小白点进来也看得懂」。所以页面不做仪表盘:顶部一行说人话的状态 (点开才是原来那张数据表),中间是对话,底部是输入框,数据表格收进抽屉。
两个具体决定:
- 工具调用翻译成人话。
pwsh→ 「运行命令」、grep→ 「搜索内容」、subagent→ 「派发子任务」。用户不需要知道pwsh是什么;卡片上还给一行命令/路径摘要,状态用 进行中 / 完成 / 失败三态。这是"看得懂进度"最关键的一处。 - 逐字输出只改一个节点。整页重建会让长会话每来一个字都重排一次。
手机只有 360–390px 宽,对话里最容易坏的是三样东西:表格、长代码、长 URL。v1.7.6
把表格渲染成真表格并放进可横滑的 .tablewrap,代码块自己横滑,长 token 在分隔符后
断行;页面级 scrollWidth 在 390 和 360 下都等于视口宽。助手正文、流式回复和工具卡
统一 100% 宽、左对齐;用户消息仍靠右。
子代理不再混在主对话里:工具条出现「子代理 N」,点进去是独立列表,再点开只读快照; 返回回到原来的根会话。没有子代理时入口不出现。
长代码块提示「可横滑」,自己滚,不把页面撑宽:
子代理不再混在主对话里:工具条出现「子代理 N」,点进去是独立列表,再点开只读快照; 返回回到原来的根会话。没有子代理时入口不出现。
360 宽下最难的表格与超长 URL 也不横向溢出:
截图是 docs/demo.html 的真实渲染(Chrome headless + CDP,DPR2,390 / 360 宽),
数据来自演示会话。回归自检:npm run check 里的 scripts/check-mobile.mjs 会在
390 / 360 两个宽度下跑 window.twSelfCheck()、切会话残留断言与左基线断言。
图片加载失败(宿主文件路由默认只服务 ~/.dsh/attachments / ~/.dsh/outbox;v1.8.1 起
本会话引用过的绝对路径也可读,其它路径仍是 403;外域 URL 也可能不可达)时,v1.7.6 会铺
一张 63px 的整宽 ERR 卡片,视觉上仍是一块把正文截断的占位框。v1.7.7 换成 36px 单行提示条,
并且页面级保证 DOM 里不留破图 img。v1.8.1 起提示条会区分「该文件未被本会话引用」与
「文件超过 50 MB」,文件卡片第二行同样按 403 / 413 分开写,并保留可点的原路径。
会话抽屉里「子代理 N」入口的图标改成内联 SVG(不再用字符,真机上曾出现「廿」这种回退字形)。
这一版的「改前 / 改后」对比图仍留在
docs/screenshots/v1.7.7-broken-before.png/v1.7.7-broken-after.png/v1.7.7-subagents-icon.png,只作历史留存,README 不再引用; 当前界面看docs/screenshots/current/。
图片的骨架流光用 inset:0 绝对定位,却忘了给 .md-imgwrap 定位,包含块一路退到视口。
长会话里总有还没进入视口的懒加载图,它们永远带着 .loading,于是整屏多出一条 1.35s
无限循环的亮带(用户原话「反复闪屏」),translateX(55%) 还把页面从 390 撑到 601。现在
流光被关进图片盒、不吃点击,离屏图也不再挂任何动画:
- 同一条会话的
scrollWidth从 601 → 390,静止 1.4s 内像素变化 2.55% → 0.1%。 - 同一轮的第二个修复:DSH 给模型看的
<path>…</path><type>image</type><content>…</content>信封,以前在手机上原样显示成 XML(某会话第一条消息整条都是它),现在渲染成图片卡, 拿不到图时仍是那条 36px 提示条。
这一版的「改前 / 改后」对比图
docs/screenshots/v1.7.8-flash-before.png/v1.7.8-flash-after.png只作历史留存,README 不再引用。
同一位真机用户报的第二个问题:手机会话里的图片位置只显示「这份内容现在读不到了 · 打开原图」, 桌面同一批图正常。
宿主的 readImage / readFileStream 除了校验字节完整性,还会拿 ref 里的 mediaType /
bytes / width / height 跟探测到的真实元数据逐项比 —— 只送 attachmentId 时这四项是
undefined,必然不等,于是稳定 404。页面解析消息附件时本来拿到了完整 ref,却只留了 id 和名字。
现在页面把完整 ref 用 &ref=<JSON> 带回去,服务端只收白名单字段(name / mediaType /
bytes / width / height / kind / sha256),attachmentId 强制用 URL 里的 id,再交给
宿主去校验;ref 坏掉或循环引用时退回只送 id,不让一条消息整条崩。图片与文件两个分支都改。
check-files 里新增的一组就是这条路的端到端测试:起真实 node:http 服务 + 按宿主源码语义写的
附件服务替身(真实 PNG 字节、真实头部探测),断言「只送 id → 404」「带完整 ref → 200 且字节
逐字节相同」「ref 里伪造 id 顶不掉 URL 的 id」「额外键不往下传」「坏 JSON 不 500」。
页面每 3 秒轮询一次,但数据没变就完全不碰 DOM,只刷新右上角时间戳。首版是
root.innerHTML = h 全量重建,结果是每 3 秒把页面拆一次:展开的东西 3 秒后被抹掉,
滚动位置被打断,看起来像「不刷新」和「点不动」——那是同一个原因。
这里有个容易漏掉的坑:比较序列化结果时不能把 generatedAt 算进去。 它是每次采集
都不同的 Date.now(),带着它比较,签名就永远在变,「数据没变就不碰 DOM」实际上是
一句空话,页面照样每 3 秒整页重建。所以客户端算签名、服务端算 ETag 时都先把这个
字段归一化,两边保持同一套判据。
而「有东西在跑的时候」仍然要重画:画面上的时长要一直往上走。所以静止时就真的不碰 DOM,有运行中的会话、后台任务、子代理或工作流时才重画。
展开状态与对话缓存存在 JS 对象里,跨轮询保留;点击用事件委托挂在常驻容器上, 所以内层 DOM 重建也不会丢监听。对话按需懒加载,点开一次即缓存。
页面每 5 秒轮询,而这个页面的主要用途是手机——最常见的状态是「揣在兜里、屏幕已熄」。 不处理的话,一个常驻后台的标签页会永远每 3 秒发一次请求。所以两头都做了收敛:
- 客户端:监听
visibilitychange,页面隐藏时clearInterval停止轮询,回到前台立刻 补一次请求。手机上省的是流量和电。 - 服务端:
snapshot()一次要问一圈服务(每个 agent 的 jobs、每个会话的eventAt/title/goal、每个 agent 的listDescendants)。现在同一份采集在 2 秒 TTL 内被复用,并发请求共用同一个 Promise 而不是各算各的——手机、桌面面板和 多个标签页同时开着时,这一层把开销从「每请求一次全量采集」压成「最多每 2 秒一次」。 - 带宽:
/taskwatch/data返回ETag并在未变化时回304,手机上只交换一个头而不是 整份 JSON。
最后一条有个必须成对的地方:页面取数用的是 cache:'no-cache',不能用 no-store。
no-store 会让浏览器根本不保存响应,也就永远不会带 If-None-Match 回来,服务端那段
304 会变成死代码。这两处因此都在契约测试里钉住了。
- 页面在后台就不刷屏:
visibilitychange一隐藏就停掉轮询、暂停所有重连排期,锁屏十分钟不会攒出一串请求。 - 断线后按退避重连:快照和两条事件流共用 1s → 2s → 5s → 10s → 30s(每档 ±20% 抖动、30s 封顶)的退避,同一时刻只允许一个
/taskwatch/data在飞。 - 恢复后自动续上:
online、回到前台(以及 iOS 的 bfcachepageshow)都会立刻取一次新快照并重开事件流;超过 3 个轮询周期没成功,页脚显示「连接已断开 · 正在重试(N 秒前成功)」并给一键重试,旧内容不会被清空。
这是监控工具最危险的失败姿态,所以单独说清。
宿主采集异常时 /taskwatch/data 回的是 HTTP 200 + { "error": ... }。如果客户端
把这份载荷当数据用,画出来就是一块「全部为 0」的板子:0 个会话、0 个后台任务、
「当前没有等待中的审批或提问」。它看起来像「真的没有任务」,而不是「没读到」。
所以两半都显式处理 error 字段,把它当错误而不是数据:
- 手机页:
tick()先查d.error,命中就只在页脚写「采集失败:…」并保留上一份 可用快照,绝不拿它去重画。 - GUI 面板:把
snap.error视为错误,继续渲染上一份可用快照,并在顶部挂一条 「数据可能已陈旧(快照时间 HH:MM:SS):…」的横幅。一次网络抖动同样只产生这条横幅, 而不是把整块面板清成空白 —— 监控面板的价值恰恰在于「断开时还能看到最后一帧」。
同一类问题的另一面是轮询的乱序:并发请求下,较早发出的响应完全可能晚回来。两半 都用一个自增序号只接受最新一次的结果,避免旧数据覆盖新数据。
- 只能读活着的会话。
ctx.sessions是内存存储,get(id)返回undefined时接口 会明确报错「这个会话不在内存里(已落盘但未加载的会话读不到)」,而不是返回一个 空的messages—— 后者会让人误以为那个会话真的没说话。 - 只读不写。 没有发送消息、批准审批、打断运行的入口。要做到那些,必须把 DSH 的 Web GUI 暴露出去,那是另一个量级的风险,本插件刻意不做。
消息只取叶子字段(role、text、工具名、时间),不把活的会话日志对象序列化出去。
lib/client.js 注册一个侧边栏面板(sidebar.panellist),数据取自同源的
/taskwatch/data,不需要任何 host RPC。样式全部挂在插件自己的 fiber 上,
卸载即还原。
面板右上角有一个指向手机页的链接,方便直接把 /taskwatch 复制到手机上打开。
面板和手机页在轮询上是对称的:窗口不可见时 clearInterval 停止轮询,回到前台立刻
补一次请求。桌面这一半容易被忽略,但它的典型用法恰恰是「GUI 最小化、拿手机看」。
dsh plugin --profile web add dsh-in-hand装完需要重启 DSH:桌面客户端关掉再打开,命令行装的用 dsh web。插件行来自
profile 的 bundle 图,而 client 侧模块元数据
按 specifier 缓存且没有失效路径,所以增删插件一律要重启才生效。
环境:Node ≥ 20(见 engines.node)。package.json 里的 engines.dsh 声明为
>=0.2.1-rc.2 <0.3.0,这是本包端到端跑过的范围(Windows 上的 DSH 桌面版、nightly 通道)。
更低的版本(含 0.1.x 稳定线)没有端到端验证过,装上去可能直接报错——这是声明与实测一致
的结果,不是故意收紧的门槛。
本包没有构建步骤:lib/ 是提交进仓库的、可直接运行的产物,客户端 bundle 就是
一个调用 window.__ModuleLoader__.load({ id, factory }) 的普通脚本。从源码安装不需要
任何构建授权(allowBuilds)。
本地开发时也可以直接指向工作副本:
dsh plugin --profile web add <你的绝对路径>/dsh-in-hand补丁里引用插件有两种写法,注意区别:bundle 自带的 cordis.patch.yml 用包名
(由 profile 的 node_modules 解析),而 profile 自带补丁里若写路径,必须是绝对路径
(相对路径是相对该补丁文件解析的,跨盘会失败)。
中继的上游默认就是 http://127.0.0.1:3080(server.mjs 里的 RELAY_UPSTREAM),
所以 DSH 的 Web 端口必须是 3080,否则 /taskwatch/data 只会回 502,手机页显示
「对话功能不可用」。桌面版默认不是这样:端口由 app.asar 决定,而那里把 --port 0
写死了(OS 每次启动随机挑一个空闲端口),并且 没有 DSH_PORT / DSH_WEB_PORT
这类环境变量入口、配置文件里也没有端口字段 —— 环境变量与 CLI 参数这条路在桌面版上
都是堵死的。
唯一「不改安装目录、升级不丢」的入口,是用户补丁层覆盖 web-app 自带的 webserver 行:
# ~/.dsh/profiles/desktop/cordis.patch.yml
- id: webserver
name: "@deepseek-ai/dsh-host-webserver"
config:
host: "127.0.0.1"
port: 3080 # 原来是 !!js ctx.webStartup.port ?? 3080,桌面版传进来的是 0
tls: !!js ctx.webStartup.tls
compression: gzip
compressionLevel: 1
compressionThresholdBytes: 1024三个要点:
- 补丁层是整段替换
config,不是深合并。少写一个字段(上面那四个除port和host之外的字段)等于把它删掉,所以必须原样写回。 - 这个 profile 的补丁文件是 live 生效的:保存后 DSH 立刻在新端口重新监听, 已经打开的窗口会断线 —— 重开一次客户端即可,会话不丢。
- 改端口时三处必须一起改:上面这行
port、~/.dsh/autostart/services-watchdog.ps1里给中继设的RELAY_UPSTREAM、以及本文档这一节。中继的UPSTREAM是启动时读取的 模块常量,改环境变量不会热生效,必须重启一次中继。 - 3080 若被别的程序占着,桌面版会 bind 失败、Web 界面起不来;先
curl http://127.0.0.1:3080/看是谁在应答。
想省掉手写 nginx,用这条三步部署 →
docs/deploy/README.md
本插件自己不做鉴权,只监听回环。 它提供的是「可被安全转发的只读源」,转发这一层 由你自己的隧道或反向代理负责。一个已验证可用的组合是:
手机 / 外网
└─ HTTPS <你的域名> 反向代理:TLS 终止
└─ 隧道回到本机 ssh -R,或任何等价隧道
└─ 本机只读中继 校验应用层共享令牌
├─ /taskwatch、/taskwatch/data、/taskwatch/session → DSH 的回环端口
└─ manifest / sw.js / 图标 → 中继本地文件
配套的只读中继是刻意做成另一件事的,不属于本插件:它是几十行的零依赖 Node 脚本, 把白名单里的固定路径转发到 DSH 的回环端口,其余一律 404。想自己写的话,要点就四条:
- 白名单硬编码,不要让上游路径可配置 —— 否则它就成了一个任意代理。
- 只允许 GET/HEAD,不接受请求体,不转发
/api。 - 自带一层共享令牌,定长比较(
timingSafeEqual),并且不要把令牌转发给上游。 - 转发时要带上查询串。这一点踩过坑:漏掉它时
/taskwatch/session会返回 HTTP 200 但内容永远是「缺少 id 参数」——状态码看着完全正常,只是数据永远为空。
PWA 外壳由中继本地提供(而不是转发上游),这样它不随上游插件的版本变化而失效; 页面文件与插件共用同一份,避免两边漂移。
上面那条路要域名、要 nginx、要一条 ssh -R —— 适合"人在外面也想看"。如果手机和电脑
就在同一个屋子里,还有一条更短的路:让中继直接监听内网地址,手机连同一个 Wi-Fi
直连它。
中继默认只监听回环(127.0.0.1),连局域网都到不了,只有隧道能进。开局域网模式就是
一个开关:在中继目录里放一个空的 .lan 标记文件,或者给它设环境变量 RELAY_LAN=1,
它就从 127.0.0.1 改为绑定 0.0.0.0;删掉标记文件即关闭(这个开关是中继启动时
读的,改完要重启它才生效)。中继自己的
/taskwatch/lan 页面(不需要令牌,也不含任何凭据)会列出当前是否已开启、本机可用
的内网地址和连接步骤。
- 在跑中继的电脑上打开
http://127.0.0.1:3099/taskwatch/pair,页面上会显示一个 6 位配对码(只有从本机访问才显示)。 - 手机连同一个 Wi-Fi,打开
http://192.168.1.20:3099/taskwatch(把192.168.1.20换成电脑的内网地址,以中继/taskwatch/lan列出的为准)。它会进配对页。 - 输入那 6 位码即可。配对码 2 分钟有效、最多试 5 次、只能用一次;换到的 cookie 有效期 30 天,并且每次带着它访问都会滑动续期。
页面右上角那个双入口胶囊也可以在这条路上用(见下一节)。
http://不是安全上下文。 装不成 PWA、没有 service worker 离线外壳,摄像头 / 麦克风、剪贴板 API 也用不了。页面本身照常能用 —— 但"添加到主屏幕当一个 App 用" 这件事只在 HTTPS 那条路上成立。- IP 会随 DHCP 变。 路由器重新分配地址之后,手机上存的那条
http://192.168.x.x:3099就失效了;以中继/taskwatch/lan当前列出的地址为准。 - 开启客户端隔离(AP isolation)的 Wi-Fi 连不上。 那种设置下同一 Wi-Fi 的设备之间 互相不可见,手机到不了电脑 —— 这是路由器的行为,不是这条路能绕开的。
- 出了这个 Wi-Fi 就连不上。 要外网访问就用上面的隧道那条路。
拿到这条地址 + 配对码,等于拿到你机器上的一个终端。 手机端能驱动 agent,而 DSH
默认是 danger-full-access。所以:只在自己家、朋友家这类你信得过的网络里开;公司 /
公共网络的 Wi-Fi 不要常开;不用的时候就删掉 .lan 标记文件,让它退回只监听回环。
配对码这一层是刻意收紧的:它一次性,并且只在本机 127.0.0.1 的页面上显示 ——
能操作这台电脑的人,才能授权一台手机。
远端(人在外面):
手机
└─ HTTPS https://dsh.example.com (示例域名,不是真实部署)
└─ nginx(服务器) 反向代理:TLS 终止
└─ SSH 反向隧道 ssh -R 本机主动连出,不需要公网 IP 与端口映射
└─ 本机中继 127.0.0.1:3099 校验中继令牌,按白名单转发
└─ DSH 上游 127.0.0.1:3080 /taskwatch/*(读 + /taskwatch/chat/* 写)
局域网(同一个 Wi-Fi):
手机
└─ HTTP http://192.168.1.20:3099/taskwatch 直连中继(中继绑定 0.0.0.0)
└─ 同一套 6 位配对码 → 30 天 cookie
└─ DSH 上游 127.0.0.1:3080
家里的机器通常在 NAT 后面:没有固定公网 IP,也没开入站端口。反向隧道是本机主动连出 到服务器,服务器再顺着这条已经建立的连接把请求送回来 —— 所以不需要公网 IP,也不需要在 路由器上做端口映射。服务器那一侧只多一个 nginx 反代负责 TLS 终止。
两段路,两道锁,解的不是同一个问题:
- 中继令牌(中继侧的
RELAY_TOKEN,或它自己生成在.token里的值)管的是 「服务器 → 本机中继」这一段:证明这个请求是从被授权的隧道入口进来的。比较是定长 的(timingSafeEqual),并且令牌不会被转发给上游。 - 6 位配对码管的是「手机 → 中继」这一段:手机第一次访问时用它换一张 30 天、会
滑动续期的
HttpOnlycookie。配对码 2 分钟有效、最多试 5 次、只能用一次,并且只在127.0.0.1的页面上显示。
隧道那条路上,请求在中继眼里来源都是 127.0.0.1 —— 它看到的对端是隧道,不是手机。
所以中继分不清手机是在家里、在 4G,还是在同一个 Wi-Fi 里;同理,HTTPS 页面也没法探测
http:// 内网地址(浏览器按混合内容拦掉,IP 主机连"可升级"都不算)。
这就是手机页右上角那个双入口胶囊只能"事后切"、不能"事先测"的原因:
- 胶囊显示当前走的是哪条路(远端 / 局域网)。点开是一张菜单,列出两条入口: 当前那条标「当前」,另一条标「切换」—— 点另一条 = 签一张票 + 跳一次。
- 没有任何自动跳转(v1.7.5 起)。页面加载时只读一次入口清单、只渲染胶囊, 不允许因为入口判断而导航;也没有定时体检。自动模式被删除的原因写在下面。
- 两道保险:跳转前在
sessionStorage['tw-route-jump']写时间戳,8 秒内重复点击 只给静态提示、不签票也不跳;URL 带?t=落地时立刻history.replaceState清掉参数,并把入口锁成当前项(sessionStorage['tw-route-lock'])。
为什么不再"自动切":v1.6.0 – v1.7.4 的自动模式用「这条路刚才通不通」猜方向, 而
apply()在初始加载与每 12 秒的体检后都会重新判断,判断出「该换」就改地址。 两条路的信号一旦互相矛盾(一条体检失败、另一条请求报错),页面就会在两个入口之间 无限往返,手机看到的是地址栏来回变、整页反复刷新。猜错的代价远大于省下的一次 点击,所以自动只保留在人的脑子里:菜单列出两条路,当前是哪条、另一条在哪,由用户点。GET /taskwatch/upstream-health仍在中继侧保留,但页面不再请求它。
入口菜单的旧截图
docs/route-entries-390.png只作历史留存,README 不再引用; 当前界面见docs/screenshots/current/。
换路等于换了一个源(https://… ↔ http://192.168.x.x:3099),而浏览器的 cookie 是
按源隔离的 —— 在远端那条路上换到的 cookie,到了局域网这条路等于不存在。
所以页面点切换时,先向当前这条路的宿主(中继,或插件宿主本身)要一张凭据:
POST /taskwatch/ticket(中继上要带令牌)→ 返回另一条路的 URL,末尾带?t=…;- 手机跳过去,对面兑换这张票并给那条路种下 cookie,于是不用重新输那 6 位配对码;
- 中继与插件宿主跑在同一台机器、同一个用户下,用同一个密钥验签,所以宿主签的票 打到公网(域名后面是中继)也能被中继认,反之亦然。
v1.7.1 起,前两条插件宿主也自己实现了(同一套语义):GET /taskwatch/entries
回答「现在有哪几条路」,POST /taskwatch/ticket 给一张换路票据。
所以从宿主页面(例如 http://127.0.0.1:52676/taskwatch)点「去远端」不再拿到 405 / 404。
GET /taskwatch/upstream-health 仍只有中继实现,但 v1.7.5 起页面不再请求它:
自动体检切换已删除,来源也不可靠 —— 中继经隧道看到的来源永远是 127.0.0.1。
t = 'v2.' + <expMs 十进制> + '.' + <nonce base64url> + '.' + <hmac base64url>
HMAC = HMAC-SHA256(secret, 'v2|' + expMs + '|' + nonce)
nonce = crypto.randomBytes(16) # base64url 后 22 个字符
校验顺序:格式 → timingSafeEqual 比 HMAC → expMs > now → expMs - now <= 180000
(60 秒 TTL + 时钟偏差)。签名不对 / 过期 / 格式不符一律当作「不是本票」;只有不是
v2. 形状的字符串才回落到原来的进程内 Map(老页面、老票仍然能用)。票据有效期内
两边各自维护一个已用 nonce 的 Set(带 TTL 清理)做进程内 best-effort 去重 ——
这不是严格一次性,详见下面「安全边界」。
公网地址只来自插件配置项 publicUrl(也支持环境变量 DSH_TASKWATCH_PUBLIC_URL),
局域网入口可以选配 lanUrl(也支持 DSH_TASKWATCH_LAN_URL),代码里不写死任何域名:
- insert:
- id: dsh-in-hand
name: dsh-in-hand
config:
publicUrl: https://dsh.example.com
lanUrl: http://192.168.1.5:3099 # 指向中继的局域网入口(有令牌门)
approveFromPhone: true # 默认 true;false 时完全退回只观测
approvalTimeoutMs: 120000 # 默认 120000,允许 10000–600000
riskConfirm: true # 默认 true;false 时高危只显示徽标、点击直接提交
# highRiskPatterns: # 默认 19 条(见「安全说明」);给了就替换默认清单
# - rm -rf
# - docker rm配了 lanUrl 就把 entries 的局域网条目换成它,不再自动探测内网 IP;没配保持现状。
lanUrl 接受 http://192.168.1.5:3099,也接受入口的完整地址
http://192.168.1.5:3099/taskwatch(只吃掉这一个后缀,entries / ticket 还会自己再拼
/taskwatch);其它路径一律按未配置处理,并在启动时打一条告警(v1.7.3 之前是静默丢弃)。
插件自己的路由没有令牌门(授权由中继那层负责),所以建议把 lanUrl 指到有鉴权的
中继,而不是直接指回插件宿主。
approveFromPhone(默认 true)决定手机端能不能认领审批与选项式提问;设成 false
就完全回到 v1.7.9 的只观测行为(两种待办都只显示,不出现操作按钮)。
approvalTimeoutMs 默认 120000,允许 10000–600000;
小于下限按 10000、大于上限按 600000,并在启动时打一条 warn —— 越界是「取边界值」,
不是悄悄回默认值。
riskConfirm(默认 true)决定高危动作在手机上是否要两步确认;关掉后卡片仍显示
红色「高危」徽标与后果说明,但点「批准一次」直接提交,不再展开确认层。
highRiskPatterns 是判定用的字符串数组:给了就替换默认清单(不是追加),
不写则用默认 19 条;最多 64 条、每条 ≤120 字符,越界会裁剪并 warn。匹配大小写不敏感,
路径类模式只在「作为被操作对象」时命中(.gitignore 不算 .git)。判定与下发的字段
(risk: 'high' | 'normal')都在宿主侧完成,页面不参与判定。
票据接口的请求体字段就是 to:页面发 {"to":"remote"} / {"to":"lan"},
服务端读 body.to(缺省 / 未知值都按 lan 处理,不会误签远端票)。这条契约由
scripts/check-contract.mjs 钉住,改页面或改服务端时必须同步。
没配 publicUrl 时只报局域网入口;ticket 的远端请求明确回 409 no-public-entry。
宿主这层仍然不做令牌校验(401 由中继负责),但票据是写路由,所以和中继的写路由同一套
门禁:POST + application/json + 同源(Origin / Sec-Fetch-Site)校验。
页面侧遇到这两条接口的 404 / 405 会进入静态降级:不重试、不弹错,胶囊按
「只有一个入口」隐藏;设置 / 任务详情里留一行说明与一个手动「去远端」链接
(上次成功的地址记在 localStorage['tw-remote-url'])。其它错误只在入口菜单里
显示一行静态提示(切菜单即清),不会自动重试,更不会因此导航。
升级后公网换路仍是 401? 先看 3099 上的中继进程是不是新代码:它在启动时 一次性加载路由与票据密钥,不会热更新。带令牌调一次
POST /taskwatch/ticket, 返回的t不是v2.形状就说明还在跑旧进程 —— 重启中继即可,插件这侧无需改动。
中继那边另有独立实现,见中继仓库
dsh-in-hand-relay(该仓库尚未创建,此处只写仓库名)。 中继没升级时,页面在宿主上仍能正常用,只是没有远端切换。
看到 502 先分清它从哪一段来,三种情况的处置完全不同:
| 现象 | 断在哪 | 说明 |
|---|---|---|
| 服务器 nginx 回的 502 | 隧道那一段 | ssh -R 断了,或者本机中继没在跑;重新拉起隧道 / 中继 |
中继回的 502,响应头带 x-taskwatch-hop: relay |
中继 → DSH 上游 | 中继自己发的:隧道是通的,但上游 DSH 不可达(回环端口没在监听,或插件没挂载) |
| 手机上反复看到 502 | 手机的 service worker | v1.6.0 之前的 SW 会把那次 502 存成外壳,之后离线导航一直吃它,而服务端早已恢复 —— v1.6.0 起只缓存 2xx,并把 CACHE 升到 v13 清掉已存的 502 |
2026-10-21 起 DSH 的 Web 端口固定在 3080(见「DSH Web 端口固定为 3080」一节)。 中继那种 502 基本只剩两种情况:DSH 没在 3080 上(客户端没开,或 3080 被别的程序占用 导致 bind 失败),或者中继进程还拿着旧端口的上游(
UPSTREAM启动时读取,必须重启中继)。
用之前记住四件事:
- 公网入口必须带令牌或一次性票据才能进。 票据 60 秒过期、带签名校验,用过的会被作废;其中「作废」是各进程自己记账(不是跨进程的全局一次性)。伪造的和过期的都当没票。
- 中继只转发白名单里的接口,不是任意代理。
- 插件自带的局域网入口没有令牌门(GUI 本身也没有认证)。所以不要把 DSH 的端口直接暴露到公网,要走中继。
- 密钥文件在
~\.dsh\taskwatch-ticket.key,权限只给当前用户,不会进任何仓库。 - 手机端的一次性放行等于把写权限延伸到手机。
approveFromPhone默认开启,是因为 没有它手机端就永远只能看;但它确实意味着「能打开这个页面的人,可以让 agent 执行一次 本该在桌面确认的工具调用」。v1.8.3 起同一个开关也覆盖回答选项式提问:答案会改变 agent 接下来怎么走(包括计划审批)。不想要就设成false,行为立刻回到只观测。 - 高危动作需要两步确认(v1.8.8)。判定在宿主侧完成(
assessRisk),页面只收到risk:'high'|'normal',拿不到模式表、也不自己用正则猜命令。默认模式 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(只在作为被操作对象时命中)。匹配对象是toolName + reason + displayReason, 不是完整命令(本来就不存命令全文)。可用highRiskPatterns增删/替换(最多 64 条、 每条 ≤120 字符,越界裁剪并 warn);riskConfirm:false会把这一步关掉、只保留徽标 —— 关掉就失去这层保护,又回到一步放行。这是一道按可见信息加的门槛,不是命令级沙箱: 绕开文案措辞的调用仍可能被判成normal,真正的权限边界仍然是桌面端。 - 审批与问答审计都写在
~/.dsh/taskwatch-audit.log(DSH_HOME优先),JSON Lines,权限只给 当前用户。审批每条只有{at, session, toolName, outcome, by, risk, reason?};reason只留 前 120 字,不记命令全文、不记 stdout / stderr。提问每条只有{at, session, kind:'question', title, outcome, by, selectedCount, custom}—— 不记选了哪一项、也不记自定义回答的原文。by区分phone/timeout/desktop/error。 - 设备列表可以定期清掉不用的手机(v1.8.7)。 宿主给每个浏览器发一个随机
twdcookie(HttpOnly、SameSite=Lax、30 天),只用来认「同一台设备」,不是登录态。 账本只存:可读的摘要名(系统 · 浏览器,≤80 字)、首次 / 最近活跃时间、从哪个入口来 (局域网 / 公网)、配对过几次、是否已撤销 —— 不存 IP、不存完整 UA,设备 cookie 原值也从不回到页面(列表里的 id 是 SHA-256 前 12 位摘要)。最多记 50 台,30 天不活跃 自动清;撤销后该设备的记录立刻作废,它下一次请求就401并提示重新配对(?pair=1清掉旧 cookie,下次访问换一张新身份)。撤销写一行{at, kind:'device-revoke', deviceId:<摘要>, via, self}。注意这不是账号体系:本机不做 鉴权,它解决的是「手机丢了 / 借人了,把这一台踢掉」,不是「谁可以登录」。 - 本会话引用过的文件可读(v1.8.1,经用户 2026-10-29 授权)。 这是对「文件路由只服务
两个固定根目录」的一次明确放宽:手机页请求
/taskwatch/file?path=…&sessionId=…时, 服务端读该会话的消息正文,把其中出现的绝对路径收成白名单,只有命中白名单、且realpath之后仍是白名单里的普通文件才放行。边界是写死的:只能读引用过的 单个文件(不支持目录列举、通配、父目录访问),单个 ≤ 50 MB(超出回413); 跨会话不可(换一个sessionId取同一路径照样 403);符号链接逃逸被拒(真实 路径也必须被引用过);未命中一律403 outside-roots,与旧行为一致。DSH_TASKWATCH_ROOTS本身没有放宽,仍只服务~/.dsh/attachments与~/.dsh/outbox。每次放行写一行{at, session, path(截断到 200 字), size, via:'session-ref'}到~/.dsh/taskwatch-audit.log;拒绝不写(避免噪声)。 - 点星走 GitHub OAuth Device Flow(v1.8.23)。 页面里点「给掌上 DSH 点个星」只是
打开授权面板:宿主向 GitHub 申请 8 位设备码,你在 GitHub 上输入并确认后,宿主才用
你本人的账号
PUT /user/starred。申请的是public_repo—— GitHub 没有「只能点星」 这种更窄的范围,空 scope 的 token 连点星都做不了(实测返回 404 而不是 403)。它不读 私有仓库、不碰资料,token 只在宿主内存里停留几秒(换到就立刻点星,然后置 null 并 删除流程;不写日志、不写文件、不进任何响应),点完即丢弃。可随时在github.com/settings/applications撤销。只有你主动点击、并在 GitHub 上亲自确认才会 发生;没有任何自动 / 批量点星。 - 超时回落是设计,不是缺陷:手机上没应答时桌面照常可以批。页面切走 / 锁屏会关掉 审批 SSE,宿主因此认为「没有手机在线」并立刻把请求交还桌面,而不是空等 120 秒。
-
写入口等于一台终端。一旦放行
/taskwatch/chat/send,能打开这个页面的人就能在 这台机器上驱动 agent —— 而 DSH 默认danger-full-access。所以中继那一层必须有 TLS + 应用层令牌,/taskwatch/chat/*能不转发就不转发。手机页默认只在已有 会话上操作,新建会话也不接受指定工作目录。 -
不要给那一层加 HTTP basic auth。 这条是踩出来的:iOS Safari 在发起 service worker 脚本请求(
/taskwatch/sw.js)时不附带 Basic 凭据,于是那个请求 401、 新 SW 永远装不上,手机就一直停在旧页面上 —— 而服务端一切正常,从外部完全查不出 异常。basic auth 也是三层里最弱的一层(一个可共享的静态口令,改一次要在所有 设备上重输)。要免密码登录,正确做法是让中继发一张有时效的 cookie(例如本机 显示的 6 位配对码换 30 天 cookie,并且每次访问滑动续期),而不是加一层口令。 -
手机上做的每一步都会落进该会话的日志,桌面上打开同一个会话就能看到全部经过 —— 这是"远程操作可追溯"的那一半。
-
插件的路由只在回环上监听,从不对外。
-
跨进程票据密钥放在
<用户主目录>/.dsh/taskwatch-ticket.key(UTF-8、trim后 至少 32 字符)。两边的解析顺序完全一致:环境变量TASKWATCH_TICKET_SECRET→ 该文件 → 都没有时由先用到的一方生成(crypto.randomBytes(32).toString('base64url'))。 Windows 下生成后用icacls <file> /inheritance:r /grant:r <当前用户>:F去掉继承、 只留当前用户;Linux/macOS 用chmod 600。该文件绝不能进任何 git 仓库:插件仓库的.gitignore已加taskwatch-ticket.key与*.key,真实文件在~/.dsh/下、 本来就不在仓库里。 -
票据的「一次性」是进程内的。签名 + 60 秒时效 + 两边各自记住用过的 nonce,把重放 窗口压得很小;跨进程没有共享的「已用」状态 —— 一张从未被这两个进程见过的票,理论上 在有效期内仍可能被重放一次。要严格一次性就得引入共享存储(Redis / SQLite), 当前「换路跳转」的场景不值得为此多一个依赖与故障点。
-
页面带
X-Robots-Tag: noindex, nofollow,响应带nosniff。除/taskwatch/data(见下)外一律no-store。 -
service worker 的作用域限定为
/taskwatch(注册时即指定,脚本也放在/taskwatch/sw.js),不允许它有机会拦截主 GUI 的请求;/taskwatch/chat/*在fetch里被显式放过,尤其不能让 SSE 被它包一层。 -
状态数据不会被离线复用:外壳可以离线打开,但
/taskwatch/data与/taskwatch/session每次都必须回源。断网时返回一份显式的offline: true空快照并在 页面上明说 —— 对监控来说,看到过期状态比看到断连更危险。这里有一处刻意的例外值得说清:
/taskwatch/data用的是no-cache+ETag,而不是no-store。区别在于no-cache允许浏览器存一份副本,但每次都必须回源校验, 只有服务端确认「内容没变」才回 304 并复用。所以页面上永远不会出现未经校验的旧数据, 而每次轮询省下了整份 JSON 的传输。要复用的只是这一份刚被服务端确认过的字节。
页面是从磁盘读的,升级后刷新手机页面就是新版;但审批、问答、文件白名单这些能力是在宿主启动时注册的。 所以刚升级完会出现"界面是新的、批准按钮却不见了"的错位 —— 关掉桌面客户端再打开即可,不需要重装或重新配对。
- 界面文案目前是中文,还没有做多语言。
/taskwatch/session只覆盖内存中的活跃会话(见上)。- 服务没有鉴权,不要把它直接暴露到公网;请自行在前面加一层。
- 会话、任务、子代理各列表最多渲染 40 条(GUI 面板是 30 条,且不像手机页那样提示 「还有 N 条未显示」),避免长会话把页面拖慢。工作流列表目前没有上限——宿主只收 运行中的工作流,通常很小,但契约上没有上界。
- 单条消息文本超过 4000 字符会被截断。
- 手机端能做两件事:一次性批准 / 拒绝一次工具调用(v1.8.0 起),以及 一次性回答选项式提问 / 计划审批(v1.8.3 起)。两件事都只成功一次、都没有 always-allow;只有「当前手机页在线」且这次请求还没被桌面处理时卡片才可点,离线 / 超时 / 桌面先处理都会退回「请在桌面端…」。不能做的是替 agent 修改已有的长文本输入 (例如桌面输入框里已经写了一半的内容、或自由输入的表单字段):那不在任何请求的认领 范围里,手机页也不会拼一段「替你改好」的文字去覆盖它。
- SSE 断线期间是边界情况:页面切走或锁屏会关掉审批 / 问答 SSE,轮询也立即停止,
宿主据此立刻交还请求;15 秒没有一次成功快照时,页脚会明确显示「连接已断开」而不是
假装「没有任务」。若网络抖动但连接没收到 close,宿主仍可能按「在线」等到超时
(最多
approvalTimeoutMs)—— 结果仍是安全的(回落桌面),只是慢。 - 真机验收尚未完成:v1.8.0 的按钮、二次确认、倒计时截图用的是真 Chrome + CDP (DPR2、390 宽)与注入状态,没有在真实手机上点过一次;v1.8.3 的选项式提问同样是 真 Chrome + CDP 的注入状态,没有真机、多题并发、超长选项文本的实测;v1.8.4 的 Wi‑Fi ↔ 4G 切换、锁屏十分钟、iOS bfcache 回来也都只做了真 Chrome 模拟。
- v1.8.1 的「本会话引用过的文件」同样没有真机验证:截图是真 Chrome + CDP,PDF 卡片 只是入口卡片,页面本身不渲染 PDF;超过 50 MB 的 413 也是在临时文件上测的。
- 子代理会话不出现在手机列表里。它们是宿主内部的干活会话(
origin: subagent), 没有父地址连日志都读不了(分页会报subagent Sessions require their durable parent address),混进列表只会让"该点哪个"更难。 - 冷会话标题要现读一次事件日志(每个会话 1~2 次持久化读,实测约 0.5 秒/次)。宿主侧
缓存 10 分钟、手机侧缓存在
localStorage,所以只有第一次打开会话列表会花一两秒, 而且是每 4 个一批刷出来、不是干等到最后。 - 会话列表只补最上面 12 条的标题;再往下的仍显示目录名,滚动不会触发补取 (下次打开才会)。
- 手机页把会话 id 显示成前 10 位加省略号,但那只是排版:完整 id 仍在
data-sid属性里,因为点击展开要用它。不要把短显示当成脱敏。 - 页面还没有设 CSP。它与 DSH Web GUI 同源,而后者没有鉴权且默认
danger-full-access,所以任何一次漏转义都等于一台能执行任意命令的机器的入口。 目前模型正文、工具提示、工具名、会话标题这四处要么走textContent、要么经esc(),也有契约测试钉着 —— 但要说清那条测试的能力边界:它是代理, 盯的是已知的四个入口,不等于"全页面无 XSS"。加一层Content-Security-Policy(脚本用 sha256 hash 而非unsafe-inline)会是更结实的纵深防御。这是本项目 最值得做的下一步加固。
npm run check # bundle、文件边界、上传路由、页面契约与票据 HMAC 互认测试
node scripts/make-icons.mjs # 重新生成图标(自实现 PNG 编码,零依赖)
node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图与预览)(check / icons / demo 也都有对应的 npm script。)
check-bundle.mjs 不只检查客户端:它还真的 import 宿主半边,断言
name / apply / inject 三个导出都在,并把 cordis.patch.yml、exports、
dsh 元数据一并核对。
check-uploads.mjs 会起一个本地 node:http 测试服务器,覆盖混合附件发送、会话隔离、
失败重试、数量 / 总大小限制、错误的请求方法与类型,以及宿主缺少上传服务时的错误提示;
测试只使用内存 fake 服务,不会连接真实模型。
check-ticket-hmac.mjs 覆盖跨进程票据:宿主签 → 中继实例验、中继签 → 宿主验、篡改签名、
过期 / 超长跨度、老格式随机票仍走原 Map、以及密钥文件自动生成(只断言存在与长度,
从不打印内容)。中继那一侧在本机存在时是真的 spawn 一个临时端口实例(临时令牌、
不碰 3099 生产进程,测完杀掉);中继目录不在本机时这两条明确 SKIP,公开仓库的 CI 不会
因此变红。
为什么宿主也要钉住:Cordis 的 inject 决定框架等哪些服务就绪后才调用 apply。
webServer 的服务是在启动阶段之后才发布的,而 apply 在组合期就会被调用——
只写 ctx.get('webServer') 软读会在那一刻拿到 undefined,于是整段路由注册被
静默跳过。现象是插件"装好了、也不报错,但 /taskwatch 一律 404",极难定位。
(v1.0.0 正是踩了这个坑。)同理 kind:'prefix' 与 kind:'exact' 都合法,但
重复的 (kind, path) 会让 register() 抛错——所以路由路径也纳入断言。
scripts/make-icons.mjs 是手写 PNG 编码(Node 内置 zlib + 自实现 CRC32),满幅不透明
方形,图形落在中心 80% 安全区内,因此同一个文件既能当普通图标也能当 maskable。
scripts/make-demo.mjs 从 lib/page.html 生成演示页,只把取数逻辑换成一份固定
假数据。之所以是生成而不是手写:手写就意味着 CSS 与结构有两份副本,必然漂移,截图会
慢慢变成一张不像当前 UI 的图。README 里那两张图就是从生成物截的,所以只要 page.html
变了,重跑脚本再截一次就与实现一致。
Apache-2.0,见 LICENSE。

























