dsh-in-hand 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,417 @@
1
- # Temporary Holding Version
1
+ # 掌上 DSH(dsh-in-hand)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **掌上 DSH(dsh-in-hand)** —— 手机端的 DeepSeek Harness,不用装 App。 它起初只是一份只读的**任务监控页**——让你在手机上
4
+ 瞄一眼 DSH 有几个任务在跑、有没有事在等你;后来接上了 DSH 自己的会话运行时,长成了手机浏览器里
5
+ 的 DSH 客户端:左上角会话列表、右上角新建对话、输入框上方是当前会话的实时指标(轮次 / 步数 /
6
+ token 用量 / 缓存命中率 / 上下文占用),可以直接对话、逐字跟随输出、随时打断。
7
+
8
+ 没有 App:不需要原生代码、不需要打包、不需要上架、不需要装任何东西——浏览器打开,用「添加到
9
+ 主屏幕」就当一个 App 用(PWA,只要求 HTTPS 或 localhost)。
10
+
11
+ A phone client for DeepSeek Harness with **no app to install**: a plain read-only task monitor
12
+ that grew into the phone-side DSH client. Talk to your sessions from the phone, watch the reply
13
+ stream in, interrupt whenever you want; it reuses the desktop GUI's design tokens and adds a live
14
+ metrics row above the composer.
15
+
16
+ ![手机端对话页(亮色)](docs/screenshot-1.png)
17
+
18
+ 手机端不只是状态板:可以直接对话、逐字跟随输出、随时打断。连续的工具调用会折叠成
19
+ 一行「N 个步骤」,**失败的那一步永不隐藏**;助手回复支持轻量 markdown(标题、列表、
20
+ 行内代码、代码块),过宽的代码块会提示可以横滑。
21
+
22
+ ![会话抽屉(暗色)](docs/screenshot-2.png)
23
+
24
+ 会话抽屉、状态卡片、输入框等全部走客户端同一套 design token —— 上图的亮色与这图的
25
+ 暗色都是真值,不是猜的近似色。所以它看起来像客户端的一部分,而不是另一个 App。
26
+
27
+ > 两张图里的会话、任务、对话**全是演示用的假数据**,不是任何真实会话。它们由
28
+ > `docs/demo.html` 渲染——那个文件是 `scripts/make-demo.mjs` 从 `lib/page.html`
29
+ > 生成的(`npm run demo` 重新生成),你可以直接用浏览器打开它,点着看,不需要装
30
+ > 任何东西,也不会发出网络请求。契约测试会校验它与当前页面构建结果一致,防止它
31
+ > 悄悄停在旧界面上。
32
+
33
+ ## 从任务监控长成手机端
34
+
35
+ 第一版只有「读」:一份只读页面,把 DSH 的会话、后台任务、子代理、goal、工作流与待批准
36
+ 渲染成手机上看得懂的样子。目的很朴素——不用坐到电脑前,也能知道它现在在干什么、有没有
37
+ 事在等你。
38
+
39
+ 后来发现同一组窄接口能接上 DSH **自己的**会话运行时,于是「写」只用了 4 条路由就够:
40
+ 对话、逐字收回复、随时停止、切换会话与模型。**读是监控,写是对话**——两者共用一套令牌和
41
+ 一条反向隧道,始终不碰 GUI。
42
+
43
+ 所以这里没有 App 可装:没有原生代码、没有打包产物、没有应用商店。手机上进一次「添加到主
44
+ 屏幕」,之后它就是一个图标;页面按请求读盘、壳由 Service Worker 换版,所以代码更新后下一次
45
+ 打开就会跟上。
46
+
47
+ ## 手机上怎么打开(不用装 App)
48
+
49
+ 1. 在跑 DSH 的电脑上启用本插件,让它监听一个本地端口(见下文各章节)。
50
+ 2. 电脑侧起一个**反向隧道 / 中继**,把那个端口映射到一个 **HTTPS** 地址。
51
+ PWA 的 Service Worker 只在 **HTTPS(或 localhost)** 下工作——纯 HTTP 能看页面,
52
+ 但装不到手机桌面,也没有离线壳。
53
+ 3. 手机上打开那个 HTTPS 地址,首次用一次性**配对码**换一个长期令牌,之后自动续期;
54
+ 用浏览器的「添加到主屏幕」就能当 App 用。
55
+
56
+ > 反代与隧道属于部署细节,本仓库只负责 DSH 这一侧:域名 + nginx + `ssh -R` 可以,
57
+ > Tailscale、Cloudflare Tunnel、ngrok 之类同样可以(它们都能免费给出 HTTPS)。
58
+ > **自己的专属入口链接里带访问令牌,等于完整控制权,绝不要提交到本仓库。**
59
+
60
+ > 两张图里的会话、任务、对话**全是演示用的假数据**,不是任何真实会话。它们由
61
+ > `docs/demo.html` 渲染——那个文件是 `scripts/make-demo.mjs` 从 `lib/page.html`
62
+ > 生成的(`npm run demo` 重新生成),你可以直接用浏览器打开它,点着看,不需要装
63
+ > 任何东西,也不会发出网络请求。契约测试会校验它与当前页面构建结果一致,防止它
64
+ > 悄悄停在旧界面上。
65
+
66
+ ## 为什么需要它
67
+
68
+ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-access`。把整个 GUI
69
+ 暴露到公网,等于把一台能执行任意命令的机器的入口挂出去。
70
+
71
+ 这个插件的做法是**不暴露 GUI,另开一组窄接口**:
72
+
73
+ - **读**:把状态渲染成一份手机看得懂的页面,只放行这一条路径就够。
74
+ - **写**:`/taskwatch/chat/*` 把 DSH **自己的**会话运行时接到手机上 —— 直接对话、
75
+ 逐字收回复、随时停止、切换会话与模型。写入口**只有 4 条**,全部列在下面。
76
+
77
+ 采集面完全通过 `ctx.get(...)` 软读取服务,任何一个服务不存在都只会让对应的那一块
78
+ 留空,不会让插件挂载失败。对话那一组同理:拿不到 `sessionController` 时只让这一个
79
+ 功能返回明确错误,不影响只读页面。
80
+
81
+ ## 采集面
82
+
83
+ | 区块 | 内容 |
84
+ |---|---|
85
+ | 需要你介入 | 等待中的 `approval/request` 与 `user-questions/request` |
86
+ | 会话 | 运行/空闲、最后活动时间、该会话在跑的后台任务数、preset、是否仅日志 |
87
+ | 会话详情 | 点开后:目标、待介入事项、属于它的后台任务与子代理、最近对话 |
88
+ | 目标 | phase、轮次 `started/max`、activation、阻塞原因 |
89
+ | 后台任务 | id、label(多行命令已折成单行)、kind、status、生命周期时长、属主会话 |
90
+ | 子代理树 | 层级、深度、父会话、activity、mode |
91
+ | 工作流 | 当前 phase、日志条数、已运行时长 |
92
+ | 采集诊断 | 采集过程中单项失败的原因(不静默吞掉) |
93
+
94
+ ## HTTP 路由
95
+
96
+ 读(`GET`):
97
+
98
+ | 路径 | 类型 | 说明 |
99
+ |---|---|---|
100
+ | `/taskwatch` | HTML | 手机页:以对话为主,顶部状态条点开是任务详情 |
101
+ | `/taskwatch/data` | JSON | 全部状态的快照,带 `ETag`,未变化回 `304` |
102
+ | `/taskwatch/session?id=&limit=` | JSON | 单个会话的最近对话(`limit` 上限 60,默认 20) |
103
+ | `/taskwatch/file?id=` · `?path=` · `?info=1` | 二进制 / JSON | 交付文件:附件走 `id`,绝对路径走白名单根(图片 / PDF / 文本 / 视频);`info=1` 只回元数据 |
104
+ | `/taskwatch/chat/sessions` | JSON | 可对话的会话列表(**不含子代理会话**) |
105
+ | `/taskwatch/chat/workspaces` | JSON | 新建对话可选的工作区(现有会话的 `cwd` + 固定候选 + 环境变量追加;只列真实存在的目录) |
106
+ | `/taskwatch/chat/models` | JSON | 可选模型目录 |
107
+ | `/taskwatch/chat/titles?ids=` | JSON | 批量补会话标题(最多 20 个,宿主缓存 10 分钟) |
108
+ | `/taskwatch/chat/page?...` | JSON | 往更早翻会话历史 |
109
+ | `/taskwatch/chat/stream?sessionId=` | SSE | 跟随一个会话:开场快照 + 增量事件 + 逐字输出 |
110
+ | `/taskwatch/manifest.webmanifest` | JSON | PWA manifest |
111
+ | `/taskwatch/sw.js` | JS | service worker |
112
+ | `/taskwatch/icon-192.png` · `/taskwatch/icon-512.png` | PNG | 图标 |
113
+
114
+ 写(`POST`)—— **只有这 4 条**:
115
+
116
+ | 路径 | 请求体 | 说明 |
117
+ |---|---|---|
118
+ | `/taskwatch/chat/send` | `{sessionId,text,mode?,timeZone?}` | 给会话发一条消息 |
119
+ | `/taskwatch/chat/cancel` | `{sessionId}` | 停止当前这一轮 |
120
+ | `/taskwatch/chat/create` | `{}` 或 `{workspace}` | 新建会话;`workspace` 必须命中 `/taskwatch/chat/workspaces` 列出的路径 |
121
+ | `/taskwatch/chat/model` | `{sessionId,provider,model}` | 切换该会话使用的模型 |
122
+
123
+ 全部注册在 `ctx.webServer` 上,不代理 DSH 自身的 `/api`,也不转发任意路径。
124
+ 写入口就只有上面 4 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
125
+ (早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了 4 个写路由之后它
126
+ 照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。
127
+
128
+ ### 手机能选工作区,但选不了任意目录
129
+
130
+ 「在手机上新开一个对话」得能指定在哪个目录开工,否则只能落在部署默认值上。但
131
+ `sessionController.create()` 收的是绝对路径(`cwd` / `workspaceId`)—— 直接透给公网
132
+ 客户端,等于把宿主的磁盘交出去。所以拆成两件事:
133
+
134
+ - **选择权给手机**:`GET /taskwatch/chat/workspaces` 回候选清单,页面只负责挑;
135
+ - **决定权留服务端**:`POST /taskwatch/chat/create` 拿客户端发来的字符串去清单里
136
+ **逐字比对**,命中才写进 `cwd`,否则直接报错。
137
+
138
+ 清单三个来源:现有会话的 `cwd`(你最近在哪儿干活,天然是你自己认过的目录)、
139
+ 环境变量 `DSH_TASKWATCH_WORKSPACES`(`;` 分隔,默认 `D:\Projects`,装到别处改它)、
140
+ 固定候选。不存在的目录用 `statSync` 过滤掉 —— 手机上少一个点进去才报错的入口。
141
+
142
+ ### 对话路由为什么这么薄
143
+
144
+ 手机端要的「像 ChatGPT 一样对话」,DSH 内部**本来就有** —— `sessionController`
145
+ 提供 `prompt` / `follow` / `cancel` / `list` / `create` / `page` / `modelCatalog` /
146
+ `selectModel`。所以这个插件一行 LLM 调用、一行流式解析都没写,只是把这几个方法
147
+ 转成 HTTP。三个实测出来的坑记在这里:
148
+
149
+ - `follow()` 返回的本来就是**为传输设计的 wire 帧**(`SessionWireEvent.data` 是
150
+ `JsonValue`),可以安全地逐帧 `JSON.stringify`;不要去碰会话活对象。
151
+ - SSE 响应必须显式带 `x-accel-buffering: no`,否则 nginx 会把流式缓冲成一次性吐出。
152
+ - `tool/result` 帧的 `callId` **不在顶层**,在 `data.message.source.callId`。
153
+ 按顶层读会一直拿到 `undefined`,表现是每来一个工具结果就多插一张错卡。
154
+ - 会话标题只能从事件日志的 `session/title` 帧里取 —— `SessionSummary` 里**没有**
155
+ title 字段。分页语义也是实测的:`throughSeq` 超过会话 cursor 会报
156
+ `past cursor N`,而 N 正是需要的值,于是用错误信息一步重试。
157
+
158
+ ### 交付文件为什么按内容识别,而不是按扩展名
159
+
160
+ 手机端要能**直接看到**交付的图和 PDF(而不是只收到一个文件名),所以加了一条只读的
161
+ `/taskwatch/file`。它的安全边界抽在 `lib/files.js` 里,配 36 项边界测试:目录穿越、
162
+ 同前缀兄弟目录、白名单外路径、realpath 复查(防符号链接逃逸)、`.svg`/`.html`/`.js`
163
+ 一律拒绝。
164
+
165
+ 一个真实验收抓到的教训值得写下来:宿主附件库是**内容寻址**的,模型产出的图落在
166
+ `~/.dsh/attachments/v1/objects/<hash>` —— **名字就是哈希、根本没有扩展名**。
167
+ 最初只按扩展名判类型,于是最常用的那条路("把刚生成的那张图发我")被判成
168
+ `unsupported-type` 直接 403。而演示页一切正常,因为它用的是 data URI 和有扩展名的
169
+ 路径 —— 这类「演示绿、真机红」的差异,只有端到端跑一次才会露出来。
170
+
171
+ 现在:有扩展名走快路;没有扩展名就读前 16 字节按魔数识别(PNG / JPEG / GIF / WebP /
172
+ PDF / ZIP / MP4)。这是**按内容识别**,不是"按内容放行" —— html / svg / js 没有魔数,
173
+ 仍然走不通,白名单没有被放宽。
174
+
175
+ ### 手机页的动效为什么这么克制
176
+
177
+ 第 10 节「纸墨微动」:静态是一张纸,动起来像墨。位移 ≤3px、时长 120–320ms、
178
+ 曲线统一"起步快、末段收",**每个动效都携带信息** —— 在跑 = 一圈墨环在转、
179
+ 完成 = 勾自己画出来、失败 = 顿一下、图 = 骨架纸纹后显影、新消息 = 浮标弹一下。
180
+ 不放光、不放烟花、不为动而动。
181
+
182
+ 两条硬约束写进了契约测试(不靠肉眼):
183
+
184
+ 1. **入场动画必须由 JS 按"状态刚刚变化"加的类门控**。挂在会被整段重渲染的节点上,
185
+ 每 3 秒轮询就重放一次,观感从"灵动"变"抽搐"。
186
+ 2. **"隐藏"只能挂在 JS 之后加的类上**。先藏内容、等 JS 显示是这类动效最常见的自伤:
187
+ JS 一旦不跑,图片就永远看不见。
188
+
189
+ 缩小动效时也不是简单全关:墨环变成静态描边、骨架图直接可见 —— 动效可以没有,
190
+ 状态语义不能丢。
191
+
192
+ ### 手机页为什么这样排
193
+
194
+ 要解决的是「小白点进来也看得懂」。所以页面**不做仪表盘**:顶部一行说人话的状态
195
+ (点开才是原来那张数据表),中间是对话,底部是输入框,数据表格收进抽屉。
196
+
197
+ 两个具体决定:
198
+
199
+ - **工具调用翻译成人话**。`pwsh` → 「运行命令」、`grep` → 「搜索内容」、`subagent` →
200
+ 「派发子任务」。用户不需要知道 `pwsh` 是什么;卡片上还给一行命令/路径摘要,状态用
201
+ 进行中 / 完成 / 失败三态。这是"看得懂进度"最关键的一处。
202
+ - **逐字输出只改一个节点**。整页重建会让长会话每来一个字都重排一次。
203
+
204
+ ### 页面为什么不做整页重建
205
+
206
+ 页面每 3 秒轮询一次,但**数据没变就完全不碰 DOM**,只刷新右上角时间戳。首版是
207
+ `root.innerHTML = h` 全量重建,结果是每 3 秒把页面拆一次:展开的东西 3 秒后被抹掉,
208
+ 滚动位置被打断,看起来像「不刷新」和「点不动」——那是同一个原因。
209
+
210
+ 这里有个容易漏掉的坑:**比较序列化结果时不能把 `generatedAt` 算进去。** 它是每次采集
211
+ 都不同的 `Date.now()`,带着它比较,签名就永远在变,「数据没变就不碰 DOM」实际上是
212
+ 一句空话,页面照样每 3 秒整页重建。所以客户端算签名、服务端算 `ETag` 时都先把这个
213
+ 字段归一化,两边保持同一套判据。
214
+
215
+ 而「有东西在跑的时候」仍然要重画:画面上的时长要一直往上走。所以静止时就真的不碰
216
+ DOM,有运行中的会话、后台任务、子代理或工作流时才重画。
217
+
218
+ 展开状态与对话缓存存在 JS 对象里,跨轮询保留;点击用事件委托挂在常驻容器上,
219
+ 所以内层 DOM 重建也不会丢监听。对话按需懒加载,点开一次即缓存。
220
+
221
+ ### 轮询在服务端和手机上各收一次口
222
+
223
+ 页面每 3 秒轮询,而这个页面的主要用途是**手机**——最常见的状态是「揣在兜里、屏幕已熄」。
224
+ 不处理的话,一个常驻后台的标签页会永远每 3 秒发一次请求。所以两头都做了收敛:
225
+
226
+ - **客户端**:监听 `visibilitychange`,页面隐藏时 `clearInterval` 停止轮询,回到前台立刻
227
+ 补一次请求。手机上省的是流量和电。
228
+ - **服务端**:`snapshot()` 一次要问一圈服务(每个 agent 的 jobs、每个会话的
229
+ `eventAt`/`title`/`goal`、每个 agent 的 `listDescendants`)。现在同一份采集在
230
+ **2 秒 TTL** 内被复用,并发请求共用同一个 Promise 而不是各算各的——手机、桌面面板和
231
+ 多个标签页同时开着时,这一层把开销从「每请求一次全量采集」压成「最多每 2 秒一次」。
232
+ - **带宽**:`/taskwatch/data` 返回 `ETag` 并在未变化时回 `304`,手机上只交换一个头而不是
233
+ 整份 JSON。
234
+
235
+ 最后一条有个**必须成对**的地方:页面取数用的是 `cache:'no-cache'`,不能用 `no-store`。
236
+ `no-store` 会让浏览器根本不保存响应,也就永远不会带 `If-None-Match` 回来,服务端那段
237
+ `304` 会变成死代码。这两处因此都在契约测试里钉住了。
238
+
239
+ ### 采集失败时不能伪装成正常
240
+
241
+ 这是监控工具最危险的失败姿态,所以单独说清。
242
+
243
+ 宿主采集异常时 `/taskwatch/data` 回的是 **HTTP 200 + `{ "error": ... }`**。如果客户端
244
+ 把这份载荷当数据用,画出来就是一块「全部为 0」的板子:0 个会话、0 个后台任务、
245
+ 「当前没有等待中的审批或提问」。**它看起来像「真的没有任务」,而不是「没读到」。**
246
+
247
+ 所以两半都显式处理 `error` 字段,把它当错误而不是数据:
248
+
249
+ - **手机页**:`tick()` 先查 `d.error`,命中就只在页脚写「采集失败:…」并保留上一份
250
+ 可用快照,绝不拿它去重画。
251
+ - **GUI 面板**:把 `snap.error` 视为错误,继续渲染**上一份可用快照**,并在顶部挂一条
252
+ 「数据可能已陈旧(快照时间 HH:MM:SS):…」的横幅。一次网络抖动同样只产生这条横幅,
253
+ 而不是把整块面板清成空白 —— 监控面板的价值恰恰在于「断开时还能看到最后一帧」。
254
+
255
+ 同一类问题的另一面是轮询的**乱序**:并发请求下,较早发出的响应完全可能晚回来。两半
256
+ 都用一个自增序号只接受最新一次的结果,避免旧数据覆盖新数据。
257
+
258
+ ### `/taskwatch/session` 的两条限制
259
+
260
+ 1. **只能读活着的会话。** `ctx.sessions` 是内存存储,`get(id)` 返回 `undefined` 时接口
261
+ 会**明确报错**「这个会话不在内存里(已落盘但未加载的会话读不到)」,而不是返回一个
262
+ 空的 `messages` —— 后者会让人误以为那个会话真的没说话。
263
+ 2. **只读不写。** 没有发送消息、批准审批、打断运行的入口。要做到那些,必须把 DSH 的
264
+ Web GUI 暴露出去,那是另一个量级的风险,本插件刻意不做。
265
+
266
+ 消息只取叶子字段(role、text、工具名、时间),不把活的会话日志对象序列化出去。
267
+
268
+ ## GUI 面板
269
+
270
+ `lib/client.js` 注册一个侧边栏面板(`sidebar.panellist`),数据取自同源的
271
+ `/taskwatch/data`,**不需要任何 host RPC**。样式全部挂在插件自己的 fiber 上,
272
+ 卸载即还原。
273
+
274
+ 面板右上角有一个指向手机页的链接,方便直接把 `/taskwatch` 复制到手机上打开。
275
+
276
+ 面板和手机页在轮询上是对称的:窗口不可见时 `clearInterval` 停止轮询,回到前台立刻
277
+ 补一次请求。桌面这一半容易被忽略,但它的典型用法恰恰是「GUI 最小化、拿手机看」。
278
+
279
+ ## 安装
280
+
281
+ ```sh
282
+ dsh plugin --profile web add dsh-in-hand
283
+ ```
284
+
285
+ 装完需要**重启 `dsh web`**:插件行来自 profile 的 bundle 图,而 client 侧模块元数据
286
+ 按 specifier 缓存且没有失效路径,所以增删插件一律要重启才生效。
287
+
288
+ 本包**没有构建步骤**:`lib/` 是提交进仓库的、可直接运行的产物,客户端 bundle 就是
289
+ 一个调用 `window.__ModuleLoader__.load({ id, factory })` 的普通脚本。从源码安装不需要
290
+ 任何构建授权(`allowBuilds`)。
291
+
292
+ 本地开发时也可以直接指向工作副本:
293
+
294
+ ```sh
295
+ dsh plugin --profile web add <你的绝对路径>/dsh-in-hand
296
+ ```
297
+
298
+ 补丁里引用插件有两种写法,注意区别:bundle 自带的 `cordis.patch.yml` 用**包名**
299
+ (由 profile 的 `node_modules` 解析),而 profile 自带补丁里若写路径,必须是绝对路径
300
+ (相对路径是相对该补丁文件解析的,跨盘会失败)。
301
+
302
+ ## 在外网 / 手机上访问(需要你自己叠一层)
303
+
304
+ **本插件自己不做鉴权,只监听回环。** 它提供的是「可被安全转发的只读源」,转发这一层
305
+ 由你自己的隧道或反向代理负责。一个已验证可用的组合是:
306
+
307
+ ```
308
+ 手机 / 外网
309
+ └─ HTTPS <你的域名> 反向代理:TLS 终止
310
+ └─ 隧道回到本机 ssh -R,或任何等价隧道
311
+ └─ 本机只读中继 校验应用层共享令牌
312
+ ├─ /taskwatch、/taskwatch/data、/taskwatch/session → DSH 的回环端口
313
+ └─ manifest / sw.js / 图标 → 中继本地文件
314
+ ```
315
+
316
+ 配套的只读中继是刻意做成**另一件事**的,不属于本插件:它是几十行的零依赖 Node 脚本,
317
+ 把白名单里的固定路径转发到 DSH 的回环端口,其余一律 404。想自己写的话,要点就四条:
318
+
319
+ 1. **白名单硬编码**,不要让上游路径可配置 —— 否则它就成了一个任意代理。
320
+ 2. **只允许 GET/HEAD**,不接受请求体,不转发 `/api`。
321
+ 3. **自带一层共享令牌**,定长比较(`timingSafeEqual`),并且不要把令牌转发给上游。
322
+ 4. **转发时要带上查询串**。这一点踩过坑:漏掉它时 `/taskwatch/session` 会返回
323
+ HTTP 200 但内容永远是「缺少 id 参数」——状态码看着完全正常,只是数据永远为空。
324
+
325
+ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上游插件的版本变化而失效;
326
+ 页面文件与插件共用同一份,避免两边漂移。
327
+
328
+ ## 安全边界
329
+
330
+ - **写入口等于一台终端**。一旦放行 `/taskwatch/chat/send`,能打开这个页面的人就能在
331
+ 这台机器上驱动 agent —— 而 DSH 默认 `danger-full-access`。所以中继那一层必须有
332
+ **TLS + 应用层令牌**,`/taskwatch/chat/*` 能不转发就不转发。手机页默认只在**已有**
333
+ 会话上操作,新建会话也不接受指定工作目录。
334
+ - **不要给那一层加 HTTP basic auth。** 这条是踩出来的:iOS Safari 在发起 service
335
+ worker 脚本请求(`/taskwatch/sw.js`)时**不附带** Basic 凭据,于是那个请求 401、
336
+ 新 SW 永远装不上,手机就一直停在旧页面上 —— 而服务端一切正常,从外部完全查不出
337
+ 异常。basic auth 也是三层里**最弱**的一层(一个可共享的静态口令,改一次要在所有
338
+ 设备上重输)。要免密码登录,正确做法是让中继发一张**有时效的 cookie**(例如本机
339
+ 显示的 6 位配对码换 30 天 cookie,并且每次访问滑动续期),而不是加一层口令。
340
+ - 手机上做的每一步都会落进该会话的日志,桌面上打开同一个会话就能看到全部经过 ——
341
+ 这是"远程操作可追溯"的那一半。
342
+ - 插件的路由只在**回环**上监听,从不对外。
343
+ - 页面带 `X-Robots-Tag: noindex, nofollow`,响应带 `nosniff`。除 `/taskwatch/data`
344
+ (见下)外一律 `no-store`。
345
+ - service worker 的作用域**限定为 `/taskwatch`**(注册时即指定,脚本也放在
346
+ `/taskwatch/sw.js`),不允许它有机会拦截主 GUI 的请求;`/taskwatch/chat/*`
347
+ 在 `fetch` 里被显式放过,尤其不能让 SSE 被它包一层。
348
+ - 状态数据**不会被离线复用**:外壳可以离线打开,但 `/taskwatch/data` 与
349
+ `/taskwatch/session` 每次都必须回源。断网时返回一份显式的 `offline: true` 空快照并在
350
+ 页面上明说 —— 对监控来说,看到过期状态比看到断连更危险。
351
+
352
+ 这里有一处刻意的例外值得说清:`/taskwatch/data` 用的是 `no-cache` + `ETag`,而不是
353
+ `no-store`。区别在于 `no-cache` 允许浏览器**存**一份副本,但**每次都必须回源校验**,
354
+ 只有服务端确认「内容没变」才回 304 并复用。所以页面上永远不会出现未经校验的旧数据,
355
+ 而每次轮询省下了整份 JSON 的传输。要复用的只是这一份**刚被服务端确认过**的字节。
356
+
357
+ ## 已知限制
358
+
359
+ - 界面文案目前是**中文**,还没有做多语言。
360
+ - `/taskwatch/session` 只覆盖内存中的活跃会话(见上)。
361
+ - 服务没有鉴权,**不要**把它直接暴露到公网;请自行在前面加一层。
362
+ - 会话、任务、子代理各列表最多渲染 40 条(GUI 面板是 30 条,且不像手机页那样提示
363
+ 「还有 N 条未显示」),避免长会话把页面拖慢。工作流列表目前没有上限——宿主只收
364
+ 运行中的工作流,通常很小,但契约上没有上界。
365
+ - 单条消息文本超过 4000 字符会被截断。
366
+ - **手机页只能对话,不能审批**。状态条会告诉你「有 N 件事在等你」,但批准/回答仍然
367
+ 只能在桌面上做 —— 也可以直接在对话里让 agent 继续,那才是它的等价物。
368
+ - **子代理会话不出现在手机列表里**。它们是宿主内部的干活会话(`origin: subagent`),
369
+ 没有父地址连日志都读不了(分页会报 `subagent Sessions require their durable parent
370
+ address`),混进列表只会让"该点哪个"更难。
371
+ - 冷会话标题要现读一次事件日志(每个会话 1~2 次持久化读,实测约 0.5 秒/次)。宿主侧
372
+ 缓存 10 分钟、手机侧缓存在 `localStorage`,所以只有第一次打开会话列表会花一两秒,
373
+ 而且是每 4 个一批刷出来、不是干等到最后。
374
+ - 会话列表只补**最上面 12 条**的标题;再往下的仍显示目录名,滚动不会触发补取
375
+ (下次打开才会)。
376
+ - 手机页把会话 id 显示成前 10 位加省略号,但那**只是排版**:完整 id 仍在 `data-sid`
377
+ 属性里,因为点击展开要用它。不要把短显示当成脱敏。
378
+ - 页面**还没有设 CSP**。它与 DSH Web GUI **同源**,而后者没有鉴权且默认
379
+ `danger-full-access`,所以任何一次漏转义都等于一台能执行任意命令的机器的入口。
380
+ 目前模型正文、工具提示、工具名、会话标题这四处要么走 `textContent`、要么经
381
+ `esc()`,也有契约测试钉着 —— 但要说清那条测试的能力边界:它是**代理**,
382
+ 盯的是已知的四个入口,不等于"全页面无 XSS"。加一层 `Content-Security-Policy`
383
+ (脚本用 sha256 hash 而非 `unsafe-inline`)会是更结实的纵深防御。这是本项目
384
+ 最值得做的下一步加固。
385
+
386
+ ## 开发
387
+
388
+ ```sh
389
+ node scripts/check-bundle.mjs # 客户端 bundle + 宿主契约 + 页面的测试(54 项)
390
+ node scripts/make-icons.mjs # 重新生成图标(自实现 PNG 编码,零依赖)
391
+ node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图与预览)
392
+ ```
393
+
394
+ (`check` / `icons` / `demo` 也都有对应的 npm script。)
395
+
396
+ `check-bundle.mjs` 不只检查客户端:它还**真的 import 宿主半边**,断言
397
+ `name` / `apply` / `inject` 三个导出都在,并把 `cordis.patch.yml`、`exports`、
398
+ `dsh` 元数据一并核对。
399
+
400
+ 为什么宿主也要钉住:Cordis 的 `inject` 决定框架等哪些服务就绪后才调用 `apply`。
401
+ `webServer` 的服务是在启动阶段之后才发布的,而 `apply` 在组合期就会被调用——
402
+ 只写 `ctx.get('webServer')` 软读会在那一刻拿到 `undefined`,于是整段路由注册被
403
+ 静默跳过。现象是插件"装好了、也不报错,但 `/taskwatch` 一律 404",极难定位。
404
+ (v1.0.0 正是踩了这个坑。)同理 `kind:'prefix'` 与 `kind:'exact'` 都合法,但
405
+ 重复的 `(kind, path)` 会让 `register()` 抛错——所以路由路径也纳入断言。
406
+
407
+ `scripts/make-icons.mjs` 是手写 PNG 编码(Node 内置 zlib + 自实现 CRC32),满幅不透明
408
+ 方形,图形落在中心 80% 安全区内,因此同一个文件既能当普通图标也能当 maskable。
409
+
410
+ `scripts/make-demo.mjs` 从 `lib/page.html` **生成**演示页,只把取数逻辑换成一份固定
411
+ 假数据。之所以是生成而不是手写:手写就意味着 CSS 与结构有两份副本,必然漂移,截图会
412
+ 慢慢变成一张不像当前 UI 的图。README 里那两张图就是从生成物截的,所以只要 `page.html`
413
+ 变了,重跑脚本再截一次就与实现一致。
414
+
415
+ ## 许可证
416
+
417
+ Apache-2.0,见 [LICENSE](LICENSE)。
@@ -0,0 +1,4 @@
1
+ # dsh bundle patch: 把本插件插进 profile 的层栈。
2
+ - insert:
3
+ - id: dsh-in-hand
4
+ name: dsh-in-hand