dsh-remote-agent 0.9.4 → 0.9.6

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/CHANGELOG.md CHANGED
@@ -9,6 +9,196 @@
9
9
 
10
10
  ---
11
11
 
12
+ ## [0.9.6] - 2026-10-20
13
+
14
+ ### 用户体验:五项提升("出了问题不知道原因"的正面回应)
15
+
16
+ 用户实测反馈的核心痛点不是功能不够,而是**看不到系统在做什么**。
17
+ 下面五项都指向同一件事:**让每一步的状态可见、每个错误可行动**。
18
+
19
+ #### ④ 统一错误翻译层(`lib/explain-error.js`)
20
+
21
+ 此前原始错误直接抛给用户:
22
+
23
+ connect ECONNREFUSED 100.86.48.54:19387
24
+ handshake timeout: no welcome/accepted within 15000ms
25
+ task t-xxx terminal: timeout after 40000ms
26
+ HTTP 401 {"error":"unauthorized"}
27
+
28
+ 项目里原本有一份 `friendlyConnectError`(`lib/setup.js`),但只覆盖 5 类
29
+ 网络错误、且没用到协议错误码。现在统一到一处,覆盖:
30
+
31
+ - **errno**(ECONNREFUSED / ETIMEDOUT / EADDRINUSE / EADDRNOTAVAIL / …)
32
+ - **协议错误码**(17 个,全部有专门翻译)
33
+ - **HTTP 状态**(401/403/404/405/409/500/502/503/504)
34
+ - **文案特征**(`timeout after Nms` → 换算成秒、`handshake timeout` → 版本差异)
35
+
36
+ **每条都给"下一步做什么"**,并保留原始信息(`raw`)供排查。
37
+
38
+ 设计约束:**绝不抛**、**不猜**(拿不准的如实说"未知,请附原始错误")。
39
+
40
+ #### ① 副机连接状态可见
41
+
42
+ **此前**:副机连不上时主机只显示「0 在线」,**没有任何原因**。
43
+ 用户为此排查了两天(重启回引导页、19387 不监听、SMB 密码…)。
44
+
45
+ **现在**:注册表记录**每一次连接尝试**(成功/失败 + 原因 + 时间 + 远端地址,
46
+ 环形上限 50 条)。面板节点区空态渲染:
47
+
48
+ ✗ newland 连接失败 2 分钟前
49
+ 100.72.146.73:19387 连不上(超时)
50
+ → 检查:① 两台机器能否互 ping;② 主机防火墙;③ 是否同一局域网
51
+ 原始:connect ETIMEDOUT
52
+
53
+ 并区分三种情形(**排查方向完全不同**):
54
+
55
+ - 有失败记录 → 显示原因与修法
56
+ - 从没尝试过 → "副机还没开始连"
57
+ - 连过但现在不在线 → "副机 DSH 可能没在跑"
58
+
59
+ #### ② 一键复制修法
60
+
61
+ 体检项旁边加「复制修法」按钮。修法里常含可直接粘贴的 YAML/命令,
62
+ 让用户手抄是纯粹的摩擦。
63
+
64
+ **只做复制,不做"一键修复"** —— 后者会改用户文件,风险 > 收益
65
+ (我们刚因为改 DSH 文件出过事故)。
66
+
67
+ #### ③ 首次使用向导
68
+
69
+ 配置完成后的"下一步"从一句话变成**编号步骤 + 每步"你应该看到什么"**:
70
+
71
+ 1 重启 DSH 必须
72
+ 关掉 DSH 窗口再打开。配置只在启动时读取。
73
+ 2 生成配对码
74
+ 重启后这个面板会出现「副机接入」按钮 —— 点它,复制那行 dshra1://
75
+ 3 在副机上粘贴
76
+ 副机也装本插件 → 打开面板 → 粘贴 → 保存 → 重启副机 DSH
77
+ 4 等它连上
78
+ 「在线节点」会从 0 变成 1。若一直是 0,看「节点」区的失败原因。
79
+
80
+ 角色选择页也加了一句判断提示:
81
+
82
+ 通常:你现在用的这台(你在这里操作 DSH 的)= 主机;
83
+ 另一台要被派活的电脑 = 副机。
84
+
85
+ #### ⑤ 任务进度摘要
86
+
87
+ 长任务(agent 跑 10 分钟)原来只有展开详情才能看到「进行步骤」。
88
+ 现在**任务行下方**直接显示:
89
+
90
+ 运行中 · 已跑 6m12s
91
+ 最近:调用工具 Write · 共 23 步
92
+
93
+ 只对运行中的任务显示(已完成看结果摘要更有用),只取最后一条有内容的步骤。
94
+
95
+ ### 顺带修复
96
+
97
+ - `lib/node/index.js` 的 `friendlyConnectError` import 已无用(被 explain-error
98
+ 替代),删除
99
+ - `explainError` 的取值路径逐层 try —— `String(err)` 在 `toString` 抛错时
100
+ 会崩,而翻译层的职责是"绝不抛"(测试抓到的真 bug)
101
+
102
+ ### 测试
103
+
104
+ - `test/explain-error.test.js` **20 条**:errno / 协议码全覆盖(断言 17 个
105
+ ERROR_CODE 都有专门翻译,不能落到兜底)/ HTTP / 文案特征 / 健壮性
106
+ (含 `toString` 抛错的恶意对象)/ 渲染
107
+ - doc-drift 新增 88(翻译层覆盖面)、89(连接历史链路)、90(复制按钮 +
108
+ 编号步骤 + 进度摘要)
109
+
110
+ **写测试时又踩了一次"断言自己的问题"**:健壮性用例的消息里写了
111
+ `String(v)`,而那个用例的 `toString` 是故意抛错的 → 断言在构造消息时
112
+ 自己先崩,误报成"实现抛错"。改用下标标识用例。
113
+
114
+ ```
115
+ packaging 4 项
116
+ doc-drift 126/126
117
+ diag:inject 11 项
118
+ tests 1007(1005 pass / 0 fail / 2 skip)
119
+ lint 干净
120
+ ```
121
+
122
+ ## [0.9.5] - 2026-10-20
123
+
124
+ > **紧急修复:0.9.4 装上后 DSH 报激活失败,插件显示"异常"。**
125
+ >
126
+ > ```
127
+ > webserver: duplicate upgrade route "/api/remote-agent/node"
128
+ > 1 entry did not activate remote-agent (dsh-remote-agent)
129
+ > ```
130
+ >
131
+ > 0.9.4 在**全新启动**的场景是好的(我验证过单次 apply 注册一次),
132
+ > 但**热重载会撞车** —— 而 DSH 在插件安装/升级后正是靠热重载生效的。
133
+
134
+ ### 🔴 根因:我们的注册不可回收,而宿主不会帮我们回收
135
+
136
+ `webServer.registerUpgrade(path)` 在 DSH 里是**按 path 查表**的,同路径第二次
137
+ 注册直接抛(组合级契约,它这么做是对的):
138
+
139
+ ```js
140
+ registerUpgrade(route) {
141
+ if (this.upgrades.has(route.path)) throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
142
+ this.upgrades.set(route.path, route)
143
+ return () => { this.upgrades.delete(route.path) }
144
+ }
145
+ ```
146
+
147
+ 而本插件的注册全部收集在 `__disposers` 里,通过 `applyHub()` 的返回值
148
+ (`disposeAll`)交给调用方保管 —— **但 DSH 不认识这个返回值**。热重载时它只
149
+ 重新 `apply`,不会回头调我们的 disposer。于是第二代的注册压在第一代的残留上:
150
+
151
+ ```
152
+ 第一代 apply → 注册 /api/remote-agent/node(表里有了)
153
+ DSH 热重载 → 直接再 apply(不 dispose)
154
+ 第二代 apply → registerUpgrade(同一 path) → throw → 整个 entry 激活失败
155
+ ```
156
+
157
+ 0.9.3 及以前没暴露,是因为那时改完配置都靠"重启 DSH"(进程重来,表是新的);
158
+ 0.9.4 是在插件管理页里安装/升级,走了热重载路径 —— 这是**场景差异,不是新引入的代码**。
159
+
160
+ ### 修复
161
+
162
+ 1. **apply 变成可重入**(`lib/index.js`):按**注册宿主**(`ctx.webServer` 服务
163
+ 实例,读不到时退回 ctx)记住"当前这一代"的 dispose;重新 apply 时先自己把
164
+ 上一代摘干净再装。**不指望宿主帮我们清理** —— 这是唯一在各种宿主行为下都
165
+ 成立的修法。
166
+ 2. **`disposeAll` 改成逆序**(`lib/hub/index.js`):此前是正序跑的,会先拆运行时
167
+ (`registry.dispose()`)再拆工具,工具的 disposer 可能碰到已销毁的 registry。
168
+ 现在与 cordis 的"逆序释放"语义一致。
169
+ 3. **独立监听加了 EADDRINUSE 有限重试**:重入时先 `close()` 旧监听、立刻用同一
170
+ 端口重新 `listen`,在 Windows 上经常还拿不到端口(close 是异步的)。重试
171
+ 4 次(200/400/600ms 退避),真被别的进程占用仍会如实报错。
172
+ 4. **`handles.dispose`**:apply 的返回值现在带一个顶层 `dispose()`,任何愿意调
173
+ 它的宿主都能正确清理。
174
+
175
+ ### 为什么 CI 没拦住(第三次同类)
176
+
177
+ 仓库里所有测试桩都**比宿主宽松**:`hub-worker-e2e.test.js` 的 `makeCtx()` 里
178
+ `register()` 是 `table.set()`(同名静默覆盖)、`registerUpgrade()` 只是再挂一个
179
+ `server.on('upgrade')`。真宿主会抛,桩不会 —— **桩比宿主宽松 = 假绿灯**。
180
+
181
+ 新增 `test/webserver-contract.test.js`:照宿主源码造一个**重复即抛**的严格 ctx,
182
+ 钉住四条契约:
183
+
184
+ - 单次 apply 只注册一条 upgrade 路由
185
+ - **同一 ctx 重复 apply 不抛**(就是本次的事故回归)
186
+ - apply → dispose → apply 可反复进行,且旧世代的 dispose 不得影响新世代
187
+ - 重入后**独立监听仍能绑回同一端口**(热重载不能把端点变成永久不可用)
188
+
189
+ ### 升级须知
190
+
191
+ - 装完请在插件页把开关**关一次再打开**(或重启 DSH),让热重载重新加载新代码。
192
+ - 如果你之前点了「生成口令」,副机要用新口令重新配对。
193
+
194
+ ```
195
+ packaging 4 项
196
+ doc-drift 123/123
197
+ diag:inject 11 项
198
+ tests 987(985 pass / 0 fail / 2 skip)
199
+ lint 干净
200
+ ```
201
+
12
202
  ## [0.9.4] - 2026-10-20
13
203
 
14
204
  > **0.9.3 审查的 19 项全部修完。** 0.9.3 把架构换对了(插件自己开 19387),
package/README.en.md CHANGED
@@ -37,7 +37,7 @@ explicitly pull them.
37
37
  | **Platforms** | Windows (fully verified) · Linux / macOS (CI covered) |
38
38
  | **Dependencies** | **Zero external npm dependencies** —— `node:` built-ins only |
39
39
  | **Node** | `^22.19.0 \|\| >=24.0.0` |
40
- | **Tests** | 982 (980 pass / 2 skip) · 123 doc-drift assertions · eslint strict |
40
+ | **Tests** | 987 (985 pass / 2 skip) · 123 doc-drift assertions · eslint strict |
41
41
 
42
42
  **Web panel**(sidebar → "远程同事" / Remote co-worker):node list with CPU /
43
43
  memory badges, task dispatch, task states, result summaries, workspace changes,
package/README.md CHANGED
@@ -30,12 +30,12 @@
30
30
 
31
31
  | | |
32
32
  |---|---|
33
- | **状态** | 0.9.4 —— 主机 + 副机端到端可用;`exec` 与 `agent` 双链路均已在两台真机上验证通过;CI 覆盖 Windows / macOS / Linux |
33
+ | **状态** | 0.9.5 —— 主机 + 副机端到端可用;`exec` 与 `agent` 双链路均已在两台真机上验证通过;CI 覆盖 Windows / macOS / Linux |
34
34
  | **DSH** | 桌面版 / CLI,0.2.0-rc.2 验证 |
35
35
  | **平台** | Windows(全量验证)· Linux / macOS(CI 覆盖)|
36
36
  | **依赖** | **零外部 npm 依赖** —— 只用 `node:` 内置模块 |
37
37
  | **Node** | `^22.19.0 \|\| >=24.0.0` |
38
- | **测试** | 982 项(980 pass / 2 skip)· 123 条文档漂移断言 · eslint 严格档 |
38
+ | **测试** | 987 项(985 pass / 2 skip)· 123 条文档漂移断言 · eslint 严格档 |
39
39
 
40
40
  **Web 面板**(侧栏「远程同事」):节点列表(含 CPU/内存徽章)、派发任务、
41
41
  任务状态、结果摘要、工作区变更、成品清单、日志尾巴、**环境体检**、
package/lib/client.js CHANGED
@@ -743,13 +743,56 @@ window.__ModuleLoader__.load({
743
743
  h('div', { className: 'dra-kv' }, h('span', { className: 'dra-k' }, '配置文件'), h('span', { className: 'dra-mono dra-ell', title: result.file }, result.file || '')),
744
744
  result.backup ? h('div', { className: 'dra-kv' }, h('span', { className: 'dra-k' }, '备份'), h('span', { className: 'dra-mono dra-ell', title: result.backup }, result.backup)) : null,
745
745
  h('div', { className: 'dra-sec' }, '下一步'),
746
- h('div', { className: 'dra-empty' }, '**重启 DSH** 后生效。重启回来打开这个面板,就能看到完整功能了。'),
747
- result.role === 'hub'
748
- ? h('div', null,
749
- h('div', { className: 'dra-sec' }, '副机怎么接进来'),
750
- h('div', { className: 'dra-empty' }, '重启后这个面板会显示「副机接入」按钮,点它生成配对码,把码发给副机即可。'),
751
- )
752
- : null,
746
+ // ── 编号步骤(2026-10-20,体验提升 ③)─────────────────────
747
+ //
748
+ // 原来只有一句"重启 DSH 后生效",用户不知道接下来分几步、
749
+ // 每步做什么、做完该看到什么。
750
+ //
751
+ // 现在给明确的编号流程,**每步一个动作 + 一个"你应该看到什么"**:
752
+ // 用户卡住时能立刻判断自己停在哪一步。
753
+ h('div', { style: { paddingLeft: '4px' } },
754
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
755
+ h('span', { className: 'dra-badge' }, '1'),
756
+ h('span', { style: { flex: '1' } }, '重启 DSH'),
757
+ h('span', { className: 'dra-dim' }, '必须'),
758
+ ),
759
+ h('div', { className: 'dra-dim', style: { paddingLeft: '28px', marginBottom: '6px' } },
760
+ '关掉 DSH 窗口再打开。配置只在启动时读取。'),
761
+
762
+ result.role === 'hub'
763
+ ? h('div', null,
764
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
765
+ h('span', { className: 'dra-badge' }, '2'),
766
+ h('span', { style: { flex: '1' } }, '生成配对码'),
767
+ ),
768
+ h('div', { className: 'dra-dim', style: { paddingLeft: '28px', marginBottom: '6px' } },
769
+ '重启后这个面板会出现「副机接入」按钮 —— 点它,复制那行 dshra1:// 开头的码。'),
770
+
771
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
772
+ h('span', { className: 'dra-badge' }, '3'),
773
+ h('span', { style: { flex: '1' } }, '在副机上粘贴'),
774
+ ),
775
+ h('div', { className: 'dra-dim', style: { paddingLeft: '28px', marginBottom: '6px' } },
776
+ '副机也装本插件 → 打开面板 → 把码粘进「配对码」框 → 保存 → 重启副机 DSH。'),
777
+
778
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
779
+ h('span', { className: 'dra-badge' }, '4'),
780
+ h('span', { style: { flex: '1' } }, '等它连上'),
781
+ ),
782
+ h('div', { className: 'dra-dim', style: { paddingLeft: '28px' } },
783
+ '回到这个面板 —— 「在线节点」会从 0 变成 1。',
784
+ '若一直是 0,看「节点」区:那里会显示最后一次连接失败的原因和修法。'),
785
+ )
786
+ : h('div', null,
787
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
788
+ h('span', { className: 'dra-badge' }, '2'),
789
+ h('span', { style: { flex: '1' } }, '确认连上了主机'),
790
+ ),
791
+ h('div', { className: 'dra-dim', style: { paddingLeft: '28px' } },
792
+ '重启后打开这个面板 —— 应显示「本机状态」而不是引导页。',
793
+ '若又回到引导页,说明配置被覆盖了:看「最近一次错误」和日志尾巴。'),
794
+ ),
795
+ ),
753
796
  ),
754
797
  )
755
798
  }
@@ -771,6 +814,13 @@ window.__ModuleLoader__.load({
771
814
  head,
772
815
  h('div', { className: 'dra-body' },
773
816
  h('div', { className: 'dra-sec' }, switching ? '改成什么角色?' : '这台电脑要做什么?'),
817
+ // 判断提示(2026-10-20,体验提升 ③):用户第一次打开时不知道选哪个。
818
+ // 实测最常见的困惑是"我要在另一台电脑上跑任务,那我这台算什么?"
819
+ switching
820
+ ? null
821
+ : h('div', { className: 'dra-dim', style: { marginBottom: '10px' } },
822
+ '通常:你现在用的这台(你在这里操作 DSH 的)= 主机;',
823
+ '另一台要被派活的电脑 = 副机。'),
774
824
  // 提示放在卡片**上方**:用户点完按钮后的反馈必须紧邻按钮,
775
825
  // 否则会被滚动位置藏在折叠线以下(实测踩过)。
776
826
  note ? h('div', { className: 'dra-err', style: { marginBottom: '8px' } }, note) : null,
@@ -946,6 +996,8 @@ window.__ModuleLoader__.load({
946
996
  function Panel() {
947
997
  const st = React.useState({
948
998
  loading: true, error: '', nodes: [], tasks: [], counts: null, caps: null,
999
+ // 连接尝试历史(体验提升 ①):null = 还没拉到,空对象 = 拉到了但没记录
1000
+ connection: null,
949
1001
  // 'hub' = 本机是派活方(有 UI 路由)
950
1002
  // 'worker' = 本机是干活方(读 STATUS_PATH)
951
1003
  // 'setup' = 本机未配置(读 SETUP_STATE_PATH,显示引导)
@@ -1336,6 +1388,8 @@ window.__ModuleLoader__.load({
1336
1388
  nodes: ov.nodes || [],
1337
1389
  counts: ov.counts || null,
1338
1390
  caps: ov.capabilities || null,
1391
+ // 连接尝试历史(体验提升 ①):副机连不上时面板要能说清原因
1392
+ connection: ov.connection || null,
1339
1393
  tasks: tk.tasks || [],
1340
1394
  })
1341
1395
  })
@@ -1770,8 +1824,22 @@ window.__ModuleLoader__.load({
1770
1824
  ? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, it.detail)
1771
1825
  : null,
1772
1826
  !it.ok && it.fix
1773
- ? h('div', { className: 'dra-dim', style: { paddingLeft: '20px', marginTop: '2px' } },
1774
- '↳ 怎么修:' + it.fix)
1827
+ ? h('div', { style: { paddingLeft: '20px', marginTop: '2px' } },
1828
+ h('div', { className: 'dra-dim' }, '↳ 怎么修:' + it.fix),
1829
+ // 一键复制(2026-10-20,体验提升 ②):
1830
+ // 修法里常含可直接粘贴的命令/YAML 片段,让用户手抄是纯粹的摩擦。
1831
+ // **只做复制,不做"一键修复"** —— 后者会改用户文件,风险 > 收益
1832
+ // (我们刚因为改 DSH 文件出过事故)。
1833
+ h('button', {
1834
+ className: 'dra-btn',
1835
+ style: { marginTop: '4px', padding: '2px 8px', fontSize: '11px' },
1836
+ onClick: function () {
1837
+ copyText(it.fix).then(function (ok) {
1838
+ setNote(ok ? '已复制修法到剪贴板' : '复制失败,请手动选中复制')
1839
+ })
1840
+ },
1841
+ }, '复制修法'),
1842
+ )
1775
1843
  : null,
1776
1844
  )
1777
1845
  }),
@@ -1873,8 +1941,60 @@ window.__ModuleLoader__.load({
1873
1941
  )
1874
1942
 
1875
1943
  // ── 节点 ──
1944
+ //
1945
+ // 空态不能只说"还没有节点接入"(2026-10-20,体验提升 ①)。
1946
+ // 实测用户为此排查了两天:副机连不上时主机只显示「0 在线」,
1947
+ // **没有任何原因**。现在把连接尝试历史渲染出来:
1948
+ // · 有过失败 → 显示"最后尝试 N 前 —— 人话原因 → 下一步做什么"
1949
+ // · 从没尝试过 → 说明"副机还没开始连"(方向完全不同!)
1950
+ const conn = state.connection || { everConnected: false, byNode: [], recent: [] }
1951
+ const fmtAgo = function (at) {
1952
+ if (!at) return '未知时间'
1953
+ const sec = Math.max(0, Math.round((Date.now() - at) / 1000))
1954
+ if (sec < 60) return sec + ' 秒前'
1955
+ if (sec < 3600) return Math.round(sec / 60) + ' 分钟前'
1956
+ if (sec < 86400) return Math.round(sec / 3600) + ' 小时前'
1957
+ return Math.round(sec / 86400) + ' 天前'
1958
+ }
1959
+
1960
+ const connDiag = function () {
1961
+ const fails = (conn.byNode || []).filter(function (e) { return e.result === 'fail' })
1962
+ if (fails.length > 0) {
1963
+ // 有失败记录:逐台显示最后一次失败的人话
1964
+ return h('div', null,
1965
+ fails.slice(0, 5).map(function (e) {
1966
+ return h('div', { key: (e.nodeId || '') + (e.remote || ''), style: { marginBottom: '8px' } },
1967
+ h('div', { className: 'dra-row', style: { alignItems: 'baseline' } },
1968
+ h('span', { className: 'dra-mono', style: { color: 'var(--dsw-alias-state-error-primary, currentColor)' } }, '✗'),
1969
+ h('span', { style: { flex: '1' } }, (e.nodeId || e.remote || '未知来源') + ' 连接失败'),
1970
+ h('span', { className: 'dra-dim' }, fmtAgo(e.at)),
1971
+ ),
1972
+ e.title
1973
+ ? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, e.title)
1974
+ : null,
1975
+ e.hint
1976
+ ? h('div', { className: 'dra-dim', style: { paddingLeft: '20px' } }, '→ ' + e.hint)
1977
+ : null,
1978
+ e.raw
1979
+ ? h('div', { className: 'dra-dim', style: { paddingLeft: '20px', opacity: 0.6 } }, '原始:' + e.raw)
1980
+ : null,
1981
+ )
1982
+ }),
1983
+ conn.everConnected
1984
+ ? null
1985
+ : h('div', { className: 'dra-dim', style: { marginTop: '4px' } },
1986
+ '这台副机从未成功连上过 —— 先确认副机上的插件已配置 role: worker 并填了正确的 hubUrl。'),
1987
+ )
1988
+ }
1989
+ // 没有任何失败记录:区分"从没尝试"与"连上过但当前离线"
1990
+ return h('div', { className: 'dra-empty' },
1991
+ conn.everConnected
1992
+ ? '副机之前连上过,现在不在线 —— 检查副机 DSH 是否在跑。'
1993
+ : '还没有任何副机尝试连接。在副电脑上装本插件,把 config.role 设为 worker 并填 hubUrl(或用面板的配对码)。')
1994
+ }
1995
+
1876
1996
  const nodeRows = state.nodes.length === 0
1877
- ? h('div', { className: 'dra-empty' }, '还没有节点接入。在副电脑上装本插件并把 config.role 设为 worker。')
1997
+ ? connDiag()
1878
1998
  : state.nodes.map(function (n) {
1879
1999
  // 资源徽章:有遥测才显示(首次采样前 cpuPct 为 null → 不误导)
1880
2000
  const r = n.resource
@@ -2156,6 +2276,37 @@ window.__ModuleLoader__.load({
2156
2276
  const liveSec = t.status === 'running' && t.startedAt
2157
2277
  ? Math.max(0, Math.round((Date.now() - t.startedAt) / 1000))
2158
2278
  : 0
2279
+
2280
+ // ── 进度摘要(2026-10-20,体验提升 ⑤)─────────────────────────
2281
+ //
2282
+ // 原来只有展开详情才能看到「进行步骤」。但长任务(agent 跑 10 分钟)
2283
+ // 用户最想知道的是"它现在在干什么"—— 为此点开详情是多余的摩擦。
2284
+ //
2285
+ // 这里在**任务行下方**加一行摘要:最后一步 + 总步数。
2286
+ // · 只对运行中的任务显示(已完成的任务看结果摘要更有用)
2287
+ // · 只取最后一条有 detail 的(tool/assistant 事件才有内容)
2288
+ let progressLine = null
2289
+ if (t.status === 'running' && Array.isArray(t.progressTail) && t.progressTail.length > 0) {
2290
+ const tail = t.progressTail
2291
+ // 从后往前找第一条带 detail 的("started"/"agent-start" 这类没有内容)
2292
+ let last = null
2293
+ for (let i = tail.length - 1; i >= 0; i -= 1) {
2294
+ const p = tail[i]
2295
+ if (p && typeof p.detail === 'string' && p.detail.trim() !== '') { last = p; break }
2296
+ }
2297
+ if (last) {
2298
+ const phase = typeof last.phase === 'string' ? last.phase : ''
2299
+ progressLine = h('div', {
2300
+ className: 'dra-dim',
2301
+ style: { paddingLeft: '20px', fontSize: '11px', marginTop: '-2px' },
2302
+ title: last.detail,
2303
+ },
2304
+ '最近:' + (phase === 'tool' ? '调用工具 ' : '') + last.detail.slice(0, 120),
2305
+ ' · 共 ' + tail.length + ' 步',
2306
+ )
2307
+ }
2308
+ }
2309
+
2159
2310
  return h('div', { key: t.taskId },
2160
2311
  h('div', {
2161
2312
  className: 'dra-row' + (isSel ? ' sel' : ''),
@@ -2169,6 +2320,7 @@ window.__ModuleLoader__.load({
2169
2320
  ? h('span', { className: 'dra-dim', title: '已运行时长' }, '已跑 ' + fmtDur(liveSec * 1000))
2170
2321
  : (t.durationMs ? h('span', { className: 'dra-dim' }, fmtDur(t.durationMs)) : null),
2171
2322
  ),
2323
+ progressLine,
2172
2324
  isSel ? h(TaskDetail, {
2173
2325
  task: t, detail: detail, caps: caps, onCancel: cancel, onPull: pull,
2174
2326
  onApprove: approve, onReject: reject,